From cd8a6c53cf109e0814c0736f51f676d5987362f5 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 1 Jun 2026 00:05:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(connect):=20Phase=203=20=E2=80=94=20cloud?= =?UTF-8?q?=20API=20routes=20for=20Xenia=20Connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cloud_backend/.env.example | 5 + cloud_backend/config.py | 3 + cloud_backend/main.py | 25 +++- cloud_backend/models/stats_snapshot.py | 12 ++ cloud_backend/routers/manager_auth.py | 91 ++++++++++++ cloud_backend/routers/menu.py | 54 +++++++ cloud_backend/routers/orders.py | 141 ++++++++++++++++++ cloud_backend/routers/remote_dashboard.py | 171 ++++++++++++++++++++++ cloud_backend/schemas/manager.py | 53 +++++++ cloud_backend/schemas/menu.py | 10 ++ cloud_backend/schemas/online_order.py | 63 ++++++++ 11 files changed, 620 insertions(+), 8 deletions(-) create mode 100644 cloud_backend/models/stats_snapshot.py create mode 100644 cloud_backend/routers/manager_auth.py create mode 100644 cloud_backend/routers/menu.py create mode 100644 cloud_backend/routers/orders.py create mode 100644 cloud_backend/routers/remote_dashboard.py create mode 100644 cloud_backend/schemas/manager.py create mode 100644 cloud_backend/schemas/menu.py create mode 100644 cloud_backend/schemas/online_order.py diff --git a/cloud_backend/.env.example b/cloud_backend/.env.example index ae0e639..38c8d8f 100644 --- a/cloud_backend/.env.example +++ b/cloud_backend/.env.example @@ -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 diff --git a/cloud_backend/config.py b/cloud_backend/config.py index 6a1f72f..e54c3f0 100644 --- a/cloud_backend/config.py +++ b/cloud_backend/config.py @@ -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")} diff --git a/cloud_backend/main.py b/cloud_backend/main.py index 24569e3..9b9b501 100644 --- a/cloud_backend/main.py +++ b/cloud_backend/main.py @@ -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") diff --git a/cloud_backend/models/stats_snapshot.py b/cloud_backend/models/stats_snapshot.py new file mode 100644 index 0000000..a1f6d06 --- /dev/null +++ b/cloud_backend/models/stats_snapshot.py @@ -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()) diff --git a/cloud_backend/routers/manager_auth.py b/cloud_backend/routers/manager_auth.py new file mode 100644 index 0000000..bdd6dc8 --- /dev/null +++ b/cloud_backend/routers/manager_auth.py @@ -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)) diff --git a/cloud_backend/routers/menu.py b/cloud_backend/routers/menu.py new file mode 100644 index 0000000..57b5032 --- /dev/null +++ b/cloud_backend/routers/menu.py @@ -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) diff --git a/cloud_backend/routers/orders.py b/cloud_backend/routers/orders.py new file mode 100644 index 0000000..c744a39 --- /dev/null +++ b/cloud_backend/routers/orders.py @@ -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() diff --git a/cloud_backend/routers/remote_dashboard.py b/cloud_backend/routers/remote_dashboard.py new file mode 100644 index 0000000..6103fa5 --- /dev/null +++ b/cloud_backend/routers/remote_dashboard.py @@ -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 + ] diff --git a/cloud_backend/schemas/manager.py b/cloud_backend/schemas/manager.py new file mode 100644 index 0000000..8946e1e --- /dev/null +++ b/cloud_backend/schemas/manager.py @@ -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 diff --git a/cloud_backend/schemas/menu.py b/cloud_backend/schemas/menu.py new file mode 100644 index 0000000..fa3d015 --- /dev/null +++ b/cloud_backend/schemas/menu.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class MenuSyncRequest(BaseModel): + site_id: int + snapshot_json: str + + +class MenuSyncResponse(BaseModel): + ok: bool diff --git a/cloud_backend/schemas/online_order.py b/cloud_backend/schemas/online_order.py new file mode 100644 index 0000000..d88e56f --- /dev/null +++ b/cloud_backend/schemas/online_order.py @@ -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