Adds four new routers, three schema files, and one new model.
All new endpoints use new URL prefixes — no existing routes touched.
routers/menu.py
GET /api/menu/{site_slug} — public menu snapshot (no auth)
POST /api/menu/sync — upsert snapshot (site API key)
routers/orders.py
POST /api/orders/{site_slug} — customer places order (public)
GET /api/orders/status/{public_ref} — customer tracks order (public)
GET /api/orders/pending/{site_id} — local backend polls (site key)
POST /api/orders/{id}/synced — mark synced (site key)
PATCH /api/orders/{id}/status — update status with transition
validation (site key)
routers/manager_auth.py
POST /api/manager/register — create manager account (admin JWT)
POST /api/manager/login — returns manager JWT
POST /api/manager/refresh — refreshes manager JWT
Shared _get_current_manager dependency used by remote_dashboard
routers/remote_dashboard.py
GET /api/remote/sites — manager JWT
GET /api/remote/sites/{id}/snapshot — manager JWT
GET /api/remote/sites/{id}/orders — manager JWT
GET /api/remote/sites/{id}/orders/pending — manager JWT
PATCH /api/remote/sites/{id}/orders/{oid}/status — manager JWT
POST /api/remote/snapshot — site API key
(stats push)
models/stats_snapshot.py (NEW)
stats_snapshots table — one row per site, holds serialized
operational stats; created by create_all on startup
config.py / .env.example
MANAGER_JWT_SECRET and MANAGER_JWT_EXPIRE_HOURS (default 72h)
added as separate settings from the admin SECRET_KEY
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
142 lines
5.3 KiB
Python
142 lines
5.3 KiB
Python
import json
|
|
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
|
from passlib.context import CryptContext
|
|
from sqlalchemy.orm import Session
|
|
|
|
from database import get_db
|
|
from models.site import Site
|
|
from models.online_order import OnlineOrder
|
|
from schemas.online_order import (
|
|
OnlineOrderCreate, OnlineOrderCreated, OrderStatusOut,
|
|
PendingOrderOut, OrderSyncedRequest, OrderStatusUpdateRequest,
|
|
)
|
|
from auth_utils import get_current_admin # manager JWT checked separately in manager_auth
|
|
|
|
router = APIRouter()
|
|
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
|
|
# Valid status transitions
|
|
_TRANSITIONS: dict[str, set[str]] = {
|
|
"pending_acceptance": {"accepted", "rejected"},
|
|
"accepted": {"preparing"},
|
|
"preparing": {"ready", "out_for_delivery"},
|
|
"ready": {"delivered"},
|
|
"out_for_delivery": {"delivered"},
|
|
}
|
|
|
|
|
|
def _require_site(
|
|
x_site_id: str = Header(..., alias="X-Site-ID"),
|
|
x_site_key: str = Header(..., alias="X-Site-Key"),
|
|
db: Session = Depends(get_db),
|
|
) -> Site:
|
|
site = db.query(Site).filter(Site.site_id == x_site_id).first()
|
|
if not site or not _pwd.verify(x_site_key, site.secret_key_hash):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid site credentials")
|
|
return site
|
|
|
|
|
|
# ── Public endpoints ──────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{site_slug}", response_model=OnlineOrderCreated, status_code=status.HTTP_201_CREATED)
|
|
def create_order(site_slug: str, body: OnlineOrderCreate, db: Session = Depends(get_db)):
|
|
"""Customer submits an order from the public menu SPA."""
|
|
site = db.query(Site).filter(Site.site_id == site_slug).first()
|
|
if not site:
|
|
raise HTTPException(status_code=404, detail="Site not found")
|
|
|
|
# Atomically increment counter and generate public_ref
|
|
site.order_counter = (site.order_counter or 0) + 1
|
|
public_ref = f"ORD-{site.order_counter:04d}"
|
|
|
|
order = OnlineOrder(
|
|
site_id=site.id,
|
|
public_ref=public_ref,
|
|
order_type=body.order_type,
|
|
customer_name=body.customer_name,
|
|
customer_phone=body.customer_phone,
|
|
customer_address=body.customer_address,
|
|
customer_notes=body.customer_notes,
|
|
items_json=json.dumps([item.model_dump() for item in body.items]),
|
|
subtotal=body.subtotal,
|
|
delivery_fee=body.delivery_fee,
|
|
total=body.total,
|
|
status="pending_acceptance",
|
|
)
|
|
db.add(order)
|
|
db.commit()
|
|
db.refresh(order)
|
|
return OnlineOrderCreated(order_id=order.id, public_ref=order.public_ref, status=order.status)
|
|
|
|
|
|
@router.get("/status/{public_ref}", response_model=OrderStatusOut)
|
|
def get_order_status(public_ref: str, db: Session = Depends(get_db)):
|
|
"""Customer polls this to track their order."""
|
|
order = db.query(OnlineOrder).filter(OnlineOrder.public_ref == public_ref).first()
|
|
if not order:
|
|
raise HTTPException(status_code=404, detail="Order not found")
|
|
return OrderStatusOut(
|
|
public_ref=order.public_ref,
|
|
status=order.status,
|
|
rejection_reason=order.rejection_reason,
|
|
)
|
|
|
|
|
|
# ── Internal endpoints (site API key) ─────────────────────────────────────────
|
|
|
|
@router.get("/pending/{site_id}", response_model=list[PendingOrderOut])
|
|
def get_pending_orders(site_id: int, site: Site = Depends(_require_site), db: Session = Depends(get_db)):
|
|
"""Return all orders not yet synced to the local backend."""
|
|
orders = (
|
|
db.query(OnlineOrder)
|
|
.filter(OnlineOrder.site_id == site_id, OnlineOrder.synced_to_local == 0)
|
|
.order_by(OnlineOrder.created_at)
|
|
.all()
|
|
)
|
|
return orders
|
|
|
|
|
|
@router.post("/{order_id}/synced", status_code=status.HTTP_204_NO_CONTENT)
|
|
def mark_order_synced(
|
|
order_id: int,
|
|
body: OrderSyncedRequest,
|
|
site: Site = Depends(_require_site),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Local backend calls this after it has created the order locally."""
|
|
order = db.query(OnlineOrder).filter(
|
|
OnlineOrder.id == order_id, OnlineOrder.site_id == site.id
|
|
).first()
|
|
if not order:
|
|
raise HTTPException(status_code=404, detail="Order not found")
|
|
order.synced_to_local = 1
|
|
order.local_order_id = body.local_order_id
|
|
db.commit()
|
|
|
|
|
|
@router.patch("/{order_id}/status", status_code=status.HTTP_204_NO_CONTENT)
|
|
def update_order_status(
|
|
order_id: int,
|
|
body: OrderStatusUpdateRequest,
|
|
site: Site = Depends(_require_site),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update order status. Called by local_backend after staff accept/reject/progress."""
|
|
order = db.query(OnlineOrder).filter(
|
|
OnlineOrder.id == order_id, OnlineOrder.site_id == site.id
|
|
).first()
|
|
if not order:
|
|
raise HTTPException(status_code=404, detail="Order not found")
|
|
|
|
allowed = _TRANSITIONS.get(order.status, set())
|
|
if body.status not in allowed:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Cannot transition from '{order.status}' to '{body.status}'",
|
|
)
|
|
|
|
order.status = body.status
|
|
if body.rejection_reason:
|
|
order.rejection_reason = body.rejection_reason
|
|
db.commit()
|