Backend: product sub-choices, sort_order, preference shared_subset, hard-delete

- ProductOption and ProductPreferenceChoice gain sub_choices (JSON Text column)
  for nested inline choices shown when the parent is selected
- ProductPreferenceSet gains default_choice_id and shared_subset (set-level
  sub-choice group shown for all choices that don't disable it)
- Product gains sort_order column; list endpoint orders by sort_order
- New PUT /products/reorder endpoint for drag-and-drop ordering
- DELETE /products/{id} now accepts ?hard=true for permanent deletion (blocked
  if product appears in any past order)
- Schemas updated with model_validators to parse stored JSON back to typed objects
- Add python-multipart to requirements (needed for file upload form parsing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 09:27:16 +03:00
parent 5dbb775308
commit 2c9276e654
4 changed files with 182 additions and 29 deletions

View File

@@ -1,5 +1,6 @@
from pydantic import BaseModel
from typing import Optional, List
import json
from pydantic import BaseModel, model_validator, field_validator
from typing import Optional, List, Any
class CategoryCreate(BaseModel):
@@ -30,21 +31,43 @@ class CategoryReorderItem(BaseModel):
# ── Options ──────────────────────────────────────────────────────────────────
class OptionSubChoice(BaseModel):
name: str
extra_cost: float = 0.0
is_default: bool = False
class ProductOptionBase(BaseModel):
name: str
extra_cost: float = 0.0
class ProductOptionCreate(ProductOptionBase):
pass
sub_choices: List[OptionSubChoice] = []
class ProductOptionOut(ProductOptionBase):
id: int
product_id: int
sub_choices: List[OptionSubChoice] = []
model_config = {"from_attributes": True}
@model_validator(mode='before')
@classmethod
def parse_option_sub_choices(cls, data: Any) -> Any:
if hasattr(data, 'sub_choices'):
raw = data.sub_choices
parsed = json.loads(raw) if isinstance(raw, str) else []
return {
'id': data.id,
'product_id': data.product_id,
'name': data.name,
'extra_cost': data.extra_cost,
'sub_choices': parsed,
}
return data
# ── Ingredients ───────────────────────────────────────────────────────────────
@@ -64,39 +87,108 @@ class ProductIngredientOut(ProductIngredientBase):
model_config = {"from_attributes": True}
# ── Preferences ───────────────────────────────────────────────────────────────
# ── Sub-choices (nested under a preference choice) ────────────────────────────
class PreferenceChoiceBase(BaseModel):
class SubChoice(BaseModel):
name: str
extra_cost: float = 0.0
is_default: bool = False
class PreferenceChoiceCreate(PreferenceChoiceBase):
pass
# ── Shared subset (set-level, shown for all non-disabling choices) ─────────────
class SharedSubsetChoice(BaseModel):
name: str
extra_cost: float = 0.0
is_default: bool = False
class PreferenceChoiceOut(PreferenceChoiceBase):
class SharedSubset(BaseModel):
name: str
choices: List[SharedSubsetChoice] = []
# ── Preferences ───────────────────────────────────────────────────────────────
class PreferenceChoiceCreate(BaseModel):
name: str
extra_cost: float = 0.0
sub_choices: List[SubChoice] = []
disables_subset: bool = False
class PreferenceChoiceOut(BaseModel):
id: int
set_id: int
name: str
extra_cost: float = 0.0
sub_choices: List[SubChoice] = []
disables_subset: bool = False
model_config = {"from_attributes": True}
@model_validator(mode='before')
@classmethod
def parse_sub_choices(cls, data: Any) -> Any:
if hasattr(data, 'sub_choices'):
raw = data.sub_choices
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except Exception:
parsed = []
else:
parsed = []
return {
'id': data.id,
'set_id': data.set_id,
'name': data.name,
'extra_cost': data.extra_cost,
'sub_choices': parsed,
'disables_subset': data.disables_subset or False,
}
return data
class PreferenceSetBase(BaseModel):
class PreferenceSetCreate(BaseModel):
name: str
class PreferenceSetCreate(PreferenceSetBase):
choices: List[PreferenceChoiceCreate] = []
default_choice_index: Optional[int] = None # index into choices (0-based)
shared_subset: Optional[SharedSubset] = None
class PreferenceSetOut(PreferenceSetBase):
class PreferenceSetOut(BaseModel):
id: int
product_id: int
name: str
choices: List[PreferenceChoiceOut] = []
default_choice_id: Optional[int] = None
shared_subset: Optional[SharedSubset] = None
model_config = {"from_attributes": True}
@model_validator(mode='before')
@classmethod
def parse_shared_subset(cls, data: Any) -> Any:
if hasattr(data, 'shared_subset'):
raw = data.shared_subset
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except Exception:
parsed = None
else:
parsed = None
return {
'id': data.id,
'product_id': data.product_id,
'name': data.name,
'choices': list(data.choices),
'default_choice_id': data.default_choice_id,
'shared_subset': parsed,
}
return data
# ── Products ──────────────────────────────────────────────────────────────────
@@ -106,6 +198,7 @@ class ProductBase(BaseModel):
base_price: float
is_available: bool = True
printer_zone_id: Optional[int] = None
sort_order: int = 0
class ProductCreate(ProductBase):
@@ -120,11 +213,17 @@ class ProductUpdate(BaseModel):
base_price: Optional[float] = None
is_available: Optional[bool] = None
printer_zone_id: Optional[int] = None
sort_order: Optional[int] = None
options: Optional[List[ProductOptionCreate]] = None
ingredients: Optional[List[ProductIngredientCreate]] = None
preference_sets: Optional[List[PreferenceSetCreate]] = None
class ProductReorderItem(BaseModel):
id: int
sort_order: int
class ProductOut(ProductBase):
id: int
options: List[ProductOptionOut] = []