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:
0
gogo/admin/__init__.py
Normal file
0
gogo/admin/__init__.py
Normal file
795
gogo/admin/router.py
Normal file
795
gogo/admin/router.py
Normal file
@@ -0,0 +1,795 @@
|
||||
"""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 M4 pipeline service)
|
||||
voice_status, voice_badge = "nije pokrenut (M4)", "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",
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user