import json from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from database import get_db from models.product import ( Category, Product, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice, ) from models.table import Table, TableGroup from models.user import User from routers.deps import require_manager router = APIRouter() EXPORT_VERSION = 1 def _serialize_product(p: Product) -> dict: quick_options = [ {"name": qo.name, "price": qo.price, "allow_multiple": qo.allow_multiple, "sort_order": qo.sort_order, "is_favorite": qo.is_favorite, "favorite_sort_order": qo.favorite_sort_order, "is_compact": qo.is_compact} for qo in p.quick_options ] options = [] for opt in p.options: sub = json.loads(opt.sub_choices) if opt.sub_choices else [] options.append({"name": opt.name, "extra_cost": opt.extra_cost, "allow_multiple": opt.allow_multiple, "sub_choices": sub, "is_favorite": opt.is_favorite, "favorite_sort_order": opt.favorite_sort_order}) ingredients = [ {"name": ing.name, "extra_cost": ing.extra_cost, "is_favorite": ing.is_favorite, "favorite_sort_order": ing.favorite_sort_order} for ing in p.ingredients ] preference_sets = [] for ps in p.preference_sets: shared = json.loads(ps.shared_subset) if ps.shared_subset else None default_index = None choices = [] for i, ch in enumerate(ps.choices): if ch.id == ps.default_choice_id: default_index = i sub = json.loads(ch.sub_choices) if ch.sub_choices else [] choices.append({"name": ch.name, "extra_cost": ch.extra_cost, "sub_choices": sub, "disables_subset": ch.disables_subset}) preference_sets.append({ "name": ps.name, "choices": choices, "default_choice_index": default_index, "shared_subset": shared, "is_favorite": ps.is_favorite, "favorite_sort_order": ps.favorite_sort_order, }) return { "name": p.name, "base_price": p.base_price, "is_available": p.is_available, "lifecycle_status": p.lifecycle_status, "sort_order": p.sort_order, "printer_zone_id": None, # always stripped on export "quick_options": quick_options, "options": options, "ingredients": ingredients, "preference_sets": preference_sets, } def _serialize_category(cat: Category) -> dict: products = [_serialize_product(p) for p in cat.products if p.lifecycle_status != "archived"] return { "name": cat.name, "color": cat.color, "sort_order": cat.sort_order, "parent_name": cat.parent.name if cat.parent else None, "general_sort_order": cat.general_sort_order, "auto_expanded": cat.auto_expanded, "products": products, } @router.get("/export/catalog") def export_catalog(db: Session = Depends(get_db), user: User = Depends(require_manager)): categories = db.query(Category).order_by(Category.sort_order).all() orphan_products = ( db.query(Product) .filter(Product.category_id == None, Product.lifecycle_status != "archived") .order_by(Product.sort_order) .all() ) data = { "categories": [_serialize_category(c) for c in categories], "uncategorized_products": [_serialize_product(p) for p in orphan_products], } payload = { "xenia_export_version": EXPORT_VERSION, "bundle": "catalog", "exported_at": datetime.now(timezone.utc).isoformat(), "data": data, } today = datetime.now(timezone.utc).strftime("%Y-%m-%d") return JSONResponse( content=payload, headers={"Content-Disposition": f'attachment; filename="xenia-catalog-{today}.json"'}, ) @router.get("/export/tables") def export_tables(db: Session = Depends(get_db), user: User = Depends(require_manager)): groups = db.query(TableGroup).order_by(TableGroup.sort_order).all() table_groups = [] for g in groups: tables = [ {"number": t.number, "label": t.label, "is_active": t.is_active, "floor_x": t.floor_x, "floor_y": t.floor_y} for t in sorted(g.tables, key=lambda t: t.number) ] table_groups.append({ "name": g.name, "prefix": g.prefix, "sort_order": g.sort_order, "color": g.color, "tables": tables, }) ungrouped = ( db.query(Table) .filter(Table.group_id == None) .order_by(Table.number) .all() ) ungrouped_tables = [ {"number": t.number, "label": t.label, "is_active": t.is_active, "floor_x": t.floor_x, "floor_y": t.floor_y} for t in ungrouped ] payload = { "xenia_export_version": EXPORT_VERSION, "bundle": "tables", "exported_at": datetime.now(timezone.utc).isoformat(), "data": {"table_groups": table_groups, "ungrouped_tables": ungrouped_tables}, } today = datetime.now(timezone.utc).strftime("%Y-%m-%d") return JSONResponse( content=payload, headers={"Content-Disposition": f'attachment; filename="xenia-tables-{today}.json"'}, ) @router.post("/import/catalog") def import_catalog(payload: dict, db: Session = Depends(get_db), user: User = Depends(require_manager)): if payload.get("bundle") != "catalog": raise HTTPException(status_code=400, detail="Wrong bundle type. Expected 'catalog'.") if payload.get("xenia_export_version", 0) != EXPORT_VERSION: raise HTTPException(status_code=400, detail="Unsupported export version.") data = payload.get("data", {}) categories_data = data.get("categories", []) def _upsert_category(cat_data: dict, parent_id=None) -> Category: existing = db.query(Category).filter( Category.name == cat_data["name"], Category.parent_id == parent_id, ).first() if existing: existing.color = cat_data.get("color") existing.sort_order = cat_data.get("sort_order", 0) existing.general_sort_order = cat_data.get("general_sort_order", 0) existing.auto_expanded = cat_data.get("auto_expanded", False) db.flush() return existing else: cat = Category( name=cat_data["name"], color=cat_data.get("color"), sort_order=cat_data.get("sort_order", 0), parent_id=parent_id, general_sort_order=cat_data.get("general_sort_order", 0), auto_expanded=cat_data.get("auto_expanded", False), ) db.add(cat) db.flush() return cat def _upsert_product(prod_data: dict, category_id=None): existing = db.query(Product).filter(Product.name == prod_data["name"]).first() if existing: existing.base_price = prod_data["base_price"] existing.is_available = prod_data.get("is_available", True) existing.lifecycle_status = prod_data.get("lifecycle_status", "active") existing.sort_order = prod_data.get("sort_order", 0) existing.category_id = category_id existing.printer_zone_id = None db.flush() product = existing else: product = Product( name=prod_data["name"], base_price=prod_data["base_price"], is_available=prod_data.get("is_available", True), lifecycle_status=prod_data.get("lifecycle_status", "active"), sort_order=prod_data.get("sort_order", 0), category_id=category_id, printer_zone_id=None, ) db.add(product) db.flush() # Replace sub-items (safe: sub-items have no direct order history references) for qo in list(product.quick_options): db.delete(qo) for opt in list(product.options): db.delete(opt) for ing in list(product.ingredients): db.delete(ing) for ps in list(product.preference_sets): db.delete(ps) db.flush() for qo in prod_data.get("quick_options", []): db.add(ProductQuickOption(product_id=product.id, **qo)) for opt in prod_data.get("options", []): sub_json = json.dumps(opt.get("sub_choices", [])) db.add(ProductOption( product_id=product.id, name=opt["name"], extra_cost=opt.get("extra_cost", 0.0), allow_multiple=opt.get("allow_multiple", False), sub_choices=sub_json, is_favorite=opt.get("is_favorite", False), favorite_sort_order=opt.get("favorite_sort_order", 0), )) for ing in prod_data.get("ingredients", []): db.add(ProductIngredient(product_id=product.id, **ing)) for ps_data in prod_data.get("preference_sets", []): shared_json = json.dumps(ps_data["shared_subset"]) if ps_data.get("shared_subset") else None ps = ProductPreferenceSet( product_id=product.id, name=ps_data["name"], shared_subset=shared_json, is_favorite=ps_data.get("is_favorite", False), favorite_sort_order=ps_data.get("favorite_sort_order", 0), ) db.add(ps) db.flush() created_choices = [] for ch in ps_data.get("choices", []): sub_json = json.dumps(ch.get("sub_choices", [])) choice = ProductPreferenceChoice( set_id=ps.id, name=ch["name"], extra_cost=ch.get("extra_cost", 0.0), sub_choices=sub_json, disables_subset=ch.get("disables_subset", False), ) db.add(choice) db.flush() created_choices.append(choice) idx = ps_data.get("default_choice_index") if idx is not None and 0 <= idx < len(created_choices): ps.default_choice_id = created_choices[idx].id # First pass: top-level categories (parent_name is None) for cat_data in categories_data: if cat_data.get("parent_name") is None: cat = _upsert_category(cat_data, parent_id=None) for prod_data in cat_data.get("products", []): _upsert_product(prod_data, category_id=cat.id) # Second pass: sub-categories (parent must already exist from first pass) for cat_data in categories_data: if cat_data.get("parent_name") is not None: parent = db.query(Category).filter( Category.name == cat_data["parent_name"], Category.parent_id == None, ).first() parent_id = parent.id if parent else None cat = _upsert_category(cat_data, parent_id=parent_id) for prod_data in cat_data.get("products", []): _upsert_product(prod_data, category_id=cat.id) # Uncategorized products (no category) for prod_data in data.get("uncategorized_products", []): _upsert_product(prod_data, category_id=None) db.commit() return {"ok": True, "message": "Catalog imported successfully."} @router.post("/import/tables") def import_tables(payload: dict, db: Session = Depends(get_db), user: User = Depends(require_manager)): if payload.get("bundle") != "tables": raise HTTPException(status_code=400, detail="Wrong bundle type. Expected 'tables'.") if payload.get("xenia_export_version", 0) != EXPORT_VERSION: raise HTTPException(status_code=400, detail="Unsupported export version.") data = payload.get("data", {}) for group_data in data.get("table_groups", []): existing_group = db.query(TableGroup).filter(TableGroup.name == group_data["name"]).first() if existing_group: existing_group.prefix = group_data.get("prefix") existing_group.sort_order = group_data.get("sort_order", 0) existing_group.color = group_data.get("color") db.flush() group = existing_group else: group = TableGroup( name=group_data["name"], prefix=group_data.get("prefix"), sort_order=group_data.get("sort_order", 0), color=group_data.get("color"), ) db.add(group) db.flush() for table_data in group_data.get("tables", []): existing_table = db.query(Table).filter( Table.number == table_data["number"], Table.group_id == group.id, ).first() if existing_table: existing_table.label = table_data.get("label") existing_table.is_active = table_data.get("is_active", True) existing_table.floor_x = table_data.get("floor_x") existing_table.floor_y = table_data.get("floor_y") else: db.add(Table( number=table_data["number"], label=table_data.get("label"), group_id=group.id, is_active=table_data.get("is_active", True), floor_x=table_data.get("floor_x"), floor_y=table_data.get("floor_y"), )) # Ungrouped tables (no zone) for table_data in data.get("ungrouped_tables", []): existing_table = db.query(Table).filter( Table.number == table_data["number"], Table.group_id == None, ).first() if existing_table: existing_table.label = table_data.get("label") existing_table.is_active = table_data.get("is_active", True) existing_table.floor_x = table_data.get("floor_x") existing_table.floor_y = table_data.get("floor_y") else: db.add(Table( number=table_data["number"], label=table_data.get("label"), group_id=None, is_active=table_data.get("is_active", True), floor_x=table_data.get("floor_x"), floor_y=table_data.get("floor_y"), )) db.commit() return {"ok": True, "message": "Tables imported successfully."}