Files
gogo-telefon/gogo/admin/router.py
Senad Uka fd45ff84dc M6 + M0: hardening, deploy docs, PoC scripts
- Pipeline-down fallback: dialplan plays gogo-fallback after a failed
  AudioSocket (§15) + script to synthesize the Bosnian prompt via TTS
- Voice heartbeat file → /health/voice endpoint + live status in admin panel
- Retention job also purges stale call registrations; chat sessions metered;
  100%-usage super-admin alert email (GOGO_ADMIN_ALERT_EMAIL)
- .env.example, docs/DEPLOY.md (compose stack, GPU node, backups pg_dump,
  update procedure, security notes)
- §16 Definition of Done as an automated test: softphone-style call → fake
  partner availability → request pushed → webhook confirm → console SMS
- M0 PoC scripts: STT accuracy harness (CER/WER + spoken-digit check; dry-run
  verified with espeak-ng samples + faster-whisper small on CPU — phone number
  extracted exactly), TTS bake-off (Azure vs ElevenLabs, latency+cost), and
  end-to-end latency smoke test speaking real AudioSocket to the live pipeline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:27:05 +02:00

808 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Super-admin panel (§11): tenants, provider config + connectivity test,
SIM/number mapping, prompt template versions + playground, usage, health."""
from __future__ import annotations
import json
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.auth import (
current_admin,
hash_password,
read_session,
set_session,
verify_password,
)
from gogo.config import get_settings
from gogo.crypto import encrypt
from gogo.db import get_session
from gogo.metering import current_month
from gogo.models import (
Admin,
BookingRequest,
EmailLog,
PhoneNumber,
PromptTemplate,
ProviderConfig,
SmsLog,
Tenant,
TenantPromptOverride,
UsageCounter,
User,
)
from gogo.telephony.forwarding import OPERATORS
from gogo.web import redirect, templates
router = APIRouter()
def ctx(request: Request, admin: Admin, **extra) -> dict:
return {"request": request, "admin": admin, "base_url": get_settings().base_url, **extra}
# -- auth --------------------------------------------------------------------
@router.get("/admin/login", response_class=HTMLResponse)
async def admin_login_page(request: Request):
return templates.TemplateResponse(request, "admin/login.html", {"request": request})
@router.post("/admin/login")
async def admin_login(
email: str = Form(...),
password: str = Form(...),
session: AsyncSession = Depends(get_session),
):
admin = (
await session.execute(select(Admin).where(Admin.email == email.strip().lower()))
).scalar_one_or_none()
if admin is None or not admin.active or not verify_password(password, admin.password_hash):
return redirect("/admin/login", err="Pogrešan email ili lozinka.")
resp = redirect("/admin")
set_session(resp, {"kind": "admin", "admin_id": str(admin.id), "email": admin.email})
return resp
@router.get("/admin/logout")
async def admin_logout():
from gogo.auth import clear_session
resp = redirect("/admin/login")
clear_session(resp)
return resp
@router.post("/admin/impersonate/{tenant_id}")
async def impersonate(
tenant_id: str,
request: Request,
admin: Admin = Depends(current_admin),
):
data = read_session(request) or {}
data["impersonate_tenant"] = tenant_id
resp = redirect("/dash", msg="Otvoren pregled kao salon (admin).")
set_session(resp, data)
return resp
# -- tenants ------------------------------------------------------------------
@router.get("/admin", response_class=HTMLResponse)
async def tenants_list(
request: Request,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
tenants = (
(await session.execute(select(Tenant).order_by(Tenant.name))).scalars().all()
)
month = current_month()
rows = []
for t in tenants:
counter = (
await session.execute(
select(UsageCounter).where(
UsageCounter.tenant_id == t.id, UsageCounter.month == month
)
)
).scalar_one_or_none()
number = (
await session.execute(select(PhoneNumber).where(PhoneNumber.tenant_id == t.id))
).scalar_one_or_none()
rows.append(
{
"tenant": t,
"used_minutes": (counter.agent_seconds // 60) if counter else 0,
"msisdn": number.msisdn if number else None,
}
)
return templates.TemplateResponse(
request, "admin/tenants.html", ctx(request, admin, rows=rows, active="tenants")
)
async def _tenant_edit_context(
request: Request, admin: Admin, session: AsyncSession, t: Tenant | None, test_result=None
) -> dict:
pconfig = {}
override_body = ""
if t:
cfg = (
await session.execute(select(ProviderConfig).where(ProviderConfig.tenant_id == t.id))
).scalar_one_or_none()
pconfig = cfg.config if cfg else {}
ov = (
await session.execute(
select(TenantPromptOverride).where(TenantPromptOverride.tenant_id == t.id)
)
).scalar_one_or_none()
override_body = ov.body if ov else ""
numbers = (
(await session.execute(select(PhoneNumber).order_by(PhoneNumber.msisdn))).scalars().all()
)
return ctx(
request,
admin,
t=t,
pconfig=pconfig,
override_body=override_body,
numbers=numbers,
test_result=test_result,
active="tenants",
)
@router.get("/admin/tenants/new", response_class=HTMLResponse)
async def tenant_new_page(
request: Request,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
return templates.TemplateResponse(
request, "admin/tenant_edit.html",
await _tenant_edit_context(request, admin, session, None),
)
@router.get("/admin/tenants/{tenant_id}", response_class=HTMLResponse)
async def tenant_edit_page(
tenant_id: str,
request: Request,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
t = (
await session.execute(select(Tenant).where(Tenant.id == uuid.UUID(tenant_id)))
).scalar_one_or_none()
if t is None:
return redirect("/admin", err="Salon nije pronađen.")
return templates.TemplateResponse(
request, "admin/tenant_edit.html",
await _tenant_edit_context(request, admin, session, t),
)
async def _apply_tenant_form(session: AsyncSession, t: Tenant, form) -> str | None:
"""Shared create/update logic. Returns error message or None."""
t.name = str(form.get("name", t.name)).strip() or t.name
t.city = str(form.get("city", "")).strip()
t.status = "disabled" if form.get("status") == "disabled" else "active"
t.plan = str(form.get("plan", "gogo_start")).strip() or "gogo_start"
t.included_minutes = int(form.get("included_minutes") or 300)
t.hard_cutoff = bool(form.get("hard_cutoff"))
t.llm_model = str(form.get("llm_model", "")).strip()
t.proposal_ttl_hours = max(1, int(form.get("proposal_ttl_hours") or 24))
paid = str(form.get("paid_until", "")).strip()
if paid:
try:
t.paid_until = datetime.strptime(paid, "%Y-%m-%d").replace(tzinfo=UTC)
except ValueError:
return "Neispravan datum 'plaćeno do' (očekivano YYYY-MM-DD)."
else:
t.paid_until = None
provider = str(form.get("scheduling_provider", t.scheduling_provider))
if provider in ("google_calendar", "partner_api", "mock"):
t.scheduling_provider = provider
# provider config (partner)
cfg_row = (
await session.execute(select(ProviderConfig).where(ProviderConfig.tenant_id == t.id))
).scalar_one_or_none()
if cfg_row is None:
cfg_row = ProviderConfig(tenant_id=t.id, provider_type=provider, config={})
session.add(cfg_row)
cfg = dict(cfg_row.config or {})
cfg_row.provider_type = provider
cfg["base_url"] = str(form.get("partner_base_url", "")).strip()
api_key = str(form.get("partner_api_key", "")).strip()
if api_key:
cfg["api_key_encrypted"] = encrypt(api_key)
secret = str(form.get("partner_webhook_secret", "")).strip()
if secret:
cfg["webhook_secret_encrypted"] = encrypt(secret)
cfg["catalog_sync"] = bool(form.get("catalog_sync"))
cfg["email_to_owner"] = bool(form.get("email_to_owner"))
cfg["polling_fallback"] = bool(form.get("polling_fallback"))
cfg_row.config = cfg
# number assignment
number_id = str(form.get("phone_number_id", "")).strip()
current = (
await session.execute(select(PhoneNumber).where(PhoneNumber.tenant_id == t.id))
).scalar_one_or_none()
if current and (not number_id or str(current.id) != number_id):
current.tenant_id = None
current.sim_status = "unassigned"
if number_id:
target = (
await session.execute(
select(PhoneNumber).where(PhoneNumber.id == uuid.UUID(number_id))
)
).scalar_one_or_none()
if target is not None:
if target.tenant_id and target.tenant_id != t.id:
return "Taj broj je već dodijeljen drugom salonu."
target.tenant_id = t.id
target.sim_status = "active"
# prompt override
override_body = str(form.get("prompt_override", "")).strip()
ov = (
await session.execute(
select(TenantPromptOverride).where(TenantPromptOverride.tenant_id == t.id)
)
).scalar_one_or_none()
if override_body:
if ov:
ov.body = override_body
else:
session.add(TenantPromptOverride(tenant_id=t.id, body=override_body))
elif ov:
await session.delete(ov)
return None
@router.post("/admin/tenants/new")
async def tenant_create(
request: Request,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
form = await request.form()
slug = str(form.get("slug", "")).strip().lower()
if not slug:
return redirect("/admin/tenants/new", err="Slug je obavezan.")
existing = (
await session.execute(select(Tenant).where(Tenant.slug == slug))
).scalar_one_or_none()
if existing:
return redirect("/admin/tenants/new", err="Slug je zauzet.")
t = Tenant(name=str(form.get("name", "")).strip() or slug, slug=slug)
session.add(t)
await session.flush()
err = await _apply_tenant_form(session, t, form)
if err:
return redirect("/admin/tenants/new", err=err)
owner_email = str(form.get("owner_email", "")).strip().lower()
owner_password = str(form.get("owner_password", "")).strip()
if owner_email and owner_password:
session.add(
User(tenant_id=t.id, email=owner_email, password_hash=hash_password(owner_password))
)
await session.commit()
return redirect(f"/admin/tenants/{t.id}", msg="Salon kreiran.")
@router.post("/admin/tenants/{tenant_id}")
async def tenant_update(
tenant_id: str,
request: Request,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
form = await request.form()
t = (
await session.execute(select(Tenant).where(Tenant.id == uuid.UUID(tenant_id)))
).scalar_one_or_none()
if t is None:
return redirect("/admin", err="Salon nije pronađen.")
err = await _apply_tenant_form(session, t, form)
if err:
await session.rollback()
return redirect(f"/admin/tenants/{tenant_id}", err=err)
await session.commit()
return redirect(f"/admin/tenants/{tenant_id}", msg="Sačuvano.")
@router.post("/admin/tenants/{tenant_id}/test-connection", response_class=HTMLResponse)
async def tenant_test_connection(
tenant_id: str,
request: Request,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
"""Connectivity test (§8.3): live availability probe + dry-run delivery."""
from datetime import date, timedelta
from gogo.domain import BookingRequestData
from gogo.models import Service, utcnow
from gogo.scheduling.base import get_provider
t = (
await session.execute(select(Tenant).where(Tenant.id == uuid.UUID(tenant_id)))
).scalar_one_or_none()
if t is None:
return redirect("/admin", err="Salon nije pronađen.")
lines = []
try:
provider = await get_provider(session, t)
svc = (
await session.execute(
select(Service).where(Service.tenant_id == t.id, Service.active.is_(True)).limit(1)
)
).scalar_one_or_none()
service_id = str(svc.id) if svc else "TEST"
lines.append(f"→ GET /availability (service {service_id})")
slots = await provider.get_availability(
service_id, date.today(), date.today() + timedelta(days=7)
)
lines.append(f"← OK, {len(slots)} slots")
for s in slots[:5]:
lines.append(f" {s.start.isoformat()} {s.end.isoformat()}"
+ (f" ({s.staff_name})" if s.staff_name else ""))
except Exception as e: # noqa: BLE001
lines.append(f"← ERROR: {type(e).__name__}: {e}")
try:
lines.append("→ POST /booking-requests (dry_run=true)")
result = await provider.deliver_request(
BookingRequestData(
gogo_request_id=str(uuid.uuid4()),
tenant_id=str(t.id),
created_at=utcnow(),
source="chat",
client_name="Test Konekcije",
client_phone="+38700000000",
summary="Dry-run test veze iz admin panela.",
dry_run=True,
)
)
lines.append(f"← ok={result.ok} detail={result.detail}")
except Exception as e: # noqa: BLE001
lines.append(f"← ERROR: {type(e).__name__}: {e}")
return templates.TemplateResponse(
request,
"admin/tenant_edit.html",
await _tenant_edit_context(request, admin, session, t, test_result="\n".join(lines)),
)
# -- numbers / SIM --------------------------------------------------------------
@router.get("/admin/numbers", response_class=HTMLResponse)
async def numbers_page(
request: Request,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
numbers = (
(await session.execute(select(PhoneNumber).order_by(PhoneNumber.msisdn))).scalars().all()
)
rows = []
for n in numbers:
tenant_name = None
if n.tenant_id:
tn = (
await session.execute(select(Tenant).where(Tenant.id == n.tenant_id))
).scalar_one_or_none()
tenant_name = tn.name if tn else None
rows.append({"n": n, "tenant_name": tenant_name})
return templates.TemplateResponse(
request,
"admin/numbers.html",
ctx(request, admin, rows=rows, operators=OPERATORS, active="numbers"),
)
@router.post("/admin/numbers")
async def number_add(
msisdn: str = Form(...),
gateway_port: str = Form(""),
operator: str = Form(""),
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
session.add(
PhoneNumber(
msisdn=msisdn.strip(),
gateway_port=int(gateway_port) if gateway_port.strip() else None,
operator=operator if operator in OPERATORS else "",
)
)
await session.commit()
return redirect("/admin/numbers", msg="Broj dodan.")
@router.post("/admin/numbers/{number_id}")
async def number_update(
number_id: str,
gateway_port: str = Form(""),
operator: str = Form(""),
sim_status: str = Form("unassigned"),
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
n = (
await session.execute(
select(PhoneNumber).where(PhoneNumber.id == uuid.UUID(number_id))
)
).scalar_one_or_none()
if n:
n.gateway_port = int(gateway_port) if gateway_port.strip() else None
n.operator = operator if operator in OPERATORS else ""
n.sim_status = sim_status
await session.commit()
return redirect("/admin/numbers", msg="Sačuvano.")
@router.post("/admin/numbers/{number_id}/delete")
async def number_delete(
number_id: str,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
n = (
await session.execute(
select(PhoneNumber).where(PhoneNumber.id == uuid.UUID(number_id))
)
).scalar_one_or_none()
if n:
await session.delete(n)
await session.commit()
return redirect("/admin/numbers", msg="Obrisano.")
# -- prompt templates -------------------------------------------------------------
@router.get("/admin/templates", response_class=HTMLResponse)
async def templates_page(
request: Request,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
from gogo.agent.prompt import DEFAULT_PROMPT_TEMPLATE
versions = (
(
await session.execute(
select(PromptTemplate).order_by(desc(PromptTemplate.version))
)
)
.scalars()
.all()
)
load = request.query_params.get("load")
draft_body = None
if load:
for v in versions:
if str(v.version) == load:
draft_body = v.body
if draft_body is None:
published = next((v for v in versions if v.published), None)
draft_body = published.body if published else DEFAULT_PROMPT_TEMPLATE
return templates.TemplateResponse(
request,
"admin/templates.html",
ctx(request, admin, versions=versions, draft_body=draft_body, active="templates"),
)
@router.post("/admin/templates")
async def template_save(
body: str = Form(...),
notes: str = Form(""),
publish: str = Form(""),
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
max_version = (
await session.execute(select(func.max(PromptTemplate.version)))
).scalar() or 0
if publish:
# unpublish the rest BEFORE adding the new one (autoflush would catch it too)
others = (
(
await session.execute(
select(PromptTemplate).where(PromptTemplate.published.is_(True))
)
)
.scalars()
.all()
)
for o in others:
o.published = False
version = PromptTemplate(
version=max_version + 1, body=body, notes=notes.strip(), published=bool(publish)
)
session.add(version)
await session.commit()
return redirect(
"/admin/templates",
msg=f"Verzija v{version.version} sačuvana{' i objavljena' if publish else ''}.",
)
@router.post("/admin/templates/{version}/publish")
async def template_publish(
version: int,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
target = (
await session.execute(select(PromptTemplate).where(PromptTemplate.version == version))
).scalar_one_or_none()
if target is None:
return redirect("/admin/templates", err="Verzija nije pronađena.")
all_versions = (await session.execute(select(PromptTemplate))).scalars().all()
for v in all_versions:
v.published = v.version == version
await session.commit()
return redirect("/admin/templates", msg=f"Verzija v{version} je sada aktivna.")
# -- playground --------------------------------------------------------------------
@router.get("/admin/playground", response_class=HTMLResponse)
async def playground_page(
request: Request,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
tenants = (
(await session.execute(select(Tenant).order_by(Tenant.name))).scalars().all()
)
return templates.TemplateResponse(
request,
"admin/playground.html",
ctx(
request,
admin,
tenants=tenants,
selected_tenant=None,
transcript=[],
history_json="[]",
channel="voice",
system_prompt=None,
error=None,
active="playground",
),
)
@router.post("/admin/playground", response_class=HTMLResponse)
async def playground_send(
request: Request,
tenant_id: str = Form(...),
channel: str = Form("voice"),
history: str = Form("[]"),
message: str = Form(""),
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
from gogo.agent.loop import AgentConversation
from gogo.agent.prompt import compose_system_prompt
tenants = (
(await session.execute(select(Tenant).order_by(Tenant.name))).scalars().all()
)
tenant = next((t for t in tenants if str(t.id) == tenant_id), None)
if tenant is None:
return redirect("/admin/playground", err="Salon nije pronađen.")
try:
past: list[dict] = json.loads(history) if history.strip() else []
except json.JSONDecodeError:
past = []
error = None
convo = AgentConversation(
session, tenant, channel=channel, caller_phone="+38765000111"
)
# rebuild plain-text history (tool traffic is not replayed; fine for a playground)
for m in past:
convo.messages.append({"role": m["role"], "content": m["text"]})
from gogo.agent.loop import Turn
convo.turns.append(Turn(m["role"], m["text"]))
if not past:
await convo.greeting()
if message.strip():
if not get_settings().anthropic_api_key:
error = "GOGO_ANTHROPIC_API_KEY nije postavljen — playground treba LLM ključ."
else:
try:
await convo.user_turn(message.strip())
await session.commit()
except Exception as e: # noqa: BLE001
error = f"LLM greška: {type(e).__name__}: {e}"
transcript = convo.transcript
history_json = json.dumps(
[{"role": t["role"], "text": t["text"]} for t in transcript], ensure_ascii=False
)
system_prompt = await compose_system_prompt(session, tenant, channel)
return templates.TemplateResponse(
request,
"admin/playground.html",
ctx(
request,
admin,
tenants=tenants,
selected_tenant=tenant,
transcript=transcript,
history_json=history_json,
channel=channel,
system_prompt=system_prompt,
error=error,
active="playground",
),
)
# -- usage + health ------------------------------------------------------------------
@router.get("/admin/usage", response_class=HTMLResponse)
async def usage_overview(
request: Request,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
month = current_month()
tenants = (
(await session.execute(select(Tenant).order_by(Tenant.name))).scalars().all()
)
rows = []
# rough per-minute cost estimate: LLM ~0.02 KM + TTS ~0.05 KM per agent minute
llm_km, tts_km = 0.02, 0.05
for t in tenants:
counter = (
await session.execute(
select(UsageCounter).where(
UsageCounter.tenant_id == t.id, UsageCounter.month == month
)
)
).scalar_one_or_none()
used = (counter.agent_seconds // 60) if counter else 0
requests_count = (
await session.execute(
select(func.count())
.select_from(BookingRequest)
.where(BookingRequest.tenant_id == t.id)
)
).scalar() or 0
rows.append(
{
"tenant": t,
"used_minutes": used,
"calls": counter.calls if counter else 0,
"sms": counter.sms_sent if counter else 0,
"chats": counter.chat_sessions if counter else 0,
"requests": requests_count,
"est_cost_km": round(used * (llm_km + tts_km), 2),
"over_80": t.included_minutes and used >= t.included_minutes * 0.8,
"over_100": t.included_minutes and used >= t.included_minutes,
}
)
return templates.TemplateResponse(
request,
"admin/usage.html",
ctx(
request,
admin,
rows=rows,
month=month,
cost_note=f"LLM ~{llm_km} KM/min + TTS ~{tts_km} KM/min (gruba procjena).",
active="usage",
),
)
@router.get("/admin/health", response_class=HTMLResponse)
async def health_page(
request: Request,
admin: Admin = Depends(current_admin),
session: AsyncSession = Depends(get_session),
):
from datetime import timedelta
from sqlalchemy import text
s = get_settings()
try:
await session.execute(text("SELECT 1"))
db_ok = True
except Exception: # noqa: BLE001
db_ok = False
week_ago = datetime.now(UTC) - timedelta(days=7)
email_errors = (
await session.execute(
select(func.count())
.select_from(EmailLog)
.where(EmailLog.status == "failed", EmailLog.at > week_ago)
)
).scalar() or 0
sms_errors = (
await session.execute(
select(func.count())
.select_from(SmsLog)
.where(SmsLog.status == "failed", SmsLog.at > week_ago)
)
).scalar() or 0
failed_rows = []
for row in (
(
await session.execute(
select(SmsLog)
.where(SmsLog.status == "failed")
.order_by(desc(SmsLog.at))
.limit(10)
)
)
.scalars()
.all()
):
failed_rows.append(
{"at": row.at, "kind": f"SMS/{row.kind}", "to": row.to_msisdn, "error": row.error}
)
# voice pipeline heartbeat (written by the voice service, M6)
import os
import time as time_mod
from gogo.voice.server import heartbeat_path
try:
age = time_mod.time() - os.path.getmtime(heartbeat_path())
if age <= 60:
voice_status, voice_badge = f"radi (heartbeat prije {int(age)}s)", "confirmed"
else:
voice_status, voice_badge = f"NE RADI (heartbeat star {int(age)}s)", "rejected"
except OSError:
voice_status, voice_badge = "nije pokrenut", "pending"
return templates.TemplateResponse(
request,
"admin/health.html",
ctx(
request,
admin,
db_ok=db_ok,
llm_configured=bool(s.anthropic_api_key),
email_mode=s.email_provider,
sms_mode=s.sms_provider,
voice_status=voice_status,
voice_badge=voice_badge,
email_errors=email_errors,
sms_errors=sms_errors,
failed_rows=failed_rows,
active="health",
),
)