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:
80
gogo/web.py
Normal file
80
gogo/web.py
Normal 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
|
||||
Reference in New Issue
Block a user