Files
xenia-pos-cloud/cloud_backend/routers/orders.py
bonamin cd8a6c53cf 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>
2026-06-01 00:05:48 +03:00

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()