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:
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
12
cloud_backend/models/stats_snapshot.py
Normal file
12
cloud_backend/models/stats_snapshot.py
Normal 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())
|
||||
91
cloud_backend/routers/manager_auth.py
Normal file
91
cloud_backend/routers/manager_auth.py
Normal 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))
|
||||
54
cloud_backend/routers/menu.py
Normal file
54
cloud_backend/routers/menu.py
Normal 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)
|
||||
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()
|
||||
171
cloud_backend/routers/remote_dashboard.py
Normal file
171
cloud_backend/routers/remote_dashboard.py
Normal 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
|
||||
]
|
||||
53
cloud_backend/schemas/manager.py
Normal file
53
cloud_backend/schemas/manager.py
Normal 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
|
||||
10
cloud_backend/schemas/menu.py
Normal file
10
cloud_backend/schemas/menu.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MenuSyncRequest(BaseModel):
|
||||
site_id: int
|
||||
snapshot_json: str
|
||||
|
||||
|
||||
class MenuSyncResponse(BaseModel):
|
||||
ok: bool
|
||||
63
cloud_backend/schemas/online_order.py
Normal file
63
cloud_backend/schemas/online_order.py
Normal 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
|
||||
Reference in New Issue
Block a user