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

@@ -15,3 +15,8 @@ ADMIN_PASSWORD=changeme
# The current latest release version — clients compare against this to detect updates
LATEST_VERSION=0.0.0
# Xenia Connect — manager JWT (use a different secret from SECRET_KEY)
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
MANAGER_JWT_SECRET=change-me-manager-secret
MANAGER_JWT_EXPIRE_HOURS=72

View File

@@ -11,6 +11,9 @@ class Settings(BaseSettings):
ADMIN_USERNAME: str = "sysadmin"
ADMIN_PASSWORD: str = "changeme"
LATEST_VERSION: str = "0.0.0"
# Manager JWT (separate secret from admin JWT)
MANAGER_JWT_SECRET: str = "change-me-manager-secret"
MANAGER_JWT_EXPIRE_HOURS: int = 72
model_config = {"env_file": str(_HERE / ".env")}

View File

@@ -5,13 +5,18 @@ from fastapi.middleware.cors import CORSMiddleware
from config import settings
from database import engine, Base
from auth_utils import hash_password
import models.admin # noqa: F401
import models.site # noqa: F401
import models.menu_snapshot # noqa: F401
import models.online_order # noqa: F401
import models.manager_account # noqa: F401
import models.admin # noqa: F401
import models.site # noqa: F401
import models.menu_snapshot # noqa: F401
import models.online_order # noqa: F401
import models.manager_account # noqa: F401
import models.stats_snapshot # noqa: F401
from routers import auth, sites, heartbeat
from routers import menu as menu_router
from routers import orders as orders_router
from routers import manager_auth as manager_auth_router
from routers import remote_dashboard as remote_dashboard_router
def _seed_default_admin():
@@ -61,9 +66,13 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
app.include_router(heartbeat.router, prefix="/api/heartbeat", tags=["heartbeat"])
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
app.include_router(heartbeat.router, prefix="/api/heartbeat", tags=["heartbeat"])
app.include_router(menu_router.router, prefix="/api/menu", tags=["menu"])
app.include_router(orders_router.router, prefix="/api/orders", tags=["orders"])
app.include_router(manager_auth_router.router, prefix="/api/manager", tags=["manager"])
app.include_router(remote_dashboard_router.router,prefix="/api/remote", tags=["remote"])
@app.get("/health")

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, Text, DateTime, ForeignKey
from sqlalchemy.sql import func
from database import Base
class StatsSnapshot(Base):
__tablename__ = "stats_snapshots"
id = Column(Integer, primary_key=True)
site_id = Column(Integer, ForeignKey("sites.id"), nullable=False, unique=True)
snapshot_json = Column(Text, nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,91 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from config import settings
from database import get_db
from models.manager_account import ManagerAccount
from models.site import Site
from auth_utils import get_current_admin
from schemas.manager import (
ManagerRegisterRequest, ManagerLoginRequest,
ManagerTokenOut, ManagerOut,
)
router = APIRouter()
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
_manager_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/manager/login")
ALGORITHM = "HS256"
# ── Shared dependency: decode manager JWT ─────────────────────────────────────
def _get_current_manager(token: str = Depends(_manager_oauth2), db: Session = Depends(get_db)) -> ManagerAccount:
try:
payload = jwt.decode(token, settings.MANAGER_JWT_SECRET, algorithms=[ALGORITHM])
manager_id = int(payload.get("sub", 0))
except (JWTError, ValueError):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
manager = db.query(ManagerAccount).filter(ManagerAccount.id == manager_id).first()
if not manager or not manager.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Manager not found or inactive")
return manager
def _create_manager_token(manager: ManagerAccount) -> str:
site_ids = [s.id for s in manager.sites]
payload = {
"sub": str(manager.id),
"email": manager.email,
"site_ids": site_ids,
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.MANAGER_JWT_EXPIRE_HOURS),
}
return jwt.encode(payload, settings.MANAGER_JWT_SECRET, algorithm=ALGORITHM)
# ── Admin-only: register a manager ───────────────────────────────────────────
@router.post("/register", response_model=ManagerOut, status_code=status.HTTP_201_CREATED)
def register_manager(
body: ManagerRegisterRequest,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
if db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
sites = db.query(Site).filter(Site.id.in_(body.site_ids)).all()
manager = ManagerAccount(
email=body.email,
password_hash=_pwd.hash(body.password),
full_name=body.full_name,
sites=sites,
)
db.add(manager)
db.commit()
db.refresh(manager)
return manager
# ── Public: manager login ─────────────────────────────────────────────────────
@router.post("/login", response_model=ManagerTokenOut)
def login_manager(body: ManagerLoginRequest, db: Session = Depends(get_db)):
manager = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
if not manager or not _pwd.verify(body.password, manager.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not manager.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")
return ManagerTokenOut(access_token=_create_manager_token(manager))
# ── Public: refresh ───────────────────────────────────────────────────────────
@router.post("/refresh", response_model=ManagerTokenOut)
def refresh_manager_token(manager: ManagerAccount = Depends(_get_current_manager)):
return ManagerTokenOut(access_token=_create_manager_token(manager))

View File

@@ -0,0 +1,54 @@
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.menu_snapshot import MenuSnapshot
from schemas.menu import MenuSyncRequest, MenuSyncResponse
router = APIRouter()
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
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 ────────────────────────────────────────────────────────────────────
@router.get("/{site_slug}")
def get_menu(site_slug: str, db: Session = Depends(get_db)):
"""Return the latest menu snapshot for a site. Used by 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")
snapshot = db.query(MenuSnapshot).filter(MenuSnapshot.site_id == site.id).first()
if not snapshot:
raise HTTPException(status_code=404, detail="No menu published yet")
import json
return json.loads(snapshot.snapshot_json)
# ── Internal (site API key) ───────────────────────────────────────────────────
@router.post("/sync", response_model=MenuSyncResponse)
def sync_menu(body: MenuSyncRequest, site: Site = Depends(_require_site), db: Session = Depends(get_db)):
"""Upsert the menu snapshot for this site. Called by local_backend on each sync."""
snapshot = db.query(MenuSnapshot).filter(MenuSnapshot.site_id == body.site_id).first()
if snapshot:
snapshot.snapshot_json = body.snapshot_json
else:
snapshot = MenuSnapshot(site_id=body.site_id, snapshot_json=body.snapshot_json)
db.add(snapshot)
db.commit()
return MenuSyncResponse(ok=True)

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

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
]

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class ManagerRegisterRequest(BaseModel):
email: str
password: str
full_name: Optional[str] = None
site_ids: list[int] = []
class ManagerLoginRequest(BaseModel):
email: str
password: str
class ManagerTokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
class ManagerSiteOut(BaseModel):
id: int
site_id: str
name: str
model_config = {"from_attributes": True}
class ManagerOut(BaseModel):
id: int
email: str
full_name: Optional[str] = None
is_active: int
created_at: datetime
sites: list[ManagerSiteOut] = []
model_config = {"from_attributes": True}
class StatsSnapshotOut(BaseModel):
site_id: int
snapshot_json: str
updated_at: datetime
model_config = {"from_attributes": True}
class StatsSnapshotRequest(BaseModel):
site_id: int
snapshot_json: str

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
class MenuSyncRequest(BaseModel):
site_id: int
snapshot_json: str
class MenuSyncResponse(BaseModel):
ok: bool

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class OrderItemIn(BaseModel):
product_id: int
name: str
quantity: int
unit_price: float
options: list = []
class OnlineOrderCreate(BaseModel):
order_type: str # "delivery" | "dine_in"
customer_name: str
customer_phone: Optional[str] = None
customer_address: Optional[str] = None
customer_notes: Optional[str] = None
items: list[OrderItemIn]
subtotal: float
delivery_fee: float = 0.0
total: float
class OnlineOrderCreated(BaseModel):
order_id: int
public_ref: str
status: str
class OrderStatusOut(BaseModel):
public_ref: str
status: str
rejection_reason: Optional[str] = None
class PendingOrderOut(BaseModel):
id: int
public_ref: str
order_type: str
customer_name: str
customer_phone: Optional[str] = None
customer_address: Optional[str] = None
customer_notes: Optional[str] = None
items_json: str
subtotal: float
delivery_fee: float
total: float
status: str
created_at: datetime
model_config = {"from_attributes": True}
class OrderSyncedRequest(BaseModel):
local_order_id: int
class OrderStatusUpdateRequest(BaseModel):
status: str
rejection_reason: Optional[str] = None