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,14 +1,16 @@
import os
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.order import OrderItem
from models.user import User
from schemas.product import (
ProductCreate, ProductUpdate, ProductOut,
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
PreferenceSetCreate,
)
@@ -24,7 +26,13 @@ def _replace_options(db, product, options):
db.delete(opt)
db.flush()
for opt in options:
db.add(ProductOption(product_id=product.id, **opt.model_dump()))
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
db.add(ProductOption(
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
sub_choices=sub_json,
))
def _replace_ingredients(db, product, ingredients):
@@ -40,11 +48,29 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
db.delete(ps)
db.flush()
for ps in sets:
new_set = ProductPreferenceSet(product_id=product.id, name=ps.name)
shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None
new_set = ProductPreferenceSet(
product_id=product.id,
name=ps.name,
shared_subset=shared_json,
)
db.add(new_set)
db.flush()
created_choices = []
for ch in ps.choices:
db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump()))
sub_json = json.dumps([s.model_dump() for s in ch.sub_choices]) if ch.sub_choices else None
choice = ProductPreferenceChoice(
set_id=new_set.id,
name=ch.name,
extra_cost=ch.extra_cost,
sub_choices=sub_json,
disables_subset=ch.disables_subset,
)
db.add(choice)
db.flush()
created_choices.append(choice)
if ps.default_choice_index is not None and 0 <= ps.default_choice_index < len(created_choices):
new_set.default_choice_id = created_choices[ps.default_choice_index].id
# ── Categories ────────────────────────────────────────────────────────────────
@@ -101,25 +127,32 @@ def list_products(all: bool = False, db: Session = Depends(get_db), user: User =
q = db.query(Product)
if not all or user.role not in ("manager", "sysadmin"):
q = q.filter(Product.is_available == True)
return q.all()
return q.order_by(Product.sort_order, Product.id).all()
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
for item in items:
product = db.query(Product).filter(Product.id == item.id).first()
if product:
product.sort_order = item.sort_order
db.commit()
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
data = body.model_dump(exclude={"options", "ingredients", "preference_sets"})
if data.get("sort_order") == 0:
data["sort_order"] = db.query(Product).count()
product = Product(**data)
db.add(product)
db.flush()
for opt in body.options:
db.add(ProductOption(product_id=product.id, **opt.model_dump()))
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
db.add(ProductOption(product_id=product.id, name=opt.name, extra_cost=opt.extra_cost, sub_choices=sub_json))
for ing in body.ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
for ps in body.preference_sets:
new_set = ProductPreferenceSet(product_id=product.id, name=ps.name)
db.add(new_set)
db.flush()
for ch in ps.choices:
db.add(ProductPreferenceChoice(set_id=new_set.id, **ch.model_dump()))
_replace_preference_sets(db, product, body.preference_sets)
db.commit()
db.refresh(product)
return product
@@ -154,7 +187,6 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
os.makedirs(IMAGE_DIR, exist_ok=True)
# Delete old image if exists
if product.image_url:
old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url))
if os.path.exists(old_path):
@@ -175,9 +207,18 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
def deactivate_product(product_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
product.is_available = False
if hard:
has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first()
if has_orders:
raise HTTPException(
status_code=400,
detail="Cannot permanently delete a product that appears in past orders. Deactivate it instead."
)
db.delete(product)
else:
product.is_available = False
db.commit()