from __future__ import annotations import asyncio import json import os import re import secrets import sqlite3 import unicodedata import uuid from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Literal, Optional from urllib.parse import quote from deep_translator import GoogleTranslator, MyMemoryTranslator from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, Response from fastapi.staticfiles import StaticFiles from langdetect import DetectorFactory, LangDetectException, detect from pydantic import BaseModel, Field VERSION = "0.1.6" DetectorFactory.seed = 0 ROOT = Path(__file__).resolve().parent DATA_DIR = Path(os.getenv("SMOS_DATA_DIR", ROOT / "data")) DB_PATH = DATA_DIR / "smos.db" ACCOUNT_ENV = Path( os.getenv( "DAILYGIT_ENV_FILE", Path.home() / ".config" / "dailyGit" / "account.env", ) ) MOLLIE_API_BASE = "https://api.mollie.com/v2" def _load_account_env(path: Path = ACCOUNT_ENV) -> None: """Load KEY=VALUE pairs from dailyGit account.env into os.environ (no overwrite).""" if not path.is_file(): return try: for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, val = line.partition("=") key = key.strip() if not key or key in os.environ: continue os.environ[key] = val.strip().strip("'").strip('"') except OSError: return _load_account_env() def corner_cafe_payments_from_env() -> dict: mollie_key = os.getenv("MOLLIE_API_KEY", "").strip() return { "businessName": os.getenv("CORNER_CAFE_BUSINESS_NAME", "The Corner Cafe").strip() or "The Corner Cafe", "payeeName": os.getenv("CORNER_CAFE_PAYEE_NAME", "").strip() or os.getenv("CORNER_CAFE_BUSINESS_NAME", "The Corner Cafe").strip() or "The Corner Cafe", "sortCode": os.getenv("CORNER_CAFE_SORT_CODE", "").strip(), "accountNumber": os.getenv("CORNER_CAFE_ACCOUNT_NUMBER", "").strip(), "bankName": os.getenv("CORNER_CAFE_BANK_NAME", "").strip(), "referencePrefix": os.getenv("CORNER_CAFE_REFERENCE_PREFIX", "CC").strip() or "CC", "mollieEnabled": bool(mollie_key), "mollieMode": "test" if mollie_key.startswith("test_") else ("live" if mollie_key.startswith("live_") else "off"), } def mollie_api_key() -> str: return os.getenv("MOLLIE_API_KEY", "").strip() def mollie_request(method: str, path: str, body: dict | None = None) -> dict: import urllib.error import urllib.request key = mollie_api_key() if not key: raise HTTPException( status_code=503, detail="Mollie is not configured. Set MOLLIE_API_KEY in account.env.", ) data = None headers = { "Authorization": f"Bearer {key}", "Accept": "application/json", "User-Agent": f"SmOS/{VERSION}", } if body is not None: data = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request( f"{MOLLIE_API_BASE}{path}", data=data, headers=headers, method=method, ) try: with urllib.request.urlopen(req, timeout=20) as resp: raw = resp.read().decode("utf-8") return json.loads(raw) if raw else {} except urllib.error.HTTPError as exc: err_body = "" try: err_body = exc.read().decode("utf-8", errors="replace") err_json = json.loads(err_body) if err_body else {} except Exception: err_json = {} detail = ( err_json.get("detail") or (err_json.get("title") and f"{err_json.get('title')}: {err_json.get('detail', '')}") or err_body or exc.reason ) raise HTTPException(status_code=502, detail=str(detail) or "Mollie API error") from exc except HTTPException: raise except Exception as exc: raise HTTPException(status_code=502, detail="Mollie API unavailable.") from exc def public_base_url(request: Request) -> str: configured = os.getenv("MOLLIE_PUBLIC_BASE", "").strip().rstrip("/") if configured: return configured proto = (request.headers.get("x-forwarded-proto") or request.url.scheme or "http").split(",")[0].strip() host = (request.headers.get("x-forwarded-host") or request.headers.get("host") or "").split(",")[0].strip() if host: return f"{proto}://{host}".rstrip("/") return str(request.base_url).rstrip("/") _UK_POSTCODE_RE = re.compile(r"^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$", re.I) def _normalise_uk_postcode(raw: str) -> str: compact = re.sub(r"\s+", "", (raw or "").strip().upper()) if len(compact) < 5: return compact return f"{compact[:-3]} {compact[-3:]}" def _format_ideal_address(row: dict) -> str: parts = [ row.get("line_1"), row.get("line_2"), row.get("line_3"), row.get("line_4"), row.get("locality") or row.get("dependant_locality"), row.get("town") or row.get("post_town"), row.get("county"), row.get("postcode"), ] return ", ".join(str(p).strip() for p in parts if p and str(p).strip()) def lookup_uk_addresses(postcode: str) -> dict: """ Live UK address list for a postcode via Ideal Postcodes. Set IDEAL_POSTCODES_API_KEY (or SMOS_ADDRESS_API_KEY) in env / account.env. """ normalised = _normalise_uk_postcode(postcode) if not _UK_POSTCODE_RE.match(normalised): raise HTTPException(status_code=400, detail="Enter a valid UK postcode.") key = ( os.getenv("IDEAL_POSTCODES_API_KEY", "").strip() or os.getenv("SMOS_ADDRESS_API_KEY", "").strip() or "ak_test" # Ideal public test key (rate-limited); set IDEAL_POSTCODES_API_KEY for production ) demo = key == "ak_test" import urllib.error import urllib.request url = ( "https://api.ideal-postcodes.co.uk/v1/postcodes/" f"{quote(normalised)}?api_key={quote(key)}" ) req = urllib.request.Request( url, headers={"Accept": "application/json", "User-Agent": f"SmOS/{VERSION}"}, method="GET", ) try: with urllib.request.urlopen(req, timeout=12) as resp: payload = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: body = "" try: body = exc.read().decode("utf-8", errors="replace") data = json.loads(body) if body else {} except Exception: data = {} msg = data.get("message") or data.get("detail") or body or exc.reason if exc.code == 404: raise HTTPException(status_code=404, detail="No addresses found for that postcode.") from exc if exc.code in {401, 402, 403}: raise HTTPException( status_code=503, detail="Address lookup key is invalid or out of credit.", ) from exc raise HTTPException(status_code=502, detail=str(msg) or "Address lookup failed.") from exc except HTTPException: raise except Exception as exc: raise HTTPException(status_code=502, detail="Address lookup service unavailable.") from exc rows = payload.get("result") if isinstance(payload, dict) else None if not isinstance(rows, list) or not rows: raise HTTPException(status_code=404, detail="No addresses found for that postcode.") addresses = [] for row in rows: if not isinstance(row, dict): continue label = _format_ideal_address(row) if not label: continue addresses.append( { "label": label, "line1": row.get("line_1") or "", "line2": row.get("line_2") or "", "line3": row.get("line_3") or "", "town": row.get("post_town") or row.get("town") or "", "postcode": row.get("postcode") or normalised, } ) if not addresses: raise HTTPException(status_code=404, detail="No addresses found for that postcode.") return { "postcode": normalised, "count": len(addresses), "addresses": addresses, "demo": demo, } LANGUAGES = [ {"code": "en", "name": "English", "locale": "en-GB"}, {"code": "tr", "name": "Türkçe · Turkish — optimised", "locale": "tr-TR"}, {"code": "ar", "name": "العربية · Arabic", "locale": "ar-SA"}, {"code": "bn", "name": "বাংলা · Bengali", "locale": "bn-BD"}, {"code": "zh-CN", "name": "中文 · Chinese", "locale": "zh-CN"}, {"code": "cs", "name": "Čeština · Czech", "locale": "cs-CZ"}, {"code": "da", "name": "Dansk · Danish", "locale": "da-DK"}, {"code": "nl", "name": "Nederlands · Dutch", "locale": "nl-NL"}, {"code": "fi", "name": "Suomi · Finnish", "locale": "fi-FI"}, {"code": "fr", "name": "Français · French", "locale": "fr-FR"}, {"code": "de", "name": "Deutsch · German", "locale": "de-DE"}, {"code": "el", "name": "Ελληνικά · Greek", "locale": "el-GR"}, {"code": "hi", "name": "हिन्दी · Hindi", "locale": "hi-IN"}, {"code": "hu", "name": "Magyar · Hungarian", "locale": "hu-HU"}, {"code": "id", "name": "Bahasa Indonesia", "locale": "id-ID"}, {"code": "it", "name": "Italiano · Italian", "locale": "it-IT"}, {"code": "ja", "name": "日本語 · Japanese", "locale": "ja-JP"}, {"code": "ko", "name": "한국어 · Korean", "locale": "ko-KR"}, {"code": "ms", "name": "Bahasa Melayu · Malay", "locale": "ms-MY"}, {"code": "no", "name": "Norsk · Norwegian", "locale": "nb-NO"}, {"code": "pl", "name": "Polski · Polish", "locale": "pl-PL"}, {"code": "pt", "name": "Português · Portuguese", "locale": "pt-PT"}, {"code": "ro", "name": "Română · Romanian", "locale": "ro-RO"}, {"code": "ru", "name": "Русский · Russian", "locale": "ru-RU"}, {"code": "es", "name": "Español · Spanish", "locale": "es-ES"}, {"code": "sw", "name": "Kiswahili · Swahili", "locale": "sw-KE"}, {"code": "sv", "name": "Svenska · Swedish", "locale": "sv-SE"}, {"code": "ta", "name": "தமிழ் · Tamil", "locale": "ta-IN"}, {"code": "th", "name": "ไทย · Thai", "locale": "th-TH"}, {"code": "uk", "name": "Українська · Ukrainian", "locale": "uk-UA"}, {"code": "ur", "name": "اردو · Urdu", "locale": "ur-PK"}, {"code": "vi", "name": "Tiếng Việt · Vietnamese", "locale": "vi-VN"}, ] LANGUAGE_CODES = {item["code"] for item in LANGUAGES} STATUSES = {"new", "accepted", "preparing", "ready", "served", "cancelled"} BOOKING_STATUSES = {"new", "confirmed", "preparing", "ready", "completed", "cancelled"} BOOKING_PAYMENT_STATUSES = {"unpaid", "awaiting", "paid", "failed", "n/a"} BOOKING_KITCHEN_STATUSES = {"not_started", "in_progress", "done"} BOOKING_BARISTA_STATUSES = {"not_started", "in_progress", "done"} BOOKING_KINDS = {"order", "reservation"} BOOKING_SERVICES = {"delivery", "collection", "sitting-in", "reservation"} ADMIN_SESSION_HOURS = int(os.getenv("SMOS_ADMIN_SESSION_HOURS", "24")) # Per-module admin credentials and display metadata. MODULES: dict[str, dict[str, str]] = { "SmOS_CC": { "name": "The Corner Cafe", "admin_user": os.getenv( "SMOS_CC_ADMIN_USER", os.getenv("SMOS_ADMIN_USER", "admin"), ), "admin_pass": os.getenv( "SMOS_CC_ADMIN_PASSWORD", os.getenv("SMOS_ADMIN_PASSWORD", "corner-cafe"), ), }, } TURKISH_SAFETY_TERMS = { ("tr", "en"): { "alerjim var": ("I have an allergy", ("allergy", "allergic")), "glütensiz": ("gluten-free", ("gluten-free", "without gluten")), "laktozsuz": ("lactose-free", ("lactose-free", "without lactose")), "süt ürünü olmasın": ("no dairy", ("no dairy", "dairy-free", "without dairy")), "fıstık alerjisi": ("peanut allergy", ("peanut allergy", "allergic to peanuts")), "fıstık alerjim var": ("peanut allergy", ("peanut allergy", "allergic to peanuts")), "fıstığa alerjim var": ("peanut allergy", ("peanut allergy", "allergic to peanuts")), "kuruyemiş alerjisi": ("nut allergy", ("nut allergy", "allergic to nuts")), "kuruyemişe alerjim var": ("nut allergy", ("nut allergy", "allergic to nuts")), "domuz eti olmasın": ("no pork", ("no pork", "without pork")), "soğansız": ("no onion", ("no onion", "without onion", "onion-free")), "sarımsaksız": ("no garlic", ("no garlic", "without garlic", "garlic-free")), "acısız": ("not spicy", ("not spicy", "non-spicy", "without spice")), }, ("en", "tr"): { "i have an allergy": ("alerjim var", ("alerjim var", "alerjim bulunuyor")), "gluten-free": ("glütensiz", ("glütensiz", "glutensiz")), "lactose-free": ("laktozsuz", ("laktozsuz",)), "no dairy": ("süt ürünü olmasın", ("süt ürünü olmasın", "süt ürünsüz")), "peanut allergy": ( "fıstık alerjisi", ("fıstık alerjisi", "fıstığa alerjim var", "yer fıstığına alerjim var"), ), "nut allergy": ("kuruyemiş alerjisi", ("kuruyemiş alerjisi",)), "no pork": ("domuz eti olmasın", ("domuz eti olmasın", "domuz etsiz")), "no onion": ("soğansız", ("soğansız", "soğan olmasın")), "no garlic": ("sarımsaksız", ("sarımsaksız", "sarımsak olmasın")), "not spicy": ("acısız", ("acısız", "acı olmasın", "baharatlı değil")), }, } def utc_now() -> str: return datetime.now(timezone.utc).isoformat() def connect() -> sqlite3.Connection: connection = sqlite3.connect(DB_PATH) connection.row_factory = sqlite3.Row return connection def initialise_database() -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) with connect() as db: db.execute( """ CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, order_number INTEGER NOT NULL, customer_name TEXT NOT NULL, table_number TEXT NOT NULL, original_text TEXT NOT NULL, original_language TEXT NOT NULL, kitchen_text TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """ ) db.execute( """ CREATE TABLE IF NOT EXISTS translations ( order_id TEXT NOT NULL, language TEXT NOT NULL, text TEXT NOT NULL, PRIMARY KEY (order_id, language) ) """ ) db.execute( """ CREATE TABLE IF NOT EXISTS bookings ( id TEXT PRIMARY KEY, module_id TEXT NOT NULL DEFAULT 'SmOS_CC', ref TEXT NOT NULL, kind TEXT NOT NULL, service TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'new', customer_name TEXT NOT NULL, phone TEXT, email TEXT, confirm_via TEXT, party_size INTEGER, arrival_date TEXT, arrival_window TEXT, address TEXT, notes TEXT, total REAL, payment TEXT, payment_status TEXT, kitchen_status TEXT NOT NULL DEFAULT 'not_started', indian_kitchen_status TEXT NOT NULL DEFAULT 'done', barista_status TEXT NOT NULL DEFAULT 'not_started', food_served INTEGER NOT NULL DEFAULT 0, indian_food_served INTEGER NOT NULL DEFAULT 1, drinks_served INTEGER NOT NULL DEFAULT 0, items_json TEXT, tables_json TEXT, payload_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """ ) db.execute( """ CREATE TABLE IF NOT EXISTS admin_sessions ( token TEXT PRIMARY KEY, module_id TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL ) """ ) db.execute( """ CREATE TABLE IF NOT EXISTS mollie_pending ( id TEXT PRIMARY KEY, mollie_payment_id TEXT UNIQUE, module_id TEXT NOT NULL, status TEXT NOT NULL, booking_id TEXT, draft_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """ ) migrate_database(db) def migrate_database(db: sqlite3.Connection) -> None: booking_cols = {row[1] for row in db.execute("PRAGMA table_info(bookings)").fetchall()} if "module_id" not in booking_cols: db.execute( "ALTER TABLE bookings ADD COLUMN module_id TEXT NOT NULL DEFAULT 'SmOS_CC'" ) if "payment_status" not in booking_cols: db.execute("ALTER TABLE bookings ADD COLUMN payment_status TEXT") if "kitchen_status" not in booking_cols: db.execute( "ALTER TABLE bookings ADD COLUMN kitchen_status TEXT NOT NULL DEFAULT 'not_started'" ) if "barista_status" not in booking_cols: db.execute( "ALTER TABLE bookings ADD COLUMN barista_status TEXT NOT NULL DEFAULT 'not_started'" ) if "food_served" not in booking_cols: db.execute("ALTER TABLE bookings ADD COLUMN food_served INTEGER NOT NULL DEFAULT 0") if "drinks_served" not in booking_cols: db.execute( "ALTER TABLE bookings ADD COLUMN drinks_served INTEGER NOT NULL DEFAULT 0" ) if "indian_kitchen_status" not in booking_cols: db.execute( "ALTER TABLE bookings ADD COLUMN indian_kitchen_status TEXT NOT NULL DEFAULT 'done'" ) if "indian_food_served" not in booking_cols: db.execute( "ALTER TABLE bookings ADD COLUMN indian_food_served INTEGER NOT NULL DEFAULT 1" ) session_cols = {row[1] for row in db.execute("PRAGMA table_info(admin_sessions)").fetchall()} if session_cols and "module_id" not in session_cols: db.execute( "ALTER TABLE admin_sessions ADD COLUMN module_id TEXT NOT NULL DEFAULT 'SmOS_CC'" ) def infer_payment_status( kind: str, payment: Optional[str], payload: Optional[dict], explicit: Optional[str] = None, ) -> str: if explicit and explicit in BOOKING_PAYMENT_STATUSES: return explicit blob = payload or {} nested = blob.get("paymentStatus") or blob.get("payment_status") if isinstance(nested, str) and nested in BOOKING_PAYMENT_STATUSES: return nested if kind == "reservation": return "n/a" method = str(blob.get("payment") or payment or "").strip().lower() if method in {"bank-transfer", "bank transfer"} or "transfer" in method: return "awaiting" if method in {"cash-collection", "cash-sit-in"} or method.startswith("cash") or "pay at" in method: return "unpaid" return "unpaid" def get_module(module_id: str) -> dict[str, str]: module = MODULES.get(module_id) if not module: raise HTTPException(status_code=404, detail="Unknown module.") return module def normalise_translation_input(text: str, source: str) -> str: normalised = unicodedata.normalize("NFC", text).replace("’", "'").replace("`", "'") normalised = " ".join(normalised.split()) if source == "tr": # Preserve dotted/dotless Turkish characters instead of ASCII-folding them. normalised = normalised.replace("İ", "İ") return normalised def infer_source_language(text: str, requested: str = "auto") -> str: if requested in LANGUAGE_CODES: return requested lowered = unicodedata.normalize("NFC", text).casefold() turkish_markers = ( "ç", "ğ", "ı", "ö", "ş", "ü", " istiyorum", " olsun", " olmasın", " lütfen", ) if any(marker in lowered for marker in turkish_markers): return "tr" try: detected = detect(text) aliases = {"zh-cn": "zh-CN", "zh-tw": "zh-CN"} detected = aliases.get(detected, detected) if detected in LANGUAGE_CODES: return detected except LangDetectException: pass return "auto" def preserve_turkish_safety_terms( source_text: str, translated_text: str, source: str, target: str ) -> str: terms = TURKISH_SAFETY_TERMS.get((source, target), {}) source_lower = source_text.casefold() translated_lower = translated_text.casefold() missing: list[str] = [] for phrase, (canonical, accepted) in terms.items(): source_has_phrase = re.search( rf"(? str: if source == "tr" and target == "en" and "acısız" in source_text.casefold(): # Generic translators can read "acı" as pain; on a food order it means spice/heat. translated_text = re.sub( r"\b(?:without\s+pain|no\s+pain|pain[\s-]?free|painless)\b", "not spicy", translated_text, flags=re.IGNORECASE, ) return translated_text def google_translate(text: str, source: str, target: str) -> str: if not text.strip() or source == target: return text source = infer_source_language(text, source) source_code = "auto" if source not in LANGUAGE_CODES else source normalised = normalise_translation_input(text, source) try: translated = GoogleTranslator(source=source_code, target=target).translate(normalised) except Exception: try: memory_codes = { "en": "en-GB", "tr": "tr-TR", } translated = MyMemoryTranslator( source=memory_codes.get(source, source), target=memory_codes.get(target, target), ).translate(normalised) except Exception as exc: raise HTTPException( status_code=503, detail="Translation is temporarily unavailable. Please retry.", ) from exc if {source, target} == {"en", "tr"}: translated = correct_turkish_restaurant_context(normalised, translated, source, target) translated = preserve_turkish_safety_terms(normalised, translated, source, target) return translated async def translate(text: str, source: str, target: str) -> str: return await asyncio.to_thread(google_translate, text, source, target) class ConnectionManager: def __init__(self) -> None: self.connections: list[WebSocket] = [] async def connect(self, websocket: WebSocket) -> None: await websocket.accept() self.connections.append(websocket) def disconnect(self, websocket: WebSocket) -> None: if websocket in self.connections: self.connections.remove(websocket) async def broadcast(self, event: dict) -> None: stale: list[WebSocket] = [] for connection in self.connections: try: await connection.send_json(event) except Exception: stale.append(connection) for connection in stale: self.disconnect(connection) manager = ConnectionManager() class TranslationRequest(BaseModel): text: str = Field(min_length=1, max_length=1500) source: str = "auto" target: str class OrderCreate(BaseModel): customer_name: str = Field(default="Guest", min_length=1, max_length=80) table_number: str = Field( default_factory=lambda: f"AUTO-{uuid.uuid4().hex[:6].upper()}", min_length=1, max_length=30, ) order_text: str = Field(min_length=2, max_length=1500) language: str = "auto" class OrderStatusUpdate(BaseModel): status: Literal["new", "accepted", "preparing", "ready", "served", "cancelled"] class BookingCreate(BaseModel): module_id: str = Field(default="SmOS_CC", min_length=1, max_length=40) kind: Literal["order", "reservation"] service: str = Field(min_length=1, max_length=40) ref: str = Field(min_length=1, max_length=40) customer_name: str = Field(min_length=1, max_length=80) phone: Optional[str] = Field(default=None, max_length=40) email: Optional[str] = Field(default=None, max_length=120) confirm_via: Optional[Literal["phone", "email", "none"]] = None party_size: Optional[int] = Field(default=None, ge=1, le=30) arrival_date: Optional[str] = None arrival_window: Optional[str] = None address: Optional[str] = Field(default=None, max_length=500) notes: Optional[str] = Field(default=None, max_length=1000) total: Optional[float] = Field(default=None, ge=0) payment: Optional[str] = Field(default=None, max_length=80) payment_status: Optional[ Literal["unpaid", "awaiting", "paid", "failed", "n/a"] ] = None items: Optional[list] = None tables: Optional[list] = None payload: dict = Field(default_factory=dict) class BookingStatusUpdate(BaseModel): status: Optional[ Literal["new", "confirmed", "preparing", "ready", "completed", "cancelled"] ] = None payment_status: Optional[ Literal["unpaid", "awaiting", "paid", "failed", "n/a"] ] = None kitchen_status: Optional[ Literal["not_started", "in_progress", "done"] ] = None indian_kitchen_status: Optional[ Literal["not_started", "in_progress", "done"] ] = None barista_status: Optional[ Literal["not_started", "in_progress", "done"] ] = None food_served: Optional[bool] = None indian_food_served: Optional[bool] = None drinks_served: Optional[bool] = None class AdminLogin(BaseModel): username: str = Field(min_length=1, max_length=80) password: str = Field(min_length=1, max_length=120) class MollieCheckoutCreate(BaseModel): module_id: str = Field(default="SmOS_CC", min_length=1, max_length=40) kind: Literal["order"] = "order" service: Literal["delivery", "collection"] ref: str = Field(min_length=1, max_length=40) customer_name: str = Field(min_length=1, max_length=80) phone: Optional[str] = Field(default=None, max_length=40) email: Optional[str] = Field(default=None, max_length=120) confirm_via: Optional[Literal["phone", "email", "none"]] = None address: Optional[str] = Field(default=None, max_length=500) notes: Optional[str] = Field(default=None, max_length=1000) total: float = Field(ge=0.01, le=5000) items: list = Field(min_length=1) tables: Optional[list] = None payload: dict = Field(default_factory=dict) def gbp_amount(value: float) -> str: return f"{round(float(value) + 1e-9, 2):.2f}" def insert_booking_from_draft(draft: dict, *, payment_name: str, payment_status: str) -> dict: module_id = str(draft.get("module_id") or "SmOS_CC").strip() get_module(module_id) service = str(draft.get("service") or "").strip().lower() kind = str(draft.get("kind") or "order").strip().lower() if kind != "order" or service not in {"delivery", "collection"}: raise HTTPException(status_code=400, detail="Mollie drafts are for delivery/collection orders only.") if payment_status != "paid": raise HTTPException(status_code=400, detail="Booking requires paid Mollie status.") booking_id = str(uuid.uuid4()) now = utc_now() items = draft.get("items") or [] tables = draft.get("tables") payload_dict = dict(draft.get("payload") or {}) payload_dict.update( { "payment": "mollie", "paymentName": payment_name, "paymentStatus": payment_status, "paymentConfirmed": True, "molliePaymentId": draft.get("mollie_payment_id"), } ) stations = station_defaults_from_items(items) with connect() as db: db.execute( """ INSERT INTO bookings ( id, module_id, ref, kind, service, status, customer_name, phone, email, confirm_via, party_size, arrival_date, arrival_window, address, notes, total, payment, payment_status, kitchen_status, indian_kitchen_status, barista_status, food_served, indian_food_served, drinks_served, items_json, tables_json, payload_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( booking_id, module_id, str(draft.get("ref") or "").strip(), kind, service, "confirmed", str(draft.get("customer_name") or "").strip(), (str(draft.get("phone") or "").strip() or None), (str(draft.get("email") or "").strip() or None), draft.get("confirm_via"), draft.get("party_size"), draft.get("arrival_date"), draft.get("arrival_window"), (str(draft.get("address") or "").strip() or None), (str(draft.get("notes") or "").strip() or None), float(draft.get("total") or 0), payment_name, payment_status, stations["kitchen_status"], stations["indian_kitchen_status"], stations["barista_status"], stations["food_served"], stations["indian_food_served"], stations["drinks_served"], json.dumps(items), json.dumps(tables) if tables is not None else None, json.dumps(payload_dict), now, now, ), ) row = db.execute("SELECT * FROM bookings WHERE id = ?", (booking_id,)).fetchone() return booking_row_to_dict(row) async def finalize_mollie_payment(mollie_payment_id: str) -> dict: payment = await asyncio.to_thread(mollie_request, "GET", f"/payments/{mollie_payment_id}") status = str(payment.get("status") or "") with connect() as db: row = db.execute( "SELECT * FROM mollie_pending WHERE mollie_payment_id = ?", (mollie_payment_id,), ).fetchone() if not row: raise HTTPException(status_code=404, detail="Unknown Mollie payment.") pending = dict(row) if pending.get("booking_id"): booking = db.execute( "SELECT * FROM bookings WHERE id = ?", (pending["booking_id"],) ).fetchone() return { "pending_id": pending["id"], "mollie_payment_id": mollie_payment_id, "mollie_status": status, "booking": booking_row_to_dict(booking) if booking else None, "finalized": True, } now = utc_now() if status != "paid": with connect() as db: db.execute( "UPDATE mollie_pending SET status = ?, updated_at = ? WHERE mollie_payment_id = ?", (status or "open", now, mollie_payment_id), ) return { "pending_id": pending["id"], "mollie_payment_id": mollie_payment_id, "mollie_status": status, "booking": None, "finalized": False, } draft = json.loads(pending["draft_json"]) draft["mollie_payment_id"] = mollie_payment_id method = "" try: method = str((payment.get("method") or "")).strip() except Exception: method = "" pay_name = f"Mollie{(' · ' + method) if method else ''}" booking = insert_booking_from_draft(draft, payment_name=pay_name, payment_status="paid") with connect() as db: db.execute( """ UPDATE mollie_pending SET status = ?, booking_id = ?, updated_at = ? WHERE mollie_payment_id = ? """, ("paid", booking["id"], now, mollie_payment_id), ) await manager.broadcast({"type": "booking.created", "booking": booking}) return { "pending_id": pending["id"], "mollie_payment_id": mollie_payment_id, "mollie_status": "paid", "booking": booking, "finalized": True, } def booking_row_to_dict(row: sqlite3.Row) -> dict: data = dict(row) for key in ("items_json", "tables_json", "payload_json"): raw = data.get(key) if raw: try: data[key.replace("_json", "")] = json.loads(raw) except json.JSONDecodeError: data[key.replace("_json", "")] = raw else: data[key.replace("_json", "")] = None if not data.get("payment_status"): data["payment_status"] = infer_payment_status( data.get("kind") or "order", data.get("payment"), data.get("payload") if isinstance(data.get("payload"), dict) else {}, ) if not data.get("kitchen_status"): data["kitchen_status"] = "not_started" if not data.get("indian_kitchen_status"): data["indian_kitchen_status"] = "done" if not data.get("barista_status"): data["barista_status"] = "not_started" data["food_served"] = bool(data.get("food_served")) data["indian_food_served"] = bool(data.get("indian_food_served", 1)) data["drinks_served"] = bool(data.get("drinks_served")) return data def purge_expired_admin_sessions(db: sqlite3.Connection) -> None: db.execute("DELETE FROM admin_sessions WHERE expires_at < ?", (utc_now(),)) def create_admin_session(db: sqlite3.Connection, module_id: str) -> str: purge_expired_admin_sessions(db) token = secrets.token_urlsafe(32) now = datetime.now(timezone.utc) expires = now + timedelta(hours=ADMIN_SESSION_HOURS) db.execute( "INSERT INTO admin_sessions (token, module_id, created_at, expires_at) VALUES (?, ?, ?, ?)", (token, module_id, now.isoformat(), expires.isoformat()), ) return token def verify_module_admin( module_id: str, authorization: Optional[str] = Header(default=None), ) -> str: get_module(module_id) if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Admin authentication required.") token = authorization.removeprefix("Bearer ").strip() if not token: raise HTTPException(status_code=401, detail="Admin authentication required.") with connect() as db: purge_expired_admin_sessions(db) row = db.execute( """ SELECT token FROM admin_sessions WHERE token = ? AND module_id = ? AND expires_at >= ? """, (token, module_id, utc_now()), ).fetchone() if not row: raise HTTPException(status_code=401, detail="Invalid or expired admin session.") return token def row_to_dict(row: sqlite3.Row) -> dict: return dict(row) @asynccontextmanager async def lifespan(_: FastAPI): initialise_database() yield app = FastAPI(title="SmOS · AI powered online ordering", version=VERSION, lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=[ "http://127.0.0.1:8877", "http://localhost:8877", "http://127.0.0.1:7860", "http://localhost:7860", "http://127.0.0.1:8080", "http://localhost:8080", ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/api/modules/SmOS_CC/payments") async def smos_cc_payments() -> dict: return corner_cafe_payments_from_env() @app.post("/api/modules/SmOS_CC/mollie/create") async def smos_cc_mollie_create(payload: MollieCheckoutCreate, request: Request) -> dict: if payload.service == "delivery" and not (payload.address or "").strip(): raise HTTPException(status_code=400, detail="Delivery address is required.") if not payload.items: raise HTTPException(status_code=400, detail="Cart is empty.") pending_id = str(uuid.uuid4()) now = utc_now() draft = payload.model_dump() draft["payload"] = { **(payload.payload or {}), "ref": payload.ref, "service": payload.service, "name": payload.customer_name, "phone": payload.phone, "email": payload.email, "confirmVia": payload.confirm_via, "address": payload.address, "notes": payload.notes, "total": payload.total, "items": payload.items, "tables": payload.tables, "payment": "mollie", "paymentName": "Mollie", "paymentStatus": "awaiting", } base = public_base_url(request) redirect_url = ( f"{base}/modules/SmOS_CC/checkout.html" f"?mollie_return=1&payment_id={{paymentId}}" ) # Mollie expands {paymentId} in redirectUrl. webhook_url = f"{base}/api/modules/SmOS_CC/mollie/webhook" with connect() as db: db.execute( """ INSERT INTO mollie_pending ( id, mollie_payment_id, module_id, status, booking_id, draft_json, created_at, updated_at ) VALUES (?, NULL, ?, ?, NULL, ?, ?, ?) """, (pending_id, payload.module_id, "open", json.dumps(draft), now, now), ) body = { "amount": {"currency": "GBP", "value": gbp_amount(payload.total)}, "description": f"Corner Cafe {payload.ref}", "redirectUrl": redirect_url, "webhookUrl": webhook_url, "metadata": { "pending_id": pending_id, "ref": payload.ref, "module_id": payload.module_id, "service": payload.service, }, } try: payment = await asyncio.to_thread(mollie_request, "POST", "/payments", body) except Exception: with connect() as db: db.execute("DELETE FROM mollie_pending WHERE id = ?", (pending_id,)) raise mollie_id = str(payment.get("id") or "") checkout = ((payment.get("_links") or {}).get("checkout") or {}).get("href") if not mollie_id or not checkout: with connect() as db: db.execute("DELETE FROM mollie_pending WHERE id = ?", (pending_id,)) raise HTTPException(status_code=502, detail="Mollie did not return a checkout URL.") with connect() as db: db.execute( """ UPDATE mollie_pending SET mollie_payment_id = ?, status = ?, updated_at = ? WHERE id = ? """, (mollie_id, str(payment.get("status") or "open"), utc_now(), pending_id), ) return { "pending_id": pending_id, "payment_id": mollie_id, "checkout_url": checkout, "status": payment.get("status"), "mode": corner_cafe_payments_from_env().get("mollieMode"), } @app.get("/api/modules/SmOS_CC/mollie/status") async def smos_cc_mollie_status(payment_id: str = Query(..., min_length=5, max_length=40)) -> dict: return await finalize_mollie_payment(payment_id.strip()) @app.post("/api/modules/SmOS_CC/mollie/webhook") async def smos_cc_mollie_webhook(request: Request) -> Response: form = await request.form() payment_id = str(form.get("id") or "").strip() if not payment_id: raise HTTPException(status_code=400, detail="Missing payment id.") await finalize_mollie_payment(payment_id) return Response(content="ok", media_type="text/plain") @app.get("/api/modules/SmOS_CC/addresses") async def smos_cc_addresses(postcode: str = Query(..., min_length=2, max_length=16)) -> dict: """Live UK addresses for a postcode (Ideal Postcodes; key from env).""" return await asyncio.to_thread(lookup_uk_addresses, postcode) @app.get("/modules/SmOS_CC/payments-config.js") async def smos_cc_payments_config_js(): base = (ROOT / "modules" / "SmOS_CC" / "payments-config.js").read_text(encoding="utf-8") overlay = corner_cafe_payments_from_env() inject = ( "\n;(() => {\n" f" const overlay = {json.dumps(overlay)};\n" " window.SmOS_CC_Payments = Object.assign({}, window.SmOS_CC_Payments || {}, overlay);\n" "})();\n" ) return Response( content=base + inject, media_type="application/javascript; charset=utf-8", headers={"Cache-Control": "no-store"}, ) app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static") app.mount("/modules", StaticFiles(directory=ROOT / "modules", html=True), name="modules") @app.get("/") async def home() -> FileResponse: platform = ROOT / "index.html" if platform.exists(): return FileResponse(platform) return FileResponse(ROOT / "static" / "index.html") @app.get("/styles.css") async def platform_styles() -> FileResponse: return FileResponse(ROOT / "styles.css") @app.get("/site.js") async def platform_site_js() -> FileResponse: return FileResponse(ROOT / "site.js") @app.get("/guest") async def legacy_guest() -> FileResponse: return FileResponse(ROOT / "static" / "index.html") @app.get("/api/config") async def config() -> dict: return {"name": "SMOS", "version": VERSION, "languages": LANGUAGES} @app.get("/api/health") async def health() -> dict: return {"status": "ok", "version": VERSION} @app.post("/api/translate") async def translate_text(payload: TranslationRequest) -> dict: if payload.target not in LANGUAGE_CODES: raise HTTPException(status_code=400, detail="Unsupported target language.") detected_source = infer_source_language(payload.text, payload.source) translated = await translate(payload.text, detected_source, payload.target) return {"translated_text": translated, "source": detected_source, "target": payload.target} @app.post("/api/orders", status_code=201) async def create_order(payload: OrderCreate) -> dict: if payload.language != "auto" and payload.language not in LANGUAGE_CODES: raise HTTPException(status_code=400, detail="Unsupported order language.") detected_language = infer_source_language(payload.order_text, payload.language) kitchen_text = await translate(payload.order_text, detected_language, "en") order_id = str(uuid.uuid4()) now = utc_now() with connect() as db: number = db.execute("SELECT COALESCE(MAX(order_number), 100) + 1 FROM orders").fetchone()[0] db.execute( """ INSERT INTO orders ( id, order_number, customer_name, table_number, original_text, original_language, kitchen_text, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 'new', ?, ?) """, ( order_id, number, payload.customer_name.strip(), payload.table_number.strip(), payload.order_text.strip(), detected_language, kitchen_text, now, now, ), ) row = db.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone() order = row_to_dict(row) await manager.broadcast({"type": "order.created", "order": order}) return order @app.get("/api/orders") async def list_orders( language: str = Query(default="en"), include_closed: bool = Query(default=True), ) -> list[dict]: if language not in LANGUAGE_CODES: raise HTTPException(status_code=400, detail="Unsupported display language.") sql = "SELECT * FROM orders" params: tuple = () if not include_closed: sql += " WHERE status NOT IN (?, ?)" params = ("served", "cancelled") sql += " ORDER BY created_at DESC LIMIT 100" with connect() as db: rows = db.execute(sql, params).fetchall() orders = [row_to_dict(row) for row in rows] for order in orders: if language == "en": order["display_text"] = order["kitchen_text"] continue with connect() as db: cached = db.execute( "SELECT text FROM translations WHERE order_id = ? AND language = ?", (order["id"], language), ).fetchone() if cached: order["display_text"] = cached["text"] else: translated = await translate(order["kitchen_text"], "en", language) order["display_text"] = translated with connect() as db: db.execute( "INSERT OR REPLACE INTO translations (order_id, language, text) VALUES (?, ?, ?)", (order["id"], language, translated), ) return orders @app.patch("/api/orders/{order_id}") async def update_order(order_id: str, payload: OrderStatusUpdate) -> dict: if payload.status not in STATUSES: raise HTTPException(status_code=400, detail="Invalid status.") now = utc_now() with connect() as db: cursor = db.execute( "UPDATE orders SET status = ?, updated_at = ? WHERE id = ?", (payload.status, now, order_id), ) if cursor.rowcount == 0: raise HTTPException(status_code=404, detail="Order not found.") row = db.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone() order = row_to_dict(row) await manager.broadcast({"type": "order.updated", "order": order}) return order def station_defaults_from_items(items: Optional[list]) -> dict[str, object]: """Mark empty kitchen / bar stations as already done / served.""" cafe = False indian = False drinks = False for item in items or []: if not isinstance(item, dict): continue section = str(item.get("section") or "").strip().lower() if section == "drinks": drinks = True elif section == "indian": indian = True else: # Breakfast / Lunch / Tea / Kebab / Pizza / unknown food → cafe kitchen cafe = True return { "kitchen_status": "not_started" if cafe else "done", "indian_kitchen_status": "not_started" if indian else "done", "barista_status": "not_started" if drinks else "done", "food_served": 0 if cafe else 1, "indian_food_served": 0 if indian else 1, "drinks_served": 0 if drinks else 1, } @app.post("/api/bookings", status_code=201) async def create_booking(payload: BookingCreate) -> dict: if payload.kind not in BOOKING_KINDS: raise HTTPException(status_code=400, detail="Invalid booking kind.") module_id = payload.module_id.strip() get_module(module_id) service = payload.service.strip().lower() if service not in BOOKING_SERVICES: raise HTTPException(status_code=400, detail="Invalid service type.") booking_id = str(uuid.uuid4()) now = utc_now() items_json = json.dumps(payload.items) if payload.items is not None else None tables_json = json.dumps(payload.tables) if payload.tables is not None else None payload_dict = payload.payload or {} payload_json = json.dumps(payload_dict) payment_status = infer_payment_status( payload.kind, payload.payment, payload_dict, payload.payment_status ) # Reservations never charge at booking time. if payload.kind == "reservation" or service == "reservation": payment_status = "n/a" # Delivery and collection are pay-first only. if payload.kind == "order" and service in {"delivery", "collection"}: if payment_status != "paid": raise HTTPException( status_code=400, detail="Delivery and collection orders must be paid and confirmed before placing.", ) initial_status = "confirmed" else: initial_status = "new" # Sit-in may be unpaid until they leave; never require prepayment. if service == "sitting-in" and payload.kind == "order" and payment_status == "n/a": payment_status = "unpaid" stations = station_defaults_from_items(payload.items if payload.kind == "order" else []) with connect() as db: if payload.kind == "order" and service == "sitting-in": assert_tables_available(db, module_id, payload.tables) elif payload.kind == "reservation": assert_tables_available( db, module_id, payload.tables, arrival_date=payload.arrival_date, ) db.execute( """ INSERT INTO bookings ( id, module_id, ref, kind, service, status, customer_name, phone, email, confirm_via, party_size, arrival_date, arrival_window, address, notes, total, payment, payment_status, kitchen_status, indian_kitchen_status, barista_status, food_served, indian_food_served, drinks_served, items_json, tables_json, payload_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( booking_id, module_id, payload.ref.strip(), payload.kind, service, initial_status, payload.customer_name.strip(), (payload.phone or "").strip() or None, (payload.email or "").strip() or None, payload.confirm_via, payload.party_size, payload.arrival_date, payload.arrival_window, (payload.address or "").strip() or None, (payload.notes or "").strip() or None, payload.total, payload.payment, payment_status, stations["kitchen_status"], stations["indian_kitchen_status"], stations["barista_status"], stations["food_served"], stations["indian_food_served"], stations["drinks_served"], items_json, tables_json, payload_json, now, now, ), ) row = db.execute("SELECT * FROM bookings WHERE id = ?", (booking_id,)).fetchone() booking = booking_row_to_dict(row) await manager.broadcast({"type": "booking.created", "booking": booking}) return booking @app.get("/api/modules/{module_id}/admin/bookings") async def module_admin_list_bookings( module_id: str, _: str = Depends(verify_module_admin), kind: Optional[str] = Query(default=None), service: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), limit: int = Query(default=200, ge=1, le=500), ) -> list[dict]: sql = "SELECT * FROM bookings WHERE module_id = ?" params: list = [module_id] if kind: sql += " AND kind = ?" params.append(kind) if service: sql += " AND service = ?" params.append(service.lower()) if status: sql += " AND status = ?" params.append(status) sql += " ORDER BY created_at DESC LIMIT ?" params.append(limit) with connect() as db: rows = db.execute(sql, params).fetchall() return [booking_row_to_dict(row) for row in rows] @app.patch("/api/modules/{module_id}/admin/bookings/{booking_id}") async def module_admin_update_booking( module_id: str, booking_id: str, payload: BookingStatusUpdate, _: str = Depends(verify_module_admin), ) -> dict: if ( payload.status is None and payload.payment_status is None and payload.kitchen_status is None and payload.indian_kitchen_status is None and payload.barista_status is None and payload.food_served is None and payload.indian_food_served is None and payload.drinks_served is None ): raise HTTPException(status_code=400, detail="Nothing to update.") if payload.status is not None and payload.status not in BOOKING_STATUSES: raise HTTPException(status_code=400, detail="Invalid status.") if ( payload.payment_status is not None and payload.payment_status not in BOOKING_PAYMENT_STATUSES ): raise HTTPException(status_code=400, detail="Invalid payment status.") if ( payload.kitchen_status is not None and payload.kitchen_status not in BOOKING_KITCHEN_STATUSES ): raise HTTPException(status_code=400, detail="Invalid kitchen status.") if ( payload.indian_kitchen_status is not None and payload.indian_kitchen_status not in BOOKING_KITCHEN_STATUSES ): raise HTTPException(status_code=400, detail="Invalid Indian kitchen status.") if ( payload.barista_status is not None and payload.barista_status not in BOOKING_BARISTA_STATUSES ): raise HTTPException(status_code=400, detail="Invalid barista status.") now = utc_now() with connect() as db: row = db.execute( "SELECT * FROM bookings WHERE id = ? AND module_id = ?", (booking_id, module_id), ).fetchone() if not row: raise HTTPException(status_code=404, detail="Booking not found.") next_status = payload.status if payload.status is not None else row["status"] next_pay = ( payload.payment_status if payload.payment_status is not None else row["payment_status"] ) next_kitchen = ( payload.kitchen_status if payload.kitchen_status is not None else (row["kitchen_status"] or "not_started") ) next_indian_kitchen = ( payload.indian_kitchen_status if payload.indian_kitchen_status is not None else (row["indian_kitchen_status"] or "done") ) next_barista = ( payload.barista_status if payload.barista_status is not None else (row["barista_status"] or "not_started") ) next_food_served = ( 1 if payload.food_served is True else 0 if payload.food_served is False else int(row["food_served"] or 0) ) next_indian_food_served = ( 1 if payload.indian_food_served is True else 0 if payload.indian_food_served is False else int(row["indian_food_served"] if row["indian_food_served"] is not None else 1) ) next_drinks_served = ( 1 if payload.drinks_served is True else 0 if payload.drinks_served is False else int(row["drinks_served"] or 0) ) # Keep order status loosely in sync with kitchen / barista progress. if ( payload.kitchen_status == "in_progress" or payload.indian_kitchen_status == "in_progress" or payload.barista_status == "in_progress" ) and next_status in {"new", "confirmed"}: next_status = "preparing" elif ( payload.kitchen_status == "done" or payload.indian_kitchen_status == "done" or payload.barista_status == "done" ) and next_status not in {"completed", "cancelled"}: next_status = "ready" if ( next_food_served and next_indian_food_served and next_drinks_served and next_status not in {"cancelled"} ): next_status = "completed" db.execute( """ UPDATE bookings SET status = ?, payment_status = ?, kitchen_status = ?, indian_kitchen_status = ?, barista_status = ?, food_served = ?, indian_food_served = ?, drinks_served = ?, updated_at = ? WHERE id = ? AND module_id = ? """, ( next_status, next_pay, next_kitchen, next_indian_kitchen, next_barista, next_food_served, next_indian_food_served, next_drinks_served, now, booking_id, module_id, ), ) row = db.execute("SELECT * FROM bookings WHERE id = ?", (booking_id,)).fetchone() booking = booking_row_to_dict(row) await manager.broadcast({"type": "booking.updated", "booking": booking}) return booking @app.post("/api/modules/{module_id}/admin/login") async def module_admin_login(module_id: str, payload: AdminLogin) -> dict: module = get_module(module_id) if payload.username != module["admin_user"] or payload.password != module["admin_pass"]: raise HTTPException(status_code=401, detail="Invalid credentials.") with connect() as db: token = create_admin_session(db, module_id) return { "token": token, "expires_in": ADMIN_SESSION_HOURS * 3600, "username": payload.username, "module_id": module_id, "module_name": module["name"], } def booking_table_ids(row: sqlite3.Row | dict) -> list[str]: ids: list[str] = [] raw_tables = None raw_payload = None if isinstance(row, sqlite3.Row): raw_tables = row["tables_json"] if "tables_json" in row.keys() else None raw_payload = row["payload_json"] if "payload_json" in row.keys() else None else: raw_tables = row.get("tables_json") or row.get("tables") raw_payload = row.get("payload_json") or row.get("payload") tables = raw_tables if isinstance(tables, str): try: tables = json.loads(tables) except json.JSONDecodeError: tables = None if isinstance(tables, list): for t in tables: if isinstance(t, dict): tid = t.get("id") or t.get("label") if tid: ids.append(str(tid)) elif t: ids.append(str(t)) if not ids: payload = raw_payload if isinstance(payload, str): try: payload = json.loads(payload) except json.JSONDecodeError: payload = {} if isinstance(payload, dict): tid = payload.get("tableId") or payload.get("table_id") if tid: ids.append(str(tid)) return ids def occupied_table_ids( db: sqlite3.Connection, module_id: str, *, arrival_date: Optional[str] = None, ) -> set[str]: """Tables held by open sit-in orders, plus open reservations for a date if given.""" occupied: set[str] = set() sit_rows = db.execute( """ SELECT tables_json, payload_json FROM bookings WHERE module_id = ? AND kind = 'order' AND service = 'sitting-in' AND status NOT IN ('completed', 'cancelled') """, (module_id,), ).fetchall() for row in sit_rows: occupied.update(booking_table_ids(row)) if arrival_date: res_rows = db.execute( """ SELECT tables_json, payload_json FROM bookings WHERE module_id = ? AND kind = 'reservation' AND status NOT IN ('completed', 'cancelled') AND arrival_date = ? """, (module_id, arrival_date), ).fetchall() for row in res_rows: occupied.update(booking_table_ids(row)) return occupied def assert_tables_available( db: sqlite3.Connection, module_id: str, tables: Optional[list], *, arrival_date: Optional[str] = None, ) -> None: wanted = set() for t in tables or []: if isinstance(t, dict): tid = t.get("id") or t.get("label") if tid: wanted.add(str(tid)) elif t: wanted.add(str(t)) if not wanted: return held = occupied_table_ids(db, module_id, arrival_date=arrival_date) clash = sorted(wanted & held) if clash: labels = ", ".join(clash) raise HTTPException( status_code=409, detail=f"Table{'s' if len(clash) > 1 else ''} {labels} already occupied. Choose another or wait until released.", ) @app.get("/api/modules/{module_id}/tables/occupied") async def module_occupied_tables( module_id: str, date: Optional[str] = Query(default=None), ) -> dict: get_module(module_id) with connect() as db: occupied = occupied_table_ids(db, module_id, arrival_date=date) return {"occupied": sorted(occupied), "date": date} @app.post("/api/modules/{module_id}/admin/logout") async def module_admin_logout( module_id: str, token: str = Depends(verify_module_admin), ) -> dict: with connect() as db: db.execute( "DELETE FROM admin_sessions WHERE token = ? AND module_id = ?", (token, module_id), ) return {"ok": True} @app.get("/api/modules/{module_id}/admin/me") async def module_admin_me( module_id: str, _: str = Depends(verify_module_admin), ) -> dict: module = get_module(module_id) return {"ok": True, "module_id": module_id, "module_name": module["name"]} @app.get("/api/image") async def image_url( description: str = Query(min_length=2, max_length=500), language: str = Query(default="en"), ) -> dict: english_description = await translate(description, language, "en") prompt = ( "professional appetizing restaurant food photography, plated dish, " f"{english_description}, warm natural light, realistic, no text, no logo" ) url = ( f"https://image.pollinations.ai/prompt/{quote(prompt, safe='')}" "?width=768&height=512&nologo=true&enhance=true" ) return {"url": url, "prompt": english_description} @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket) -> None: await manager.connect(websocket) try: while True: message = await websocket.receive_text() if message == "ping": await websocket.send_text(json.dumps({"type": "pong"})) except WebSocketDisconnect: manager.disconnect(websocket)