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>
172 lines
6.5 KiB
Python
172 lines
6.5 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from database import get_db
|
|
from models.manager_account import ManagerAccount
|
|
from models.online_order import OnlineOrder
|
|
from models.stats_snapshot import StatsSnapshot
|
|
from schemas.manager import StatsSnapshotOut, StatsSnapshotRequest
|
|
from routers.manager_auth import _get_current_manager
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Sites the manager can access ──────────────────────────────────────────────
|
|
|
|
@router.get("/sites")
|
|
def list_sites(manager: ManagerAccount = Depends(_get_current_manager)):
|
|
return [{"id": s.id, "site_id": s.site_id, "name": s.name} for s in manager.sites]
|
|
|
|
|
|
# ── Stats snapshot ────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/sites/{site_id}/snapshot", response_model=StatsSnapshotOut)
|
|
def get_stats_snapshot(
|
|
site_id: int,
|
|
manager: ManagerAccount = Depends(_get_current_manager),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_check_site_access(manager, site_id)
|
|
snap = db.query(StatsSnapshot).filter(StatsSnapshot.site_id == site_id).first()
|
|
if not snap:
|
|
raise HTTPException(status_code=404, detail="No snapshot available yet")
|
|
return snap
|
|
|
|
|
|
# ── Orders ────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/sites/{site_id}/orders")
|
|
def list_orders(
|
|
site_id: int,
|
|
order_status: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
manager: ManagerAccount = Depends(_get_current_manager),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_check_site_access(manager, site_id)
|
|
q = db.query(OnlineOrder).filter(OnlineOrder.site_id == site_id)
|
|
if order_status:
|
|
q = q.filter(OnlineOrder.status == order_status)
|
|
orders = q.order_by(OnlineOrder.created_at.desc()).offset(offset).limit(limit).all()
|
|
return _serialize_orders(orders)
|
|
|
|
|
|
@router.get("/sites/{site_id}/orders/pending")
|
|
def list_pending_orders(
|
|
site_id: int,
|
|
manager: ManagerAccount = Depends(_get_current_manager),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_check_site_access(manager, site_id)
|
|
orders = (
|
|
db.query(OnlineOrder)
|
|
.filter(OnlineOrder.site_id == site_id, OnlineOrder.status == "pending_acceptance")
|
|
.order_by(OnlineOrder.created_at)
|
|
.all()
|
|
)
|
|
return _serialize_orders(orders)
|
|
|
|
|
|
# ── Stats snapshot push (site API key — called by local_backend) ──────────────
|
|
# Imported and registered from main.py under a site-key-protected sub-path.
|
|
# Defined here as a plain function so remote_dashboard router stays manager-JWT only.
|
|
|
|
from fastapi import Header
|
|
from passlib.context import CryptContext
|
|
from models.site import Site
|
|
|
|
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
|
|
|
|
@router.post("/snapshot", status_code=status.HTTP_204_NO_CONTENT)
|
|
def push_stats_snapshot(
|
|
body: StatsSnapshotRequest,
|
|
x_site_id: str = Header(..., alias="X-Site-ID"),
|
|
x_site_key: str = Header(..., alias="X-Site-Key"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Local backend pushes stats every 5 minutes. Auth: site API key."""
|
|
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")
|
|
|
|
snap = db.query(StatsSnapshot).filter(StatsSnapshot.site_id == body.site_id).first()
|
|
if snap:
|
|
snap.snapshot_json = body.snapshot_json
|
|
else:
|
|
snap = StatsSnapshot(site_id=body.site_id, snapshot_json=body.snapshot_json)
|
|
db.add(snap)
|
|
db.commit()
|
|
|
|
|
|
# ── Manager can also update order status (goes via cloud, local polls) ─────────
|
|
|
|
from schemas.online_order import OrderStatusUpdateRequest
|
|
|
|
_TRANSITIONS: dict[str, set[str]] = {
|
|
"pending_acceptance": {"accepted", "rejected"},
|
|
"accepted": {"preparing"},
|
|
"preparing": {"ready", "out_for_delivery"},
|
|
"ready": {"delivered"},
|
|
"out_for_delivery": {"delivered"},
|
|
}
|
|
|
|
|
|
@router.patch("/sites/{site_id}/orders/{order_id}/status", status_code=status.HTTP_204_NO_CONTENT)
|
|
def manager_update_order_status(
|
|
site_id: int,
|
|
order_id: int,
|
|
body: OrderStatusUpdateRequest,
|
|
manager: ManagerAccount = Depends(_get_current_manager),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_check_site_access(manager, site_id)
|
|
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()
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _check_site_access(manager: ManagerAccount, site_id: int):
|
|
if not any(s.id == site_id for s in manager.sites):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this site")
|
|
|
|
|
|
def _serialize_orders(orders):
|
|
return [
|
|
{
|
|
"id": o.id,
|
|
"public_ref": o.public_ref,
|
|
"order_type": o.order_type,
|
|
"customer_name": o.customer_name,
|
|
"customer_phone": o.customer_phone,
|
|
"customer_address": o.customer_address,
|
|
"customer_notes": o.customer_notes,
|
|
"subtotal": o.subtotal,
|
|
"delivery_fee": o.delivery_fee,
|
|
"total": o.total,
|
|
"status": o.status,
|
|
"rejection_reason": o.rejection_reason,
|
|
"synced_to_local": o.synced_to_local,
|
|
"local_order_id": o.local_order_id,
|
|
"created_at": o.created_at.isoformat() if o.created_at else None,
|
|
"updated_at": o.updated_at.isoformat() if o.updated_at else None,
|
|
}
|
|
for o in orders
|
|
]
|