"""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", ), )