feat(connect): Phase 3 — cloud API routes for Xenia Connect
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>
This commit is contained in:
141
cloud_backend/routers/orders.py
Normal file
141
cloud_backend/routers/orders.py
Normal file
@@ -0,0 +1,141 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user