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,6 +1,8 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from database import engine, Base
|
||||
from middleware.license_check import LicenseCheckMiddleware
|
||||
@@ -16,9 +18,34 @@ import models.order # noqa: F401
|
||||
from routers import auth, tables, products, orders, waiters, reports, system
|
||||
|
||||
|
||||
def _run_migrations():
|
||||
"""Apply additive schema changes that create_all won't handle."""
|
||||
from sqlalchemy import text
|
||||
with engine.connect() as conn:
|
||||
# Add extra_cost to product_ingredients if missing
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
# Add image_url to products if missing
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE products ADD COLUMN image_url VARCHAR"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
# Add group_id to tables if missing (added in Phase 3 table groups)
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_run_migrations()
|
||||
sync_task = await start_cloud_sync()
|
||||
yield
|
||||
sync_task.cancel()
|
||||
@@ -34,6 +61,11 @@ app.add_middleware(
|
||||
)
|
||||
app.add_middleware(LicenseCheckMiddleware)
|
||||
|
||||
# Serve product images as static files
|
||||
IMAGE_DIR = "/app/data/product_images"
|
||||
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||
app.mount("/static/product_images", StaticFiles(directory=IMAGE_DIR), name="product_images")
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(tables.router, prefix="/api/tables", tags=["tables"])
|
||||
app.include_router(products.router, prefix="/api/products", tags=["products"])
|
||||
|
||||
@@ -23,11 +23,13 @@ class Product(Base):
|
||||
base_price = Column(Float, nullable=False)
|
||||
is_available = Column(Boolean, default=True, nullable=False)
|
||||
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
||||
image_url = Column(String, nullable=True)
|
||||
|
||||
category = relationship("Category", back_populates="products")
|
||||
printer_zone = relationship("Printer", back_populates="products")
|
||||
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
|
||||
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
|
||||
preference_sets = relationship("ProductPreferenceSet", back_populates="product", cascade="all, delete-orphan")
|
||||
order_items = relationship("OrderItem", back_populates="product")
|
||||
|
||||
|
||||
@@ -48,5 +50,28 @@ class ProductIngredient(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
|
||||
product = relationship("Product", back_populates="ingredients")
|
||||
|
||||
|
||||
class ProductPreferenceSet(Base):
|
||||
__tablename__ = "product_preference_sets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="preference_sets")
|
||||
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ProductPreferenceChoice(Base):
|
||||
__tablename__ = "product_preference_choices"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
|
||||
set = relationship("ProductPreferenceSet", back_populates="choices")
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
class TableGroup(Base):
|
||||
__tablename__ = "table_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, unique=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
tables = relationship("Table", back_populates="group")
|
||||
|
||||
|
||||
class Table(Base):
|
||||
__tablename__ = "tables"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
number = Column(Integer, unique=True, nullable=False)
|
||||
number = Column(Integer, nullable=False)
|
||||
label = Column(String, nullable=True)
|
||||
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
floor_x = Column(Float, nullable=True)
|
||||
floor_y = Column(Float, nullable=True)
|
||||
|
||||
group = relationship("TableGroup", back_populates="tables")
|
||||
orders = relationship("Order", back_populates="table")
|
||||
|
||||
@@ -62,3 +62,8 @@ def refresh(token: str, db: Session = Depends(get_db)):
|
||||
def logout(token: str):
|
||||
_blacklisted_tokens.add(token)
|
||||
return {"status": "logged out"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
@@ -109,13 +109,18 @@ def add_items(
|
||||
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
||||
if not product or not product.is_available:
|
||||
raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available")
|
||||
# Calculate extra cost from selected options
|
||||
extra_cost = sum(
|
||||
(o.price_delta or o.extra_cost or 0.0)
|
||||
for o in (item_in.selected_options or [])
|
||||
)
|
||||
item = OrderItem(
|
||||
order_id=order_id,
|
||||
product_id=item_in.product_id,
|
||||
added_by=user.id,
|
||||
quantity=item_in.quantity,
|
||||
unit_price=product.base_price, # price snapshot
|
||||
selected_options=json.dumps(item_in.selected_options) if item_in.selected_options else None,
|
||||
unit_price=product.base_price + extra_cost, # price snapshot with options
|
||||
selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None,
|
||||
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
|
||||
notes=item_in.notes,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,24 +3,72 @@ from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.table import Table
|
||||
from models.table import Table, TableGroup
|
||||
from models.order import Order
|
||||
from models.user import User
|
||||
from schemas.table import TableCreate, TableUpdate, TableFloorplanUpdate, TableOut
|
||||
from schemas.table import (
|
||||
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
|
||||
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
||||
TableBatchCreate,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Table Groups ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/groups", response_model=List[TableGroupOut])
|
||||
def list_groups(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return db.query(TableGroup).order_by(TableGroup.sort_order).all()
|
||||
|
||||
|
||||
@router.post("/groups", response_model=TableGroupOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
|
||||
raise HTTPException(status_code=400, detail="Group name already exists")
|
||||
sort_order = db.query(TableGroup).count()
|
||||
group = TableGroup(name=body.name, sort_order=sort_order)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}", response_model=TableGroupOut)
|
||||
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(group, field, value)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
db.query(Table).filter(Table.group_id == group_id).update({"group_id": None})
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Tables ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_model=List[TableOut])
|
||||
def list_tables(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return db.query(Table).filter(Table.is_active == True).all()
|
||||
def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
q = db.query(Table)
|
||||
if not include_inactive:
|
||||
q = q.filter(Table.is_active == True)
|
||||
return q.order_by(Table.group_id, Table.number).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if db.query(Table).filter(Table.number == body.number).first():
|
||||
raise HTTPException(status_code=400, detail="Table number already exists")
|
||||
table = Table(**body.model_dump())
|
||||
db.add(table)
|
||||
db.commit()
|
||||
@@ -28,6 +76,28 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
|
||||
return table
|
||||
|
||||
|
||||
@router.post("/batch", response_model=List[TableOut], status_code=status.HTTP_201_CREATED)
|
||||
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if body.count < 1 or body.count > 200:
|
||||
raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
|
||||
created = []
|
||||
for i in range(body.count):
|
||||
n = body.start_number + i
|
||||
table = Table(
|
||||
number=n,
|
||||
label=f"{body.name_prefix}{n}",
|
||||
group_id=body.group_id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(table)
|
||||
db.flush()
|
||||
created.append(table)
|
||||
db.commit()
|
||||
for t in created:
|
||||
db.refresh(t)
|
||||
return created
|
||||
|
||||
|
||||
@router.put("/{table_id}", response_model=TableOut)
|
||||
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
@@ -41,11 +111,20 @@ def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db)
|
||||
|
||||
|
||||
@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def deactivate_table(table_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
table.is_active = False
|
||||
if hard:
|
||||
active_order = db.query(Order).filter(
|
||||
Order.table_id == table_id,
|
||||
Order.status.in_(["open", "partially_paid"])
|
||||
).first()
|
||||
if active_order:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete table with active order")
|
||||
db.delete(table)
|
||||
else:
|
||||
table.is_active = False
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,18 @@ from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class SelectedOptionInput(BaseModel):
|
||||
id: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
price_delta: Optional[float] = None
|
||||
extra_cost: Optional[float] = None
|
||||
|
||||
|
||||
class OrderItemInput(BaseModel):
|
||||
product_id: int
|
||||
quantity: int
|
||||
selected_options: Optional[List[int]] = None
|
||||
removed_ingredients: Optional[List[int]] = None
|
||||
selected_options: Optional[List[SelectedOptionInput]] = None
|
||||
removed_ingredients: Optional[List[str]] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
@@ -2,28 +2,34 @@ from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class CategoryOut(CategoryBase):
|
||||
class CategoryOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CategoryReorderItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int
|
||||
|
||||
|
||||
# ── Options ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProductOptionBase(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
@@ -40,8 +46,11 @@ class ProductOptionOut(ProductOptionBase):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Ingredients ───────────────────────────────────────────────────────────────
|
||||
|
||||
class ProductIngredientBase(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
|
||||
|
||||
class ProductIngredientCreate(ProductIngredientBase):
|
||||
@@ -55,6 +64,42 @@ class ProductIngredientOut(ProductIngredientBase):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Preferences ───────────────────────────────────────────────────────────────
|
||||
|
||||
class PreferenceChoiceBase(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
|
||||
|
||||
class PreferenceChoiceCreate(PreferenceChoiceBase):
|
||||
pass
|
||||
|
||||
|
||||
class PreferenceChoiceOut(PreferenceChoiceBase):
|
||||
id: int
|
||||
set_id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PreferenceSetBase(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class PreferenceSetCreate(PreferenceSetBase):
|
||||
choices: List[PreferenceChoiceCreate] = []
|
||||
|
||||
|
||||
class PreferenceSetOut(PreferenceSetBase):
|
||||
id: int
|
||||
product_id: int
|
||||
choices: List[PreferenceChoiceOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProductBase(BaseModel):
|
||||
name: str
|
||||
category_id: Optional[int] = None
|
||||
@@ -66,6 +111,7 @@ class ProductBase(BaseModel):
|
||||
class ProductCreate(ProductBase):
|
||||
options: List[ProductOptionCreate] = []
|
||||
ingredients: List[ProductIngredientCreate] = []
|
||||
preference_sets: List[PreferenceSetCreate] = []
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
@@ -74,11 +120,16 @@ class ProductUpdate(BaseModel):
|
||||
base_price: Optional[float] = None
|
||||
is_available: Optional[bool] = None
|
||||
printer_zone_id: Optional[int] = None
|
||||
options: Optional[List[ProductOptionCreate]] = None
|
||||
ingredients: Optional[List[ProductIngredientCreate]] = None
|
||||
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
||||
|
||||
|
||||
class ProductOut(ProductBase):
|
||||
id: int
|
||||
options: List[ProductOptionOut] = []
|
||||
ingredients: List[ProductIngredientOut] = []
|
||||
preference_sets: List[PreferenceSetOut] = []
|
||||
image_url: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class TableGroupCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class TableGroupUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class TableGroupOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
sort_order: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TableBase(BaseModel):
|
||||
number: int
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
@@ -12,9 +29,17 @@ class TableCreate(TableBase):
|
||||
pass
|
||||
|
||||
|
||||
class TableBatchCreate(BaseModel):
|
||||
group_id: Optional[int] = None
|
||||
count: int
|
||||
name_prefix: str # e.g. "Out-" → Out-1, Out-2 ...
|
||||
start_number: int = 1
|
||||
|
||||
|
||||
class TableUpdate(BaseModel):
|
||||
number: Optional[int] = None
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
@@ -27,5 +52,6 @@ class TableOut(TableBase):
|
||||
id: int
|
||||
floor_x: Optional[float] = None
|
||||
floor_y: Optional[float] = None
|
||||
group: Optional[TableGroupOut] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
Reference in New Issue
Block a user