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