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:
2026-06-01 00:05:48 +03:00
parent 835806f92d
commit cd8a6c53cf
11 changed files with 620 additions and 8 deletions

View File

@@ -0,0 +1,171 @@
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
]