Files
gogo-telefon/gogo/dashboard/router.py
Senad Uka 646917c322 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>
2026-07-11 10:51:57 +02:00

1106 lines
35 KiB
Python

"""Owner dashboard (§10) — Bosnian UI, server-rendered.
Settings: profile/hours (with paste-to-parse), services CRUD + 'Zalijepi
cjenovnik', scheduling per provider, telephony (forwarding codes, ring group,
worker QR), agent (voice/price mode/notes/widget snippet), notifications.
Review: booking requests (same actions as email), call/chat history, usage.
"""
from __future__ import annotations
import json
import uuid
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import FileResponse, HTMLResponse, Response
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.agent.prompt import compose_greeting
from gogo.auth import (
OwnerContext,
clear_session,
current_owner,
read_session,
set_session,
verify_password,
)
from gogo.config import get_settings
from gogo.db import get_session
from gogo.domain import BookingStatus, Slot
from gogo.i18n import fmt_slot
from gogo.metering import usage_summary
from gogo.models import (
BookingRequest,
CalendarConnection,
CalendarMapping,
Call,
ChatMessage,
ChatSession,
PhoneNumber,
Service,
Tenant,
User,
Worker,
)
from gogo.proposals import engine as proposal_engine
from gogo.sms.templates import DEFAULT_TEMPLATES
from gogo.telephony import sip
from gogo.telephony.forwarding import OPERATORS, forwarding_codes
from gogo.web import (
DAY_LABELS,
OUTCOME_LABELS,
STATUS_LABELS,
TTS_VOICES,
hours_to_text,
redirect,
templates,
text_to_intervals,
)
router = APIRouter()
def ctx(request: Request, owner: OwnerContext, **extra) -> dict:
return {
"request": request,
"owner": owner,
"t": owner.tenant,
"status_labels": STATUS_LABELS,
"outcome_labels": OUTCOME_LABELS,
**extra,
}
# -- auth ---------------------------------------------------------------------
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse(request, "dashboard/login.html", {"request": request})
@router.post("/login")
async def login(
request: Request,
email: str = Form(...),
password: str = Form(...),
session: AsyncSession = Depends(get_session),
):
user = (
await session.execute(select(User).where(User.email == email.strip().lower()))
).scalar_one_or_none()
if user is None or not user.active or not verify_password(password, user.password_hash):
return redirect("/login", err="Pogrešan email ili lozinka.")
resp = redirect("/dash")
set_session(resp, {"kind": "owner", "user_id": str(user.id)})
return resp
@router.get("/logout")
async def logout(request: Request):
data = read_session(request) or {}
resp = redirect("/admin" if data.get("kind") == "admin" else "/login")
if data.get("kind") == "admin":
data.pop("impersonate_tenant", None)
set_session(resp, data)
else:
clear_session(resp)
return resp
# -- review: requests ---------------------------------------------------------
async def _request_row(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> dict:
service_name = req.service_name_raw or ""
if req.service_id:
svc = (
await session.execute(select(Service).where(Service.id == req.service_id))
).scalar_one_or_none()
if svc:
service_name = svc.name
slots = [
fmt_slot(Slot.model_validate(s).start, tenant.timezone) for s in (req.slots or [])
]
return {"req": req, "service_name": service_name, "slots": slots}
@router.get("/dash", response_class=HTMLResponse)
async def requests_list(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
reqs = (
(
await session.execute(
select(BookingRequest)
.where(BookingRequest.tenant_id == owner.tenant.id)
.order_by(desc(BookingRequest.created_at))
.limit(100)
)
)
.scalars()
.all()
)
rows = [await _request_row(session, owner.tenant, r) for r in reqs]
usage = await usage_summary(session, owner.tenant)
await session.commit()
return templates.TemplateResponse(
request,
"dashboard/requests.html",
ctx(request, owner, rows=rows, usage=usage, active="requests"),
)
async def _load_request(
session: AsyncSession, owner: OwnerContext, request_id: str
) -> BookingRequest | None:
try:
rid = uuid.UUID(request_id)
except ValueError:
return None
return (
await session.execute(
select(BookingRequest).where(
BookingRequest.id == rid, BookingRequest.tenant_id == owner.tenant.id
)
)
).scalar_one_or_none()
@router.get("/dash/requests/{request_id}", response_class=HTMLResponse)
async def request_detail(
request_id: str,
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
req = await _load_request(session, owner, request_id)
if req is None:
return redirect("/dash", err="Zahtjev nije pronađen.")
row = await _request_row(session, owner.tenant, req)
from gogo.sms.templates import render_sms
transcript = await _transcript_for_request(session, req)
confirmed_label = ""
if req.confirmed_slot:
confirmed_label = fmt_slot(
Slot.model_validate(req.confirmed_slot).start, owner.tenant.timezone
)
return templates.TemplateResponse(
request,
"dashboard/request_detail.html",
ctx(
request,
owner,
req=req,
service_name=row["service_name"],
slots=row["slots"],
rejection_sms=render_sms(owner.tenant, "rejection"),
transcript=transcript,
confirmed_label=confirmed_label,
active="requests",
),
)
async def _transcript_for_request(session: AsyncSession, req: BookingRequest) -> list[dict]:
if req.call_id:
call = (
await session.execute(select(Call).where(Call.id == req.call_id))
).scalar_one_or_none()
if call and call.transcript:
return call.transcript
if req.chat_session_id:
msgs = (
(
await session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == req.chat_session_id)
.order_by(ChatMessage.at)
)
)
.scalars()
.all()
)
return [{"role": m.role, "text": m.content} for m in msgs]
return []
@router.post("/dash/requests/{request_id}/resolve")
async def request_resolve(
request_id: str,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
req = await _load_request(session, owner, request_id)
if req is None or req.status != BookingStatus.pending.value:
return redirect("/dash", err="Zahtjev je već obrađen.")
await proposal_engine.resolve_request(session, owner.tenant, req)
await session.commit()
return redirect("/dash", msg="Zahtjev označen kao riješen — klijentu nije poslan SMS.")
@router.post("/dash/requests/{request_id}/confirm/{slot_index}")
async def request_confirm(
request_id: str,
slot_index: int,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
req = await _load_request(session, owner, request_id)
if req is None or req.status != BookingStatus.pending.value:
return redirect("/dash", err="Zahtjev je već obrađen.")
slots = [Slot.model_validate(s) for s in (req.slots or [])]
if slot_index >= len(slots):
return redirect(f"/dash/requests/{request_id}", err="Termin ne postoji.")
ok = await proposal_engine.confirm_request(session, owner.tenant, req, slots[slot_index])
await session.commit()
if not ok:
return redirect(
f"/dash/requests/{request_id}",
err="Termin je u međuvremenu zauzet u kalendaru — provjerite pa pokušajte ponovo.",
)
return redirect(f"/dash/requests/{request_id}", msg="Termin potvrđen, SMS poslan klijentu.")
@router.post("/dash/requests/{request_id}/reject")
async def request_reject(
request_id: str,
sms_body: str = Form(""),
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
req = await _load_request(session, owner, request_id)
if req is None or req.status != BookingStatus.pending.value:
return redirect("/dash", err="Zahtjev je već obrađen.")
await proposal_engine.reject_request(
session, owner.tenant, req, custom_sms=sms_body.strip() or None
)
await session.commit()
return redirect("/dash", msg="Zahtjev odbijen, klijent je obaviješten SMS-om.")
# transcript short-link used in proposal emails: /t/{request_id}
@router.get("/t/{request_id}")
async def transcript_link(
request_id: str,
owner: OwnerContext = Depends(current_owner),
):
return redirect(f"/dash/requests/{request_id}")
# -- review: calls / chats / usage ---------------------------------------------
@router.get("/dash/calls", response_class=HTMLResponse)
async def calls_list(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
calls = (
(
await session.execute(
select(Call)
.where(Call.tenant_id == owner.tenant.id)
.order_by(desc(Call.started_at))
.limit(200)
)
)
.scalars()
.all()
)
return templates.TemplateResponse(
request, "dashboard/calls.html", ctx(request, owner, calls=calls, active="calls")
)
@router.get("/dash/calls/{call_id}", response_class=HTMLResponse)
async def call_detail(
call_id: str,
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
call = (
await session.execute(
select(Call).where(
Call.id == uuid.UUID(call_id), Call.tenant_id == owner.tenant.id
)
)
).scalar_one_or_none()
if call is None:
return redirect("/dash/calls", err="Poziv nije pronađen.")
return templates.TemplateResponse(
request, "dashboard/call_detail.html", ctx(request, owner, call=call, active="calls")
)
@router.get("/dash/calls/{call_id}/audio")
async def call_audio(
call_id: str,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
call = (
await session.execute(
select(Call).where(
Call.id == uuid.UUID(call_id), Call.tenant_id == owner.tenant.id
)
)
).scalar_one_or_none()
if call is None or not call.recording_path:
return Response(status_code=404)
return FileResponse(call.recording_path, media_type="audio/wav")
@router.get("/dash/chats", response_class=HTMLResponse)
async def chats_list(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
chats = (
(
await session.execute(
select(ChatSession)
.where(ChatSession.tenant_id == owner.tenant.id)
.order_by(desc(ChatSession.started_at))
.limit(200)
)
)
.scalars()
.all()
)
rows = []
for chat in chats:
first_user = (
await session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == chat.id, ChatMessage.role == "user")
.order_by(ChatMessage.at)
.limit(1)
)
).scalar_one_or_none()
preview = (first_user.content[:80] + "") if first_user and len(first_user.content) > 80 \
else (first_user.content if first_user else "(bez poruka)")
rows.append({"chat": chat, "preview": preview})
return templates.TemplateResponse(
request, "dashboard/chats.html", ctx(request, owner, sessions=rows, active="chats")
)
@router.get("/dash/chats/{chat_id}", response_class=HTMLResponse)
async def chat_detail(
chat_id: str,
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
chat = (
await session.execute(
select(ChatSession).where(
ChatSession.id == uuid.UUID(chat_id),
ChatSession.tenant_id == owner.tenant.id,
)
)
).scalar_one_or_none()
if chat is None:
return redirect("/dash/chats", err="Razgovor nije pronađen.")
messages = (
(
await session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == chat.id)
.order_by(ChatMessage.at)
)
)
.scalars()
.all()
)
return templates.TemplateResponse(
request,
"dashboard/chat_detail.html",
ctx(request, owner, chat=chat, messages=messages, active="chats"),
)
@router.get("/dash/usage", response_class=HTMLResponse)
async def usage_page(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
usage = await usage_summary(session, owner.tenant)
await session.commit()
return templates.TemplateResponse(
request, "dashboard/usage.html", ctx(request, owner, usage=usage, active="usage")
)
# -- settings: profile + hours --------------------------------------------------
@router.get("/dash/settings", response_class=HTMLResponse)
async def settings_page(
request: Request,
owner: OwnerContext = Depends(current_owner),
):
return templates.TemplateResponse(
request,
"dashboard/settings.html",
ctx(
request,
owner,
hours_text=hours_to_text(owner.tenant.working_hours),
day_labels=DAY_LABELS,
active="settings",
),
)
@router.post("/dash/settings")
async def settings_save(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
form = await request.form()
t = await session.merge(owner.tenant)
t.name = str(form.get("name", t.name)).strip() or t.name
t.address = str(form.get("address", "")).strip()
t.city = str(form.get("city", "")).strip()
t.phone = str(form.get("phone", "")).strip()
t.website = str(form.get("website", "")).strip()
hours = {}
try:
for key, _label in DAY_LABELS:
hours[key] = text_to_intervals(str(form.get(f"wh_{key}", "")))
except ValueError as e:
await session.commit()
return redirect("/dash/settings", err=str(e))
t.working_hours = hours
await session.commit()
return redirect("/dash/settings", msg="Sačuvano.")
@router.post("/dash/settings/parse-hours")
async def settings_parse_hours(
text: str = Form(...),
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
from gogo.dashboard.parse import parse_working_hours
hours = await parse_working_hours(text)
if hours is None:
return redirect(
"/dash/settings", err="Nisam uspio pročitati radno vrijeme — unesite ga ručno."
)
t = await session.merge(owner.tenant)
t.working_hours = hours
await session.commit()
return redirect("/dash/settings", msg="Radno vrijeme pročitano — provjerite i po potrebi ispravite.")
# -- settings: services ----------------------------------------------------------
@router.get("/dash/services", response_class=HTMLResponse)
async def services_page(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
services = (
(
await session.execute(
select(Service)
.where(Service.tenant_id == owner.tenant.id, Service.active.is_(True))
.order_by(Service.name)
)
)
.scalars()
.all()
)
return templates.TemplateResponse(
request,
"dashboard/services.html",
ctx(request, owner, services=services, parsed=None, active="services"),
)
def _num_or_none(v) -> float | None:
try:
s = str(v).strip()
return float(s) if s else None
except ValueError:
return None
@router.post("/dash/services")
async def service_add(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
form = await request.form()
session.add(
Service(
tenant_id=owner.tenant.id,
name=str(form.get("name", "")).strip(),
duration_min=int(form.get("duration_min") or 30),
price_min=_num_or_none(form.get("price_min")),
price_max=_num_or_none(form.get("price_max")),
home_visit=bool(form.get("home_visit")),
agent_note=str(form.get("agent_note", "")).strip(),
)
)
await session.commit()
return redirect("/dash/services", msg="Usluga dodana.")
@router.post("/dash/services/{service_id:uuid}")
async def service_update(
service_id: uuid.UUID,
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
form = await request.form()
svc = (
await session.execute(
select(Service).where(
Service.id == service_id, Service.tenant_id == owner.tenant.id
)
)
).scalar_one_or_none()
if svc is None:
return redirect("/dash/services", err="Usluga nije pronađena.")
svc.name = str(form.get("name", svc.name)).strip() or svc.name
svc.duration_min = int(form.get("duration_min") or svc.duration_min)
svc.price_min = _num_or_none(form.get("price_min"))
svc.price_max = _num_or_none(form.get("price_max"))
svc.home_visit = bool(form.get("home_visit"))
svc.agent_note = str(form.get("agent_note", "")).strip()
await session.commit()
return redirect("/dash/services", msg="Sačuvano.")
@router.post("/dash/services/{service_id}/delete")
async def service_delete(
service_id: str,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
svc = (
await session.execute(
select(Service).where(
Service.id == uuid.UUID(service_id), Service.tenant_id == owner.tenant.id
)
)
).scalar_one_or_none()
if svc:
svc.active = False # soft delete: history references remain valid
await session.commit()
return redirect("/dash/services", msg="Usluga uklonjena.")
@router.post("/dash/services/parse", response_class=HTMLResponse)
async def services_parse(
request: Request,
text: str = Form(...),
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
from gogo.dashboard.parse import parse_price_list
parsed = await parse_price_list(text)
if not parsed:
return redirect(
"/dash/services", err="Nisam uspio pročitati cjenovnik — dodajte usluge ručno."
)
services = (
(
await session.execute(
select(Service)
.where(Service.tenant_id == owner.tenant.id, Service.active.is_(True))
.order_by(Service.name)
)
)
.scalars()
.all()
)
return templates.TemplateResponse(
request,
"dashboard/services.html",
ctx(request, owner, services=services, parsed=parsed, active="services"),
)
@router.post("/dash/services/import")
async def services_import(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
form = await request.form()
count = int(form.get("count") or 0)
added = 0
for i in range(count):
if not form.get(f"row_{i}"):
continue
name = str(form.get(f"name_{i}", "")).strip()
if not name:
continue
session.add(
Service(
tenant_id=owner.tenant.id,
name=name,
duration_min=int(form.get(f"duration_{i}") or 30),
price_min=_num_or_none(form.get(f"pmin_{i}")),
price_max=_num_or_none(form.get(f"pmax_{i}")),
home_visit=bool(form.get(f"home_{i}")),
agent_note=str(form.get(f"note_{i}", "")).strip(),
)
)
added += 1
await session.commit()
return redirect("/dash/services", msg=f"Dodano {added} usluga.")
# -- settings: scheduling ---------------------------------------------------------
@router.get("/dash/scheduling", response_class=HTMLResponse)
async def scheduling_page(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
t = owner.tenant
connection = (
await session.execute(
select(CalendarConnection).where(CalendarConnection.tenant_id == t.id)
)
).scalar_one_or_none()
mappings_rows = (
(await session.execute(select(CalendarMapping).where(CalendarMapping.tenant_id == t.id)))
.scalars()
.all()
)
mappings = {}
for m in mappings_rows:
mappings["default" if m.service_id is None else str(m.service_id)] = m.google_calendar_id
services = (
(
await session.execute(
select(Service)
.where(Service.tenant_id == t.id, Service.active.is_(True))
.order_by(Service.name)
)
)
.scalars()
.all()
)
s = get_settings()
return templates.TemplateResponse(
request,
"dashboard/scheduling.html",
ctx(
request,
owner,
connection=connection,
mappings=mappings,
services=services,
google_configured=bool(s.google_client_id and s.google_client_secret),
partner_ok=None,
last_sync=None,
active="scheduling",
),
)
@router.post("/dash/scheduling/mappings")
async def scheduling_mappings_save(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
form = await request.form()
t = owner.tenant
existing = (
(await session.execute(select(CalendarMapping).where(CalendarMapping.tenant_id == t.id)))
.scalars()
.all()
)
for m in existing:
await session.delete(m)
default = str(form.get("default", "")).strip()
if default:
session.add(
CalendarMapping(tenant_id=t.id, service_id=None, google_calendar_id=default)
)
for key, value in form.items():
if key.startswith("svc_") and str(value).strip():
session.add(
CalendarMapping(
tenant_id=t.id,
service_id=uuid.UUID(key.removeprefix("svc_")),
google_calendar_id=str(value).strip(),
)
)
await session.commit()
return redirect("/dash/scheduling", msg="Sačuvano.")
@router.post("/dash/scheduling/buffers")
async def scheduling_buffers_save(
min_notice_hours: int = Form(...),
max_days_ahead: int = Form(...),
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
t = await session.merge(owner.tenant)
t.min_notice_hours = max(0, min(72, min_notice_hours))
t.max_days_ahead = max(1, min(60, max_days_ahead))
await session.commit()
return redirect("/dash/scheduling", msg="Sačuvano.")
# Google OAuth (free/busy read-only scope, §8.1)
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
FREEBUSY_SCOPE = "https://www.googleapis.com/auth/calendar.freebusy"
@router.get("/dash/scheduling/google/start")
async def google_oauth_start(owner: OwnerContext = Depends(current_owner)):
from urllib.parse import urlencode
s = get_settings()
params = {
"client_id": s.google_client_id,
"redirect_uri": f"{s.base_url}/oauth/google/callback",
"response_type": "code",
"scope": f"openid email {FREEBUSY_SCOPE}",
"access_type": "offline",
"prompt": "consent",
"state": str(owner.tenant.id),
}
return redirect(f"{GOOGLE_AUTH_URL}?{urlencode(params)}")
@router.get("/oauth/google/callback")
async def google_oauth_callback(
code: str = "",
state: str = "",
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
import httpx
from gogo.crypto import encrypt
if not code or state != str(owner.tenant.id):
return redirect("/dash/scheduling", err="Google prijava nije uspjela.")
s = get_settings()
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
GOOGLE_TOKEN_URL,
data={
"client_id": s.google_client_id,
"client_secret": s.google_client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": f"{s.base_url}/oauth/google/callback",
},
)
if resp.status_code != 200:
return redirect("/dash/scheduling", err="Google prijava nije uspjela (token).")
tokens = resp.json()
email = ""
id_token = tokens.get("id_token", "")
if id_token.count(".") == 2: # decode JWT payload without verification (email is cosmetic)
import base64
try:
payload = id_token.split(".")[1]
payload += "=" * (-len(payload) % 4)
email = json.loads(base64.urlsafe_b64decode(payload)).get("email", "")
except Exception: # noqa: BLE001
email = ""
from datetime import UTC, datetime, timedelta
token_data = {
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"expiry": (
datetime.now(UTC) + timedelta(seconds=tokens.get("expires_in", 3600))
).isoformat(),
}
existing = (
await session.execute(
select(CalendarConnection).where(CalendarConnection.tenant_id == owner.tenant.id)
)
).scalar_one_or_none()
if existing:
existing.google_account = email or existing.google_account
existing.token_data_encrypted = encrypt(json.dumps(token_data))
existing.status = "connected"
else:
session.add(
CalendarConnection(
tenant_id=owner.tenant.id,
google_account=email or "(nepoznat nalog)",
scopes=[FREEBUSY_SCOPE],
token_data_encrypted=encrypt(json.dumps(token_data)),
)
)
await session.commit()
return redirect("/dash/scheduling", msg="Google kalendar povezan.")
@router.post("/dash/scheduling/google/disconnect")
async def google_disconnect(
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
existing = (
await session.execute(
select(CalendarConnection).where(CalendarConnection.tenant_id == owner.tenant.id)
)
).scalar_one_or_none()
if existing:
await session.delete(existing)
await session.commit()
return redirect("/dash/scheduling", msg="Veza s Google kalendarom prekinuta.")
# -- settings: telephony -----------------------------------------------------------
@router.get("/dash/telephony", response_class=HTMLResponse)
async def telephony_page(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
t = owner.tenant
number = (
await session.execute(select(PhoneNumber).where(PhoneNumber.tenant_id == t.id))
).scalar_one_or_none()
workers = (
(
await session.execute(
select(Worker)
.where(Worker.tenant_id == t.id, Worker.active.is_(True))
.order_by(Worker.name)
)
)
.scalars()
.all()
)
forwarding = (
[forwarding_codes(op, number.msisdn, t.ring_timeout_s) for op in OPERATORS]
if number
else []
)
return templates.TemplateResponse(
request,
"dashboard/telephony.html",
ctx(request, owner, number=number, workers=workers, forwarding=forwarding,
active="telephony"),
)
@router.post("/dash/telephony")
async def telephony_save(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
form = await request.form()
t = await session.merge(owner.tenant)
t.ring_first_enabled = bool(form.get("ring_first_enabled"))
t.ring_timeout_s = max(5, min(60, int(form.get("ring_timeout_s") or 12)))
strategy = str(form.get("ring_strategy", "ring_all"))
t.ring_strategy = strategy if strategy in ("ring_all", "sequential") else "ring_all"
await session.commit()
return redirect("/dash/telephony", msg="Sačuvano.")
@router.post("/dash/workers")
async def worker_add(
name: str = Form(...),
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
worker = Worker(
tenant_id=owner.tenant.id,
name=name.strip(),
sip_username=sip.make_sip_username(owner.tenant.slug, name),
sip_password=sip.make_sip_password(),
)
session.add(worker)
await session.commit()
return redirect(f"/dash/workers/{worker.id}/setup", msg=f"{worker.name} dodan/a.")
async def _load_worker(session, owner: OwnerContext, worker_id: str) -> Worker | None:
try:
wid = uuid.UUID(worker_id)
except ValueError:
return None
return (
await session.execute(
select(Worker).where(Worker.id == wid, Worker.tenant_id == owner.tenant.id)
)
).scalar_one_or_none()
@router.get("/dash/workers/{worker_id}/setup", response_class=HTMLResponse)
async def worker_setup(
worker_id: str,
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
worker = await _load_worker(session, owner, worker_id)
if worker is None:
return redirect("/dash/telephony", err="Radnik nije pronađen.")
return templates.TemplateResponse(
request,
"dashboard/worker_setup.html",
ctx(request, owner, worker=worker, domain=sip.sip_domain(), active="telephony"),
)
@router.get("/dash/workers/{worker_id}/qr.png")
async def worker_qr(
worker_id: str,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
worker = await _load_worker(session, owner, worker_id)
if worker is None:
return Response(status_code=404)
png = sip.qr_png(sip.provisioning_text(worker.sip_username, worker.sip_password))
return Response(png, media_type="image/png")
@router.post("/dash/workers/{worker_id}/delete")
async def worker_delete(
worker_id: str,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
worker = await _load_worker(session, owner, worker_id)
if worker:
worker.active = False
await session.commit()
return redirect("/dash/telephony", msg="Uklonjeno.")
# -- settings: agent + notifications -------------------------------------------------
@router.get("/dash/agent", response_class=HTMLResponse)
async def agent_page(
request: Request,
owner: OwnerContext = Depends(current_owner),
):
return templates.TemplateResponse(
request,
"dashboard/agent.html",
ctx(
request,
owner,
voices=TTS_VOICES,
base_url=get_settings().base_url,
greeting=compose_greeting(owner.tenant, "voice"),
active="agent",
),
)
@router.post("/dash/agent")
async def agent_save(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
form = await request.form()
t = await session.merge(owner.tenant)
voice = str(form.get("tts_voice", ""))
if voice in {v["id"] for v in TTS_VOICES}:
t.tts_voice = voice
mode = str(form.get("price_mode", "exact"))
t.price_mode = mode if mode in ("exact", "range", "on_request") else "exact"
t.agent_notes = str(form.get("agent_notes", "")).strip()
await session.commit()
return redirect("/dash/agent", msg="Sačuvano.")
@router.get("/dash/notifications", response_class=HTMLResponse)
async def notifications_page(
request: Request,
owner: OwnerContext = Depends(current_owner),
):
t = owner.tenant
sms_rows = [
("confirmation", "Potvrda termina", DEFAULT_TEMPLATES["confirmation"]),
("rejection", "Odbijanje zahtjeva", DEFAULT_TEMPLATES["rejection"]),
("request_received", "Zahtjev zaprimljen", DEFAULT_TEMPLATES["request_received"]),
("missed_call", "Propušten poziv", DEFAULT_TEMPLATES["missed_call"]),
("expiry", "Istek zahtjeva", DEFAULT_TEMPLATES["expiry"]),
]
return templates.TemplateResponse(
request,
"dashboard/notifications.html",
ctx(
request,
owner,
emails_text="\n".join(t.notify_emails or []),
overrides=t.sms_templates or {},
sms_rows=sms_rows,
active="notifications",
),
)
@router.post("/dash/notifications")
async def notifications_save(
request: Request,
owner: OwnerContext = Depends(current_owner),
session: AsyncSession = Depends(get_session),
):
form = await request.form()
t = await session.merge(owner.tenant)
emails = [
e.strip()
for e in str(form.get("notify_emails", "")).replace(",", "\n").splitlines()
if e.strip()
]
t.notify_emails = emails
t.sms_request_received = bool(form.get("sms_request_received"))
overrides = {}
for key in DEFAULT_TEMPLATES:
val = str(form.get(f"sms_{key}", "")).strip()
if val and val != DEFAULT_TEMPLATES[key]:
overrides[key] = val
t.sms_templates = overrides
await session.commit()
return redirect("/dash/notifications", msg="Sačuvano.")
# -- widget demo page ------------------------------------------------------------
@router.get("/widget-demo/{key}", response_class=HTMLResponse)
async def widget_demo(key: str, request: Request):
return templates.TemplateResponse(request, "widget_demo.html", {"request": request, "key": key})