Fix order saving, isMyOrder, blocked waiters, options pricing; add preferences, table groups, product images

Backend:
- OrderItemInput accepts option objects {id,name,price_delta} instead of int IDs
- extra_cost from selected options added to unit_price snapshot
- GET /api/products/?all=true for manager (includes unavailable)
- PUT /api/products/{id} now replaces options, ingredients, preference_sets
- POST /api/products/{id}/image — persistent image upload to /app/data/product_images
- New models: ProductPreferenceSet, ProductPreferenceChoice, TableGroup
- tables: group_id FK, hard delete (?hard=true), batch create POST /api/tables/batch
- GET /api/tables/groups + POST/PUT/DELETE groups endpoints
- POST /api/auth/me endpoint for token rehydration
- Auto-migration on startup for new columns

PWA:
- AuthRehydrator: fetches /auth/me on load so isMyOrder works after page reload
- 401 response force-logs out (covers blocked waiters)
- ItemOptionsModal: uses extra_cost correctly, shows preferences as radio buttons

Manager:
- ProductsPage: shows unavailable products greyed out, category color picker + reorder,
  full option/ingredient/preference editing, image upload
- TablesPage: table groups, auto-increment, deactivate vs hard delete, batch add
This commit is contained in:
2026-04-20 18:39:51 +03:00
parent 8f52156f5b
commit 24a029a8cc
16 changed files with 826 additions and 172 deletions

View File

@@ -1,20 +1,53 @@
from fastapi import APIRouter, Depends, HTTPException, status
import os
import uuid
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
from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.user import User
from schemas.product import (
ProductCreate, ProductUpdate, ProductOut,
CategoryCreate, CategoryUpdate, CategoryOut,
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
PreferenceSetCreate,
)
from routers.deps import get_current_user, require_manager
router = APIRouter()
IMAGE_DIR = "/app/data/product_images"
# ── Categories ───────────────────────────────────────────────────────────────
def _replace_options(db, product, options):
for opt in product.options:
db.delete(opt)
db.flush()
for opt in options:
db.add(ProductOption(product_id=product.id, **opt.model_dump()))
def _replace_ingredients(db, product, ingredients):
for ing in product.ingredients:
db.delete(ing)
db.flush()
for ing in ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
for ps in product.preference_sets:
db.delete(ps)
db.flush()
for ps in 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()))
# ── Categories ────────────────────────────────────────────────────────────────
@router.get("/categories", response_model=List[CategoryOut])
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
@@ -23,13 +56,23 @@ def list_categories(db: Session = Depends(get_db), user: User = Depends(get_curr
@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_manager)):
cat = Category(**body.model_dump())
max_order = db.query(Category).count()
cat = Category(name=body.name, color=body.color, sort_order=max_order)
db.add(cat)
db.commit()
db.refresh(cat)
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_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()
@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_manager)):
cat = db.query(Category).filter(Category.id == category_id).first()
@@ -54,13 +97,16 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
# ── Products ──────────────────────────────────────────────────────────────────
@router.get("/", response_model=List[ProductOut])
def list_products(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(Product).filter(Product.is_available == True).all()
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_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()
@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"})
data = body.model_dump(exclude={"options", "ingredients", "preference_sets"})
product = Product(**data)
db.add(product)
db.flush()
@@ -68,6 +114,12 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
db.add(ProductOption(product_id=product.id, **opt.model_dump()))
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()))
db.commit()
db.refresh(product)
return product
@@ -78,8 +130,45 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
for field, value in body.model_dump(exclude_none=True).items():
for field, value in body.model_dump(exclude_none=True, exclude={"options", "ingredients", "preference_sets"}).items():
setattr(product, field, value)
if body.options is not None:
_replace_options(db, product, body.options)
if body.ingredients is not None:
_replace_ingredients(db, product, body.ingredients)
if body.preference_sets is not None:
_replace_preference_sets(db, product, body.preference_sets)
db.commit()
db.refresh(product)
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_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)
# 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):
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)
return product