| 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" |
| ) |
| 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")) |
|
|
| |
| 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": |
| |
| 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(): |
| |
| 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}}" |
| ) |
| |
| 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: |
| |
| 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 |
| ) |
| |
| if payload.kind == "reservation" or service == "reservation": |
| payment_status = "n/a" |
|
|
| |
| 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" |
|
|
| |
| 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) |
| ) |
| |
| 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) |
|
|