Download app.py from 0001AMA/SMOS: direct link, hf CLI and curl.
- Browser
- Download file 63.9 kB
-
https://huggingface.co/spaces/0001AMA/SMOS/resolve/main/app.py
- Command line
-
hf download hf://spaces/0001AMA/SMOS/app.py
-
curl -L -o app.py https://huggingface.co/spaces/0001AMA/SMOS/resolve/main/app.py
63.9 kB
| 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 hah_restaurant_payments_from_env() -> dict: | |
| mollie_key = os.getenv("MOLLIE_API_KEY", "").strip() | |
| return { | |
| "businessName": os.getenv("HAH_RESTAURANT_BUSINESS_NAME", "HAH restaurant").strip() | |
| or "HAH restaurant", | |
| "payeeName": os.getenv("HAH_RESTAURANT_PAYEE_NAME", "").strip() | |
| or os.getenv("HAH_RESTAURANT_BUSINESS_NAME", "HAH restaurant").strip() | |
| or "HAH restaurant", | |
| "sortCode": os.getenv("HAH_RESTAURANT_SORT_CODE", "").strip(), | |
| "accountNumber": os.getenv("HAH_RESTAURANT_ACCOUNT_NUMBER", "").strip(), | |
| "bankName": os.getenv("HAH_RESTAURANT_BANK_NAME", "").strip(), | |
| "referencePrefix": os.getenv("HAH_RESTAURANT_REFERENCE_PREFIX", "HAH").strip() or "HAH", | |
| "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": "HAH restaurant", | |
| "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", "hah-restaurant"), | |
| ), | |
| }, | |
| } | |
| 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"(?<!\w){re.escape(phrase)}(?!\w)", source_lower, flags=re.UNICODE | |
| ) | |
| translation_has_term = any( | |
| re.search( | |
| rf"(?<!\w){re.escape(term.casefold())}(?!\w)", | |
| translated_lower, | |
| flags=re.UNICODE, | |
| ) | |
| for term in accepted | |
| ) | |
| if source_has_phrase and not translation_has_term: | |
| missing.append(canonical) | |
| if not missing: | |
| return translated_text | |
| label = "Order note" if target == "en" else "Sipariş notu" | |
| return f"{translated_text} — {label}: {', '.join(missing)}" | |
| def correct_turkish_restaurant_context( | |
| source_text: str, translated_text: str, source: str, target: str | |
| ) -> 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) | |
| 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=["*"], | |
| ) | |
| async def smos_cc_payments() -> dict: | |
| return hah_restaurant_payments_from_env() | |
| 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"HAH restaurant {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": hah_restaurant_payments_from_env().get("mollieMode"), | |
| } | |
| 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()) | |
| 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") | |
| 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) | |
| async def smos_cc_payments_config_js(): | |
| base = (ROOT / "modules" / "SmOS_CC" / "payments-config.js").read_text(encoding="utf-8") | |
| overlay = hah_restaurant_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") | |
| async def home() -> FileResponse: | |
| platform = ROOT / "index.html" | |
| if platform.exists(): | |
| return FileResponse(platform) | |
| return FileResponse(ROOT / "static" / "index.html") | |
| async def platform_styles() -> FileResponse: | |
| return FileResponse(ROOT / "styles.css") | |
| async def platform_site_js() -> FileResponse: | |
| return FileResponse(ROOT / "site.js") | |
| async def legacy_guest() -> FileResponse: | |
| return FileResponse(ROOT / "static" / "index.html") | |
| async def config() -> dict: | |
| return {"name": "SMOS", "version": VERSION, "languages": LANGUAGES} | |
| async def health() -> dict: | |
| return {"status": "ok", "version": VERSION} | |
| 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} | |
| 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 | |
| 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 | |
| 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, | |
| } | |
| 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 | |
| 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] | |
| 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 | |
| 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.", | |
| ) | |
| 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} | |
| 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} | |
| 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"]} | |
| 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} | |
| 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) | |