M3: chat widget, owner dashboard, super-admin panel

- Embeddable vanilla-JS chat widget (one script tag + tenant public key):
  WebSocket, resumable sessions (24h localStorage token), honeypot + per-IP
  and per-message rate limits, Bosnian UI (§7)
- Session auth (bcrypt + signed cookies), owner accounts + super-admins,
  admin impersonation ('otvori kao salon', §11)
- Owner dashboard (§10, Bosnian): requests with same actions as email,
  call/chat history with transcripts, usage bar; settings for profile,
  working hours (manual + LLM paste-to-parse), services CRUD + 'Zalijepi
  cjenovnik' LLM import with review step, scheduling (Google OAuth free/busy
  read-only + calendar mappings + buffers, partner status), telephony
  (forwarding MMI codes per operator, ring group, worker SIP creds + QR),
  agent voice/price-mode/notes + widget snippet, notifications/SMS templates
- Super-admin panel (§11): tenant CRUD incl. plan/minutes/paid-until,
  partner API config (encrypted secrets), number/SIM registry + assignment,
  connectivity test (live availability + dry-run push), versioned prompt
  template editor with publish/rollback, playground, usage overview, health
- Fixes: WAL + busy_timeout for sqlite tests, no awaits in WS disconnect
  path (deadlock), template publish autoflush bug
- 76 tests green (incl. widget WS e2e booking flow); dashboard, widget and
  admin verified live in a browser against Postgres

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 10:51:57 +02:00
parent 0d0838e6e2
commit 646917c322
46 changed files with 4402 additions and 8 deletions

80
gogo/web.py Normal file
View File

@@ -0,0 +1,80 @@
"""Shared web helpers: Jinja templates, redirects, Bosnian labels."""
from __future__ import annotations
from pathlib import Path
from urllib.parse import quote
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
STATUS_LABELS = {
"pending": "na čekanju",
"resolved_by_owner": "riješeno",
"confirmed": "potvrđeno",
"rejected": "odbijeno",
"expired": "isteklo",
}
OUTCOME_LABELS = {
"human_answered": "javilo se osoblje",
"request_created": "zahtjev kreiran",
"info_only": "informacija",
"message_taken": "poruka",
"abandoned": "prekinut",
}
DAY_LABELS = [
("mon", "Ponedjeljak"),
("tue", "Utorak"),
("wed", "Srijeda"),
("thu", "Četvrtak"),
("fri", "Petak"),
("sat", "Subota"),
("sun", "Nedjelja"),
]
# Curated TTS voices (§10.5) — final list confirmed by the Phase-0 PoC bake-off.
TTS_VOICES = [
{"id": "azure:sr-RS-SophieNeural", "label": "Sofija — ženski, topao (Azure)"},
{"id": "azure:sr-RS-NicholasNeural", "label": "Nikola — muški, smiren (Azure)"},
{"id": "azure:hr-HR-GabrijelaNeural", "label": "Gabrijela — ženski, vedar (Azure)"},
]
def redirect(url: str, msg: str = "", err: str = "") -> RedirectResponse:
if msg:
url += ("&" if "?" in url else "?") + "msg=" + quote(msg)
if err:
url += ("&" if "?" in url else "?") + "err=" + quote(err)
return RedirectResponse(url, status_code=303)
def hours_to_text(working_hours: dict) -> dict[str, str]:
"""{'mon': [['09:00','13:00'],['14:00','18:00']]} → {'mon': '09:00-13:00, 14:00-18:00'}"""
out = {}
for key, _label in DAY_LABELS:
intervals = (working_hours or {}).get(key, [])
out[key] = ", ".join(f"{a}-{b}" for a, b in intervals)
return out
def text_to_intervals(text: str) -> list[list[str]]:
"""'09:00-13:00, 14:00-18:00' → [['09:00','13:00'],['14:00','18:00']]; '' → []"""
import re
intervals = []
for part in text.split(","):
part = part.strip()
if not part:
continue
m = re.match(r"^(\d{1,2})(?::(\d{2}))?\s*[-]\s*(\d{1,2})(?::(\d{2}))?$", part)
if not m:
raise ValueError(f"Neispravan format: '{part}' (očekivano npr. 09:00-18:00)")
h1, m1, h2, m2 = m.groups()
a = f"{int(h1):02d}:{m1 or '00'}"
b = f"{int(h2):02d}:{m2 or '00'}"
intervals.append([a, b])
return intervals