import os import uuid import json from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from pydantic import BaseModel from sqlalchemy.orm import Session from typing import List from database import get_db from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice, ProductModifierGroup from models.prep_zone import PrepZone from models.order import OrderItem from models.user import User from schemas.product import ( ProductCreate, ProductUpdate, ProductOut, ProductReorderItem, CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem, SubcategoryReorderItem, ParentGeneralReorderItem, PreferenceSetCreate, ProductQuickOptionCreate, CategoryReparentRequest, ModifierGroupCreate, ModifierGroupOut, ) from routers.deps import get_current_user, require_menu_manager from services.sse_bus import broadcast_sync router = APIRouter() def _broadcast_products_changed(): broadcast_sync("products_changed", {}) IMAGE_DIR = "/app/data/product_images" def _replace_modifier_groups(db, product, groups): """Recreate modifier groups and return a list of created DB objects (index-aligned).""" for g in product.modifier_groups: db.delete(g) db.flush() created = [] for i, g in enumerate(groups): new_g = ProductModifierGroup( product_id=product.id, modifier_type=g.modifier_type, name=g.name, sort_order=i, ) db.add(new_g) db.flush() created.append(new_g) return created def _replace_quick_options(db, product, quick_options): for qo in product.quick_options: db.delete(qo) db.flush() for i, qo in enumerate(quick_options): db.add(ProductQuickOption( product_id=product.id, name=qo.name, price=qo.price, allow_multiple=qo.allow_multiple, sort_order=qo.sort_order if qo.sort_order else i, is_favorite=qo.is_favorite, favorite_sort_order=qo.favorite_sort_order, is_compact=qo.is_compact, )) def _replace_options(db, product, options, group_id_map=None): for opt in product.options: db.delete(opt) db.flush() for opt in options: sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None resolved_group = group_id_map[opt.group_id] if (group_id_map and opt.group_id is not None and opt.group_id < len(group_id_map)) else None db.add(ProductOption( product_id=product.id, name=opt.name, extra_cost=opt.extra_cost, allow_multiple=opt.allow_multiple, multi_select=opt.multi_select, sub_choices=sub_json, is_favorite=opt.is_favorite, favorite_sort_order=opt.favorite_sort_order, is_compact=opt.is_compact, group_id=resolved_group, )) def _replace_ingredients(db, product, ingredients, group_id_map=None): for ing in product.ingredients: db.delete(ing) db.flush() for ing in ingredients: resolved_group = group_id_map[ing.group_id] if (group_id_map and ing.group_id is not None and ing.group_id < len(group_id_map)) else None db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group)) def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate], group_id_map=None): for ps in product.preference_sets: db.delete(ps) db.flush() for ps in sets: shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None resolved_group = group_id_map[ps.group_id] if (group_id_map and ps.group_id is not None and ps.group_id < len(group_id_map)) else None new_set = ProductPreferenceSet( product_id=product.id, name=ps.name, shared_subset=shared_json, is_favorite=ps.is_favorite, favorite_sort_order=ps.favorite_sort_order, group_id=resolved_group, allow_multi_select=ps.allow_multi_select, allow_choice_quantity=ps.allow_choice_quantity, ) db.add(new_set) db.flush() created_choices = [] for ch in ps.choices: 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, is_compact=ch.is_compact, ) 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 ──────────────────────────────────────────────────────────────── @router.get("/tags") def list_all_tags(db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Return sorted unique tag strings across all products.""" rows = db.query(Product.tags).filter(Product.tags != None, Product.tags != "[]").all() tag_set = set() for (tags_json,) in rows: try: tags = json.loads(tags_json) if tags_json else [] tag_set.update(tags) except Exception: pass return sorted(tag_set) @router.get("/categories", response_model=List[CategoryOut]) def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)): return db.query(Category).order_by(Category.sort_order).all() @router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED) def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): # sort_order is among siblings (same parent_id level) sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count() cat = Category( name=body.name, color=body.color, sort_order=sibling_count, parent_id=body.parent_id, general_sort_order=body.general_sort_order, ) db.add(cat) db.commit() db.refresh(cat) _broadcast_products_changed() return cat @router.put("/categories/reorder", status_code=status.HTTP_204_NO_CONTENT) def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): for item in items: cat = db.query(Category).filter(Category.id == item.id).first() if cat: cat.sort_order = item.sort_order db.commit() _broadcast_products_changed() @router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT) def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): """Reorder sub-categories within their parent (sort_order among siblings).""" for item in items: cat = db.query(Category).filter(Category.id == item.id).first() if cat: cat.sort_order = item.sort_order db.commit() _broadcast_products_changed() @router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT) def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): """Update general_sort_order on parent categories (position of the General group).""" for item in items: cat = db.query(Category).filter(Category.id == item.id).first() if cat: cat.general_sort_order = item.general_sort_order db.commit() _broadcast_products_changed() @router.put("/categories/{category_id}/reparent", response_model=CategoryOut) def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): """Move a category to a new parent (or promote to top-level if parent_id is null). All products assigned to this category follow it automatically (no product updates needed). """ cat = db.query(Category).filter(Category.id == category_id).first() if not cat: raise HTTPException(status_code=404, detail="Category not found") if body.parent_id is not None: new_parent = db.query(Category).filter(Category.id == body.parent_id).first() if not new_parent: raise HTTPException(status_code=404, detail="Target parent category not found") if new_parent.parent_id is not None: raise HTTPException(status_code=400, detail="Cannot nest more than two levels deep") if body.parent_id == category_id: raise HTTPException(status_code=400, detail="A category cannot be its own parent") # If cat currently has children and is being made a sub, block it has_children = db.query(Category).filter(Category.parent_id == category_id).count() > 0 if has_children and body.parent_id is not None: raise HTTPException(status_code=400, detail="Cannot nest a category that has subcategories") # Assign new sort_order at the end of the destination level sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count() cat.parent_id = body.parent_id cat.sort_order = sibling_count db.commit() db.refresh(cat) _broadcast_products_changed() return cat @router.put("/categories/{category_id}", response_model=CategoryOut) def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): cat = db.query(Category).filter(Category.id == category_id).first() if not cat: raise HTTPException(status_code=404, detail="Category not found") for field, value in body.model_dump(exclude_none=True).items(): setattr(cat, field, value) db.commit() db.refresh(cat) _broadcast_products_changed() return cat @router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): cat = db.query(Category).filter(Category.id == category_id).first() if not cat: raise HTTPException(status_code=404, detail="Category not found") db.delete(cat) db.commit() _broadcast_products_changed() # ── Products ────────────────────────────────────────────────────────────────── @router.get("/", response_model=List[ProductOut]) def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)): q = db.query(Product) has_dashboard = user.role in ("superadmin", "manager") or getattr(user, "perm_access_dashboard", False) if not all or not has_dashboard: # Waiters only see active, available products q = q.filter(Product.is_available == True, Product.lifecycle_status == "active") return q.order_by(Product.sort_order, Product.id).all() class BulkPrepZoneBody(BaseModel): product_ids: List[int] prep_zone_ids: List[int] @router.post("/bulk-prep-zones", status_code=status.HTTP_204_NO_CONTENT) def bulk_assign_prep_zones(body: BulkPrepZoneBody, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): """Replace the prep zone assignments for a list of products at once.""" zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all() products = db.query(Product).filter(Product.id.in_(body.product_ids)).all() for p in products: p.prep_zones = zones db.commit() _broadcast_products_changed() @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_menu_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() _broadcast_products_changed() @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_menu_manager)): data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags"}) if data.get("sort_order") == 0: data["sort_order"] = db.query(Product).count() if body.cost_breakdown is not None: data["cost_breakdown"] = json.dumps([item.model_dump() for item in body.cost_breakdown]) data["tags"] = json.dumps(body.tags) if body.tags is not None else None product = Product(**data) db.add(product) db.flush() # Assign prep zones if body.prep_zone_ids: zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all() product.prep_zones = zones for i, qo in enumerate(body.quick_options): db.add(ProductQuickOption( product_id=product.id, name=qo.name, price=qo.price, allow_multiple=qo.allow_multiple, sort_order=qo.sort_order if qo.sort_order else i, is_favorite=qo.is_favorite, favorite_sort_order=qo.favorite_sort_order, is_compact=qo.is_compact, )) created_groups = _replace_modifier_groups(db, product, body.modifier_groups) group_id_map = [g.id for g in created_groups] for opt in body.options: sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None resolved_group = group_id_map[opt.group_id] if (opt.group_id is not None and opt.group_id < len(group_id_map)) else None db.add(ProductOption( product_id=product.id, name=opt.name, extra_cost=opt.extra_cost, allow_multiple=opt.allow_multiple, multi_select=opt.multi_select, sub_choices=sub_json, is_favorite=opt.is_favorite, favorite_sort_order=opt.favorite_sort_order, is_compact=opt.is_compact, group_id=resolved_group, )) for ing in body.ingredients: resolved_group = group_id_map[ing.group_id] if (ing.group_id is not None and ing.group_id < len(group_id_map)) else None db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group)) _replace_preference_sets(db, product, body.preference_sets, group_id_map) db.commit() db.refresh(product) _broadcast_products_changed() return product @router.put("/{product_id}", response_model=ProductOut) def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): product = db.query(Product).filter(Product.id == product_id).first() if not product: raise HTTPException(status_code=404, detail="Product not found") scalar_fields = body.model_dump( exclude_none=True, exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags", "modifier_groups"}, ) for field, value in scalar_fields.items(): setattr(product, field, value) # Always clear legacy per-product printer — routing is now via prep zones only product.printer_zone_id = None # Update prep zones if provided if body.prep_zone_ids is not None: zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all() product.prep_zones = zones # cost_breakdown is a list of objects — serialize to JSON for storage if body.cost_breakdown is not None: product.cost_breakdown = json.dumps([item.model_dump() for item in body.cost_breakdown]) elif "cost_breakdown" in body.model_fields_set: # explicitly set to null — clear it product.cost_breakdown = None # tags is a list of strings — serialize to JSON for storage if body.tags is not None: product.tags = json.dumps(body.tags) elif "tags" in body.model_fields_set: product.tags = None if body.quick_options is not None: _replace_quick_options(db, product, body.quick_options) # Modifier groups must be recreated before items so we have the ID map group_id_map = None if body.modifier_groups is not None: created_groups = _replace_modifier_groups(db, product, body.modifier_groups) group_id_map = [g.id for g in created_groups] if body.options is not None: _replace_options(db, product, body.options, group_id_map) if body.ingredients is not None: _replace_ingredients(db, product, body.ingredients, group_id_map) if body.preference_sets is not None: _replace_preference_sets(db, product, body.preference_sets, group_id_map) db.commit() db.refresh(product) _broadcast_products_changed() return product @router.post("/{product_id}/image", response_model=ProductOut) async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): product = db.query(Product).filter(Product.id == product_id).first() if not product: raise HTTPException(status_code=404, detail="Product not found") if not file.content_type.startswith("image/"): raise HTTPException(status_code=400, detail="File must be an image") os.makedirs(IMAGE_DIR, exist_ok=True) if product.image_url: old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url)) if os.path.exists(old_path): os.remove(old_path) ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "jpg" filename = f"{product_id}_{uuid.uuid4().hex[:8]}.{ext}" filepath = os.path.join(IMAGE_DIR, filename) contents = await file.read() with open(filepath, "wb") as f: f.write(contents) product.image_url = f"/static/product_images/{filename}" db.commit() db.refresh(product) _broadcast_products_changed() return product # ── Modifier Groups ─────────────────────────────────────────────────────────── @router.get("/{product_id}/modifier-groups", response_model=List[ModifierGroupOut]) def list_modifier_groups(product_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): return db.query(ProductModifierGroup).filter(ProductModifierGroup.product_id == product_id).order_by(ProductModifierGroup.sort_order).all() @router.post("/{product_id}/modifier-groups", response_model=ModifierGroupOut, status_code=status.HTTP_201_CREATED) def create_modifier_group(product_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): product = db.query(Product).filter(Product.id == product_id).first() if not product: raise HTTPException(status_code=404, detail="Product not found") g = ProductModifierGroup(product_id=product_id, modifier_type=body.modifier_type, name=body.name, sort_order=body.sort_order) db.add(g) db.commit() db.refresh(g) _broadcast_products_changed() return g @router.put("/{product_id}/modifier-groups/{group_id}", response_model=ModifierGroupOut) def update_modifier_group(product_id: int, group_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first() if not g: raise HTTPException(status_code=404, detail="Group not found") g.name = body.name g.sort_order = body.sort_order db.commit() db.refresh(g) _broadcast_products_changed() return g @router.delete("/{product_id}/modifier-groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_modifier_group(product_id: int, group_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first() if not g: raise HTTPException(status_code=404, detail="Group not found") # Un-group all items in this group before deleting db.query(ProductOption).filter(ProductOption.group_id == group_id).update({"group_id": None}) db.query(ProductIngredient).filter(ProductIngredient.group_id == group_id).update({"group_id": None}) db.query(ProductPreferenceSet).filter(ProductPreferenceSet.group_id == group_id).update({"group_id": None}) db.delete(g) db.commit() _broadcast_products_changed() @router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)): product = db.query(Product).filter(Product.id == product_id).first() if not product: raise HTTPException(status_code=404, detail="Product not found") 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. Archive it instead." ) db.delete(product) else: # If product has order history, archive it; otherwise hard delete has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first() if has_orders: product.lifecycle_status = "archived" else: db.delete(product) db.commit() _broadcast_products_changed()