- 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>
81 lines
2.5 KiB
Python
81 lines
2.5 KiB
Python
"""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
|