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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user