796 lines
26 KiB
Python
796 lines
26 KiB
Python
|
|
"""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",
|
|||
|
|
),
|
|||
|
|
)
|