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",
|
||||||
|
),
|
||||||
|
)
|
||||||
106
gogo/auth.py
Normal file
106
gogo/auth.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Session-based auth (§4): owner accounts (one per salon) + super-admins.
|
||||||
|
|
||||||
|
No public registration — accounts are created by the super-admin. Sessions are
|
||||||
|
signed cookies (itsdangerous). Admins can impersonate a tenant ("open dashboard
|
||||||
|
as salon") for support/onboarding (§11).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from fastapi import Depends, HTTPException, Request, Response
|
||||||
|
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from gogo.config import get_settings
|
||||||
|
from gogo.db import get_session
|
||||||
|
from gogo.models import Admin, Tenant, User
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _serializer() -> URLSafeTimedSerializer:
|
||||||
|
return URLSafeTimedSerializer(get_settings().secret_key, salt="session")
|
||||||
|
|
||||||
|
|
||||||
|
def set_session(response: Response, data: dict[str, Any]) -> None:
|
||||||
|
s = get_settings()
|
||||||
|
response.set_cookie(
|
||||||
|
s.session_cookie,
|
||||||
|
_serializer().dumps(data),
|
||||||
|
max_age=s.session_max_age,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session(response: Response) -> None:
|
||||||
|
response.delete_cookie(get_settings().session_cookie)
|
||||||
|
|
||||||
|
|
||||||
|
def read_session(request: Request) -> dict[str, Any] | None:
|
||||||
|
raw = request.cookies.get(get_settings().session_cookie)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _serializer().loads(raw, max_age=get_settings().session_max_age)
|
||||||
|
except BadSignature:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class OwnerContext:
|
||||||
|
def __init__(self, user: User | None, tenant: Tenant, impersonated_by: str | None = None):
|
||||||
|
self.user = user
|
||||||
|
self.tenant = tenant
|
||||||
|
self.impersonated_by = impersonated_by # admin email when impersonating
|
||||||
|
|
||||||
|
|
||||||
|
async def current_owner(
|
||||||
|
request: Request, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> OwnerContext:
|
||||||
|
"""Dependency: require an owner session (or an admin impersonating one)."""
|
||||||
|
data = read_session(request)
|
||||||
|
if data and data.get("kind") == "owner":
|
||||||
|
user = (
|
||||||
|
await session.execute(select(User).where(User.id == uuid.UUID(data["user_id"])))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user and user.active:
|
||||||
|
tenant = (
|
||||||
|
await session.execute(select(Tenant).where(Tenant.id == user.tenant_id))
|
||||||
|
).scalar_one()
|
||||||
|
return OwnerContext(user, tenant)
|
||||||
|
if data and data.get("kind") == "admin" and data.get("impersonate_tenant"):
|
||||||
|
tenant = (
|
||||||
|
await session.execute(
|
||||||
|
select(Tenant).where(Tenant.id == uuid.UUID(data["impersonate_tenant"]))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if tenant:
|
||||||
|
return OwnerContext(None, tenant, impersonated_by=data.get("email"))
|
||||||
|
raise HTTPException(status_code=303, headers={"Location": "/login"})
|
||||||
|
|
||||||
|
|
||||||
|
async def current_admin(
|
||||||
|
request: Request, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> Admin:
|
||||||
|
data = read_session(request)
|
||||||
|
if data and data.get("kind") == "admin":
|
||||||
|
admin = (
|
||||||
|
await session.execute(select(Admin).where(Admin.id == uuid.UUID(data["admin_id"])))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if admin and admin.active:
|
||||||
|
return admin
|
||||||
|
raise HTTPException(status_code=303, headers={"Location": "/admin/login"})
|
||||||
0
gogo/chat/__init__.py
Normal file
0
gogo/chat/__init__.py
Normal file
195
gogo/chat/router.py
Normal file
195
gogo/chat/router.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""Chat widget backend (§7): WebSocket endpoint + embeddable script serving.
|
||||||
|
|
||||||
|
Same agent, same tools, text instead of voice. No client accounts; sessions are
|
||||||
|
ephemeral and resumable for 24h via a localStorage token. Rate-limited per IP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from gogo.agent.loop import AgentConversation
|
||||||
|
from gogo.agent.prompt import compose_greeting
|
||||||
|
from gogo.db import get_sessionmaker
|
||||||
|
from gogo.models import ChatMessage, ChatSession, Tenant
|
||||||
|
|
||||||
|
log = logging.getLogger("gogo.chat")
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
MAX_MESSAGE_CHARS = 500
|
||||||
|
MAX_MESSAGES_PER_SESSION = 40
|
||||||
|
MIN_SECONDS_BETWEEN_MESSAGES = 1.0
|
||||||
|
SESSIONS_PER_IP_PER_HOUR = 10
|
||||||
|
RESUME_WINDOW = timedelta(hours=24)
|
||||||
|
|
||||||
|
# ip -> deque of session-creation timestamps (in-memory; fine for MVP scale §15)
|
||||||
|
_session_creations: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_allowed(ip: str) -> bool:
|
||||||
|
now = time.monotonic()
|
||||||
|
q = _session_creations[ip]
|
||||||
|
while q and now - q[0] > 3600:
|
||||||
|
q.popleft()
|
||||||
|
if len(q) >= SESSIONS_PER_IP_PER_HOUR:
|
||||||
|
return False
|
||||||
|
q.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
WIDGET_JS = (Path(__file__).parent.parent / "static" / "widget.js").read_text
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/widget.js")
|
||||||
|
async def widget_js():
|
||||||
|
return Response(WIDGET_JS(), media_type="application/javascript")
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/widget/ws")
|
||||||
|
async def widget_ws(ws: WebSocket):
|
||||||
|
key = ws.query_params.get("key", "")
|
||||||
|
resume = ws.query_params.get("resume", "")
|
||||||
|
ip = ws.client.host if ws.client else ""
|
||||||
|
|
||||||
|
async with get_sessionmaker()() as session:
|
||||||
|
tenant = (
|
||||||
|
await session.execute(
|
||||||
|
select(Tenant).where(Tenant.widget_public_key == key, Tenant.status == "active")
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if tenant is None:
|
||||||
|
await ws.close(code=4404)
|
||||||
|
return
|
||||||
|
|
||||||
|
chat, history = await _resume_or_create(session, tenant, resume, ip)
|
||||||
|
if chat is None:
|
||||||
|
await ws.close(code=4429) # rate limited
|
||||||
|
return
|
||||||
|
await ws.accept()
|
||||||
|
|
||||||
|
convo = AgentConversation(
|
||||||
|
session, tenant, channel="chat", chat_session_id=chat.id
|
||||||
|
)
|
||||||
|
greeting = compose_greeting(tenant, "chat")
|
||||||
|
if history:
|
||||||
|
# rebuild the model-visible history from stored messages
|
||||||
|
for m in history:
|
||||||
|
convo.messages.append(
|
||||||
|
{"role": "user" if m.role == "user" else "assistant", "content": m.content}
|
||||||
|
)
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "resume",
|
||||||
|
"session": chat.resume_token,
|
||||||
|
"messages": [{"role": m.role, "text": m.content} for m in history],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
convo.messages.append({"role": "assistant", "content": greeting})
|
||||||
|
session.add(ChatMessage(session_id=chat.id, role="assistant", content=greeting))
|
||||||
|
await session.commit()
|
||||||
|
await ws.send_json(
|
||||||
|
{"type": "greeting", "session": chat.resume_token, "text": greeting}
|
||||||
|
)
|
||||||
|
|
||||||
|
message_count = len(history)
|
||||||
|
last_message_at = 0.0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await ws.receive_json()
|
||||||
|
if data.get("type") != "message":
|
||||||
|
continue
|
||||||
|
if data.get("website"): # honeypot field — bots fill it, humans never see it
|
||||||
|
continue
|
||||||
|
text = str(data.get("text", "")).strip()[:MAX_MESSAGE_CHARS]
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_message_at < MIN_SECONDS_BETWEEN_MESSAGES:
|
||||||
|
await ws.send_json({"type": "error", "code": "too_fast"})
|
||||||
|
continue
|
||||||
|
last_message_at = now
|
||||||
|
message_count += 1
|
||||||
|
if message_count > MAX_MESSAGES_PER_SESSION:
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"text": (
|
||||||
|
"Dostigli ste ograničenje poruka. Molimo pozovite salon "
|
||||||
|
"telefonom. Hvala!"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
session.add(ChatMessage(session_id=chat.id, role="user", content=text))
|
||||||
|
await ws.send_json({"type": "typing"})
|
||||||
|
try:
|
||||||
|
reply = await convo.user_turn(text)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("chat agent error (tenant %s)", tenant.slug)
|
||||||
|
reply = (
|
||||||
|
"Izvinite, došlo je do tehničke greške. Molimo pokušajte "
|
||||||
|
"ponovo ili pozovite salon telefonom."
|
||||||
|
)
|
||||||
|
session.add(ChatMessage(session_id=chat.id, role="assistant", content=reply))
|
||||||
|
chat.outcome = convo.outcome
|
||||||
|
await session.commit()
|
||||||
|
await ws.send_json({"type": "message", "text": reply})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
# No awaits here: the task may be cancelled during teardown and an
|
||||||
|
# in-flight DB call on a closing connection can never complete.
|
||||||
|
# Outcome is already persisted after every message.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _resume_or_create(
|
||||||
|
session, tenant: Tenant, resume_token: str, ip: str
|
||||||
|
) -> tuple[ChatSession | None, list[ChatMessage]]:
|
||||||
|
if resume_token:
|
||||||
|
chat = (
|
||||||
|
await session.execute(
|
||||||
|
select(ChatSession).where(
|
||||||
|
ChatSession.tenant_id == tenant.id,
|
||||||
|
ChatSession.resume_token == resume_token,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if chat is not None:
|
||||||
|
started = chat.started_at
|
||||||
|
if started.tzinfo is None:
|
||||||
|
started = started.replace(tzinfo=UTC)
|
||||||
|
if datetime.now(UTC) - started < RESUME_WINDOW:
|
||||||
|
history = (
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(ChatMessage)
|
||||||
|
.where(ChatMessage.session_id == chat.id)
|
||||||
|
.order_by(ChatMessage.at)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return chat, list(history)
|
||||||
|
if not _ip_allowed(ip):
|
||||||
|
return None, []
|
||||||
|
chat = ChatSession(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
resume_token=uuid.uuid4().hex,
|
||||||
|
client_ip=ip,
|
||||||
|
outcome="abandoned", # upgraded as the conversation progresses
|
||||||
|
)
|
||||||
|
session.add(chat)
|
||||||
|
await session.commit()
|
||||||
|
return chat, []
|
||||||
0
gogo/dashboard/__init__.py
Normal file
0
gogo/dashboard/__init__.py
Normal file
125
gogo/dashboard/parse.py
Normal file
125
gogo/dashboard/parse.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""'Zalijepi cjenovnik' paste-to-parse (§10.2): LLM turns a free-text price list
|
||||||
|
(from a website, Word, Facebook…) into structured services the owner reviews.
|
||||||
|
Same approach for working hours."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from gogo.agent.llm import LLMProvider, get_llm
|
||||||
|
|
||||||
|
log = logging.getLogger("gogo.parse")
|
||||||
|
|
||||||
|
_SERVICES_TOOL = {
|
||||||
|
"name": "save_services",
|
||||||
|
"description": "Save the parsed price list as structured services.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"services": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"duration_min": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "estimate a realistic duration if not stated",
|
||||||
|
},
|
||||||
|
"price_min": {"type": "number"},
|
||||||
|
"price_max": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "same as price_min when a single price",
|
||||||
|
},
|
||||||
|
"home_visit": {"type": "boolean", "default": False},
|
||||||
|
"note": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name", "duration_min"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["services"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_HOURS_TOOL = {
|
||||||
|
"name": "save_hours",
|
||||||
|
"description": "Save the parsed working hours.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
day: {
|
||||||
|
"type": "array",
|
||||||
|
"description": f"open intervals for {day} as [\"HH:MM\",\"HH:MM\"] pairs; empty if closed",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"minItems": 2,
|
||||||
|
"maxItems": 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for day in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||||
|
},
|
||||||
|
"required": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_price_list(text: str, llm: LLMProvider | None = None) -> list[dict]:
|
||||||
|
"""Returns a list of service dicts for the owner to review (never saved directly)."""
|
||||||
|
llm = llm or get_llm()
|
||||||
|
response = await llm.complete(
|
||||||
|
system=(
|
||||||
|
"You parse beauty-salon price lists written in Bosnian/Serbian/Croatian "
|
||||||
|
"into structured data. Prices are in KM (BAM). A range like '60-90 KM' "
|
||||||
|
"means price_min=60, price_max=90. Estimate realistic durations when "
|
||||||
|
"missing (šišanje ~45, farbanje ~90, manikir ~45, pedikir ~60, "
|
||||||
|
"depilacija ~30 minutes). Set home_visit only when the text explicitly "
|
||||||
|
"mentions coming to the client's home. Keep names as written (fix only "
|
||||||
|
"obvious typos). Call save_services exactly once."
|
||||||
|
),
|
||||||
|
messages=[{"role": "user", "content": text[:8000]}],
|
||||||
|
tools=[_SERVICES_TOOL],
|
||||||
|
max_tokens=4096,
|
||||||
|
)
|
||||||
|
for tu in response.tool_uses:
|
||||||
|
if tu.name == "save_services":
|
||||||
|
return list(tu.input.get("services", []))
|
||||||
|
log.warning("price-list parse produced no tool call")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_working_hours(text: str, llm: LLMProvider | None = None) -> dict | None:
|
||||||
|
llm = llm or get_llm()
|
||||||
|
response = await llm.complete(
|
||||||
|
system=(
|
||||||
|
"You parse salon working hours written in Bosnian/Serbian/Croatian into "
|
||||||
|
"structured JSON. 'pon-pet 9-18' means mon..fri [[\"09:00\",\"18:00\"]]. "
|
||||||
|
"'pauza 13-14' splits the day into two intervals. Closed days get []. "
|
||||||
|
"Call save_hours exactly once."
|
||||||
|
),
|
||||||
|
messages=[{"role": "user", "content": text[:2000]}],
|
||||||
|
tools=[_HOURS_TOOL],
|
||||||
|
max_tokens=2048,
|
||||||
|
)
|
||||||
|
for tu in response.tool_uses:
|
||||||
|
if tu.name == "save_hours":
|
||||||
|
hours = {k: tu.input.get(k, []) for k in
|
||||||
|
["mon", "tue", "wed", "thu", "fri", "sat", "sun"]}
|
||||||
|
return _validate_hours(hours)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_hours(hours: dict) -> dict | None:
|
||||||
|
try:
|
||||||
|
for _day, intervals in hours.items():
|
||||||
|
for iv in intervals:
|
||||||
|
a, b = iv
|
||||||
|
for t in (a, b):
|
||||||
|
h, m = t.split(":")
|
||||||
|
assert 0 <= int(h) <= 23 and 0 <= int(m) <= 59
|
||||||
|
return hours
|
||||||
|
except (ValueError, AssertionError, TypeError):
|
||||||
|
log.warning("parsed hours failed validation: %s", json.dumps(hours)[:200])
|
||||||
|
return None
|
||||||
1105
gogo/dashboard/router.py
Normal file
1105
gogo/dashboard/router.py
Normal file
File diff suppressed because it is too large
Load Diff
22
gogo/db.py
22
gogo/db.py
@@ -19,7 +19,27 @@ _sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
|||||||
def get_engine():
|
def get_engine():
|
||||||
global _engine, _sessionmaker
|
global _engine, _sessionmaker
|
||||||
if _engine is None:
|
if _engine is None:
|
||||||
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
|
url = get_settings().database_url
|
||||||
|
kwargs = {}
|
||||||
|
if url.startswith("sqlite"):
|
||||||
|
# tests: fresh connection per checkout so sessions work across event loops
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
kwargs["poolclass"] = NullPool
|
||||||
|
kwargs["connect_args"] = {"timeout": 30}
|
||||||
|
_engine = create_async_engine(url, pool_pre_ping=True, **kwargs)
|
||||||
|
if url.startswith("sqlite"):
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
|
@event.listens_for(_engine.sync_engine, "connect")
|
||||||
|
def _sqlite_pragmas(dbapi_conn, _record):
|
||||||
|
# WAL lets readers proceed alongside a writer — the test suite runs
|
||||||
|
# the app and fixtures on different event loops/connections
|
||||||
|
cursor = dbapi_conn.cursor()
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cursor.execute("PRAGMA busy_timeout=30000")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)
|
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)
|
||||||
return _engine
|
return _engine
|
||||||
|
|
||||||
|
|||||||
14
gogo/main.py
14
gogo/main.py
@@ -29,10 +29,24 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
from gogo.admin.router import router as admin_router
|
||||||
|
from gogo.chat.router import router as chat_router
|
||||||
|
from gogo.dashboard.router import router as dashboard_router
|
||||||
|
|
||||||
app = FastAPI(title="Gogo Telefon", docs_url=None, redoc_url=None, lifespan=lifespan)
|
app = FastAPI(title="Gogo Telefon", docs_url=None, redoc_url=None, lifespan=lifespan)
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(actions.router)
|
app.include_router(actions.router)
|
||||||
app.include_router(webhooks.router)
|
app.include_router(webhooks.router)
|
||||||
|
app.include_router(chat_router)
|
||||||
|
app.include_router(dashboard_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
async def root():
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
return RedirectResponse("/dash")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
104
gogo/metering.py
Normal file
104
gogo/metering.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""Usage metering (§12): agent voice minutes per tenant per month.
|
||||||
|
|
||||||
|
Human-answered ring-group time and chat do NOT count toward voice minutes.
|
||||||
|
Soft limits: 80% → owner email + dashboard banner; 100% → super-admin alert,
|
||||||
|
agent keeps answering (hard cutoff only via tenant flag, default off).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from gogo.models import Tenant, UsageCounter
|
||||||
|
|
||||||
|
log = logging.getLogger("gogo.metering")
|
||||||
|
|
||||||
|
|
||||||
|
def current_month(at: datetime | None = None) -> str:
|
||||||
|
at = at or datetime.now(UTC)
|
||||||
|
return f"{at.year:04d}-{at.month:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_counter(
|
||||||
|
session: AsyncSession, tenant_id: uuid.UUID, month: str | None = None
|
||||||
|
) -> UsageCounter:
|
||||||
|
month = month or current_month()
|
||||||
|
counter = (
|
||||||
|
await session.execute(
|
||||||
|
select(UsageCounter).where(
|
||||||
|
UsageCounter.tenant_id == tenant_id, UsageCounter.month == month
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if counter is None:
|
||||||
|
counter = UsageCounter(tenant_id=tenant_id, month=month)
|
||||||
|
session.add(counter)
|
||||||
|
await session.flush()
|
||||||
|
return counter
|
||||||
|
|
||||||
|
|
||||||
|
async def record_agent_call(
|
||||||
|
session: AsyncSession, tenant: Tenant, agent_seconds: int
|
||||||
|
) -> None:
|
||||||
|
"""Meter one agent-handled call and fire soft-limit warnings (§12)."""
|
||||||
|
counter = await get_counter(session, tenant.id)
|
||||||
|
counter.agent_seconds += max(0, agent_seconds)
|
||||||
|
counter.calls += 1
|
||||||
|
|
||||||
|
included_s = tenant.included_minutes * 60
|
||||||
|
if included_s <= 0:
|
||||||
|
return
|
||||||
|
used = counter.agent_seconds
|
||||||
|
if used >= included_s and not counter.warned_100:
|
||||||
|
counter.warned_100 = True
|
||||||
|
log.warning(
|
||||||
|
"tenant %s exceeded included minutes (%d/%d min) — super-admin alert",
|
||||||
|
tenant.slug, used // 60, tenant.included_minutes,
|
||||||
|
)
|
||||||
|
await _send_warning(session, tenant, used // 60, 100)
|
||||||
|
elif used >= included_s * 0.8 and not counter.warned_80:
|
||||||
|
counter.warned_80 = True
|
||||||
|
await _send_warning(session, tenant, used // 60, 80)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_warning(session: AsyncSession, tenant: Tenant, used_minutes: int, pct: int):
|
||||||
|
from gogo.proposals.email import send_usage_warning_email
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send_usage_warning_email(session, tenant, used_minutes, pct)
|
||||||
|
except Exception: # noqa: BLE001 — metering must never break call handling
|
||||||
|
log.exception("usage warning email failed (tenant %s)", tenant.slug)
|
||||||
|
|
||||||
|
|
||||||
|
async def record_sms(session: AsyncSession, tenant_id: uuid.UUID) -> None:
|
||||||
|
counter = await get_counter(session, tenant_id)
|
||||||
|
counter.sms_sent += 1
|
||||||
|
|
||||||
|
|
||||||
|
async def record_chat_session(session: AsyncSession, tenant_id: uuid.UUID) -> None:
|
||||||
|
counter = await get_counter(session, tenant_id)
|
||||||
|
counter.chat_sessions += 1
|
||||||
|
|
||||||
|
|
||||||
|
async def usage_summary(session: AsyncSession, tenant: Tenant) -> dict:
|
||||||
|
"""For the dashboard usage bar (§10) and super-admin overview (§11)."""
|
||||||
|
counter = await get_counter(session, tenant.id)
|
||||||
|
used_min = counter.agent_seconds // 60
|
||||||
|
included = tenant.included_minutes
|
||||||
|
pct = min(100, round(used_min * 100 / included)) if included else 0
|
||||||
|
return {
|
||||||
|
"month": counter.month,
|
||||||
|
"used_minutes": used_min,
|
||||||
|
"included_minutes": included,
|
||||||
|
"pct": pct,
|
||||||
|
"calls": counter.calls,
|
||||||
|
"sms_sent": counter.sms_sent,
|
||||||
|
"chat_sessions": counter.chat_sessions,
|
||||||
|
"over_80": bool(included) and used_min >= included * 0.8,
|
||||||
|
"over_100": bool(included) and used_min >= included,
|
||||||
|
}
|
||||||
@@ -10,17 +10,11 @@ import sys
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from gogo.auth import hash_password
|
||||||
from gogo.crypto import encrypt
|
from gogo.crypto import encrypt
|
||||||
from gogo.db import Base, get_engine, get_sessionmaker
|
from gogo.db import Base, get_engine, get_sessionmaker
|
||||||
from gogo.models import Admin, ProviderConfig, Service, Tenant, User
|
from gogo.models import Admin, ProviderConfig, Service, Tenant, User
|
||||||
|
|
||||||
|
|
||||||
def hash_password(plain: str) -> str:
|
|
||||||
import bcrypt
|
|
||||||
|
|
||||||
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
MERIMA_HOURS = {
|
MERIMA_HOURS = {
|
||||||
"mon": [["09:00", "18:00"]],
|
"mon": [["09:00", "18:00"]],
|
||||||
"tue": [["09:00", "18:00"]],
|
"tue": [["09:00", "18:00"]],
|
||||||
|
|||||||
153
gogo/static/widget.js
Normal file
153
gogo/static/widget.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/* Gogo Telefon chat widget — embeddable, dependency-free (§7).
|
||||||
|
*
|
||||||
|
* Usage on the salon's site:
|
||||||
|
* <script src="https://app.gogotelefon.ba/widget.js" data-gogo-key="PUBLIC_KEY" async></script>
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var script = document.currentScript ||
|
||||||
|
document.querySelector("script[data-gogo-key]");
|
||||||
|
if (!script) return;
|
||||||
|
var KEY = script.getAttribute("data-gogo-key");
|
||||||
|
if (!KEY) return;
|
||||||
|
var BASE = (function () {
|
||||||
|
try {
|
||||||
|
var u = new URL(script.src);
|
||||||
|
return u.origin;
|
||||||
|
} catch (e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
var WS_BASE = BASE.replace(/^http/, "ws");
|
||||||
|
var STORE = "gogo_chat_" + KEY;
|
||||||
|
|
||||||
|
/* ---------- styles ---------- */
|
||||||
|
var css =
|
||||||
|
".gogo-bubble{position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;" +
|
||||||
|
"background:#7c3aed;color:#fff;border:none;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.25);" +
|
||||||
|
"font-size:26px;z-index:999999;display:flex;align-items:center;justify-content:center}" +
|
||||||
|
".gogo-panel{position:fixed;bottom:88px;right:20px;width:340px;max-width:calc(100vw - 32px);" +
|
||||||
|
"height:480px;max-height:calc(100vh - 120px);background:#fff;border-radius:14px;z-index:999999;" +
|
||||||
|
"box-shadow:0 8px 30px rgba(0,0,0,.3);display:none;flex-direction:column;overflow:hidden;" +
|
||||||
|
"font-family:system-ui,sans-serif}" +
|
||||||
|
".gogo-panel.open{display:flex}" +
|
||||||
|
".gogo-head{background:#7c3aed;color:#fff;padding:12px 16px;font-weight:600;font-size:15px}" +
|
||||||
|
".gogo-head small{display:block;font-weight:400;opacity:.85;font-size:12px}" +
|
||||||
|
".gogo-msgs{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px;background:#f7f7fa}" +
|
||||||
|
".gogo-msg{max-width:82%;padding:8px 12px;border-radius:12px;font-size:14px;line-height:1.45;" +
|
||||||
|
"white-space:pre-wrap;word-wrap:break-word}" +
|
||||||
|
".gogo-msg.bot{background:#fff;border:1px solid #e5e5ec;align-self:flex-start;border-bottom-left-radius:4px}" +
|
||||||
|
".gogo-msg.user{background:#7c3aed;color:#fff;align-self:flex-end;border-bottom-right-radius:4px}" +
|
||||||
|
".gogo-msg.typing{color:#888;font-style:italic;background:#fff;border:1px dashed #ddd}" +
|
||||||
|
".gogo-form{display:flex;border-top:1px solid #e5e5ec;background:#fff}" +
|
||||||
|
".gogo-form input[type=text]{flex:1;border:0;padding:12px 14px;font-size:14px;outline:none}" +
|
||||||
|
".gogo-form button{border:0;background:none;color:#7c3aed;font-weight:700;padding:0 16px;cursor:pointer;font-size:14px}" +
|
||||||
|
".gogo-hp{position:absolute;left:-9999px;opacity:0;height:0;width:0}";
|
||||||
|
|
||||||
|
var style = document.createElement("style");
|
||||||
|
style.textContent = css;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
/* ---------- DOM ---------- */
|
||||||
|
var bubble = document.createElement("button");
|
||||||
|
bubble.className = "gogo-bubble";
|
||||||
|
bubble.setAttribute("aria-label", "Otvori chat");
|
||||||
|
bubble.textContent = "💬";
|
||||||
|
|
||||||
|
var panel = document.createElement("div");
|
||||||
|
panel.className = "gogo-panel";
|
||||||
|
panel.innerHTML =
|
||||||
|
'<div class="gogo-head">Virtuelni asistent<small>Gogo Telefon — odgovaramo odmah</small></div>' +
|
||||||
|
'<div class="gogo-msgs"></div>' +
|
||||||
|
'<form class="gogo-form">' +
|
||||||
|
'<input type="text" placeholder="Upišite poruku…" maxlength="500" autocomplete="off">' +
|
||||||
|
'<input type="text" class="gogo-hp" name="website" tabindex="-1" autocomplete="off">' +
|
||||||
|
"<button type=\"submit\">Pošalji</button></form>";
|
||||||
|
|
||||||
|
document.body.appendChild(bubble);
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
var msgs = panel.querySelector(".gogo-msgs");
|
||||||
|
var form = panel.querySelector(".gogo-form");
|
||||||
|
var input = form.querySelector('input[type=text]:not(.gogo-hp)');
|
||||||
|
var honeypot = form.querySelector(".gogo-hp");
|
||||||
|
|
||||||
|
function addMsg(text, who) {
|
||||||
|
var el = document.createElement("div");
|
||||||
|
el.className = "gogo-msg " + who;
|
||||||
|
el.textContent = text;
|
||||||
|
msgs.appendChild(el);
|
||||||
|
msgs.scrollTop = msgs.scrollHeight;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
var typingEl = null;
|
||||||
|
function showTyping() {
|
||||||
|
if (typingEl) return;
|
||||||
|
typingEl = addMsg("Gogo piše…", "bot typing");
|
||||||
|
}
|
||||||
|
function hideTyping() {
|
||||||
|
if (typingEl) { typingEl.remove(); typingEl = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- WebSocket ---------- */
|
||||||
|
var ws = null;
|
||||||
|
var connected = false;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (ws) return;
|
||||||
|
var resume = "";
|
||||||
|
try { resume = localStorage.getItem(STORE) || ""; } catch (e) {}
|
||||||
|
ws = new WebSocket(
|
||||||
|
WS_BASE + "/widget/ws?key=" + encodeURIComponent(KEY) +
|
||||||
|
(resume ? "&resume=" + encodeURIComponent(resume) : "")
|
||||||
|
);
|
||||||
|
ws.onopen = function () { connected = true; };
|
||||||
|
ws.onmessage = function (ev) {
|
||||||
|
var data;
|
||||||
|
try { data = JSON.parse(ev.data); } catch (e) { return; }
|
||||||
|
if (data.session) {
|
||||||
|
try { localStorage.setItem(STORE, data.session); } catch (e) {}
|
||||||
|
}
|
||||||
|
if (data.type === "greeting") {
|
||||||
|
addMsg(data.text, "bot");
|
||||||
|
} else if (data.type === "resume") {
|
||||||
|
msgs.innerHTML = "";
|
||||||
|
(data.messages || []).forEach(function (m) {
|
||||||
|
addMsg(m.text, m.role === "user" ? "user" : "bot");
|
||||||
|
});
|
||||||
|
} else if (data.type === "typing") {
|
||||||
|
showTyping();
|
||||||
|
} else if (data.type === "message") {
|
||||||
|
hideTyping();
|
||||||
|
addMsg(data.text, "bot");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = function (ev) {
|
||||||
|
connected = false;
|
||||||
|
ws = null;
|
||||||
|
hideTyping();
|
||||||
|
if (ev.code === 4429) {
|
||||||
|
addMsg("Previše pokušaja — molimo pokušajte kasnije ili pozovite salon.", "bot");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bubble.addEventListener("click", function () {
|
||||||
|
panel.classList.toggle("open");
|
||||||
|
if (panel.classList.contains("open")) {
|
||||||
|
connect();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var text = input.value.trim();
|
||||||
|
if (!text || !ws || !connected) return;
|
||||||
|
addMsg(text, "user");
|
||||||
|
ws.send(JSON.stringify({ type: "message", text: text, website: honeypot.value }));
|
||||||
|
input.value = "";
|
||||||
|
});
|
||||||
|
})();
|
||||||
0
gogo/telephony/__init__.py
Normal file
0
gogo/telephony/__init__.py
Normal file
47
gogo/telephony/forwarding.py
Normal file
47
gogo/telephony/forwarding.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Per-operator conditional call forwarding codes (§5.1, M5 write-only).
|
||||||
|
|
||||||
|
BiH mobile operators use standard GSM MMI codes. The dashboard shows these for
|
||||||
|
the salon's assigned Gogo number; the owner dials them once on their phone.
|
||||||
|
Live verification with each operator happens in the joint hardware session
|
||||||
|
(HARDWARE_TESTING.md).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
OPERATORS = {
|
||||||
|
"mtel": "m:tel",
|
||||||
|
"bhtelecom": "BH Telecom",
|
||||||
|
"eronet": "HT Eronet",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ForwardingCodes:
|
||||||
|
operator: str
|
||||||
|
operator_label: str
|
||||||
|
activate_no_answer: str
|
||||||
|
activate_busy: str
|
||||||
|
activate_unreachable: str
|
||||||
|
deactivate_all: str
|
||||||
|
check_status: str
|
||||||
|
|
||||||
|
|
||||||
|
def forwarding_codes(operator: str, gogo_number: str, no_answer_seconds: int = 15) -> ForwardingCodes:
|
||||||
|
"""Standard GSM MMI codes (same across BiH operators; kept per-operator so any
|
||||||
|
operator-specific quirks found during live testing can be encoded here)."""
|
||||||
|
n = gogo_number.replace(" ", "")
|
||||||
|
if operator not in OPERATORS:
|
||||||
|
raise ValueError(f"unknown operator: {operator}")
|
||||||
|
# round to the nearest supported no-answer delay (5..30 in steps of 5)
|
||||||
|
delay = min(30, max(5, 5 * round(no_answer_seconds / 5)))
|
||||||
|
return ForwardingCodes(
|
||||||
|
operator=operator,
|
||||||
|
operator_label=OPERATORS[operator],
|
||||||
|
activate_no_answer=f"**61*{n}**{delay}#",
|
||||||
|
activate_busy=f"**67*{n}#",
|
||||||
|
activate_unreachable=f"**62*{n}#",
|
||||||
|
deactivate_all="##002#",
|
||||||
|
check_status="*#61#",
|
||||||
|
)
|
||||||
53
gogo/telephony/sip.py
Normal file
53
gogo/telephony/sip.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""SIP credential provisioning for ring-group workers (§5.3).
|
||||||
|
|
||||||
|
Workers exist only as SIP endpoints — no product accounts. The dashboard shows
|
||||||
|
generated credentials + a QR code for quick softphone setup (Linphone/Zoiper/
|
||||||
|
Groundwire all accept manual entry; the QR encodes a standard sip URI plus the
|
||||||
|
fields spelled out).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from gogo.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def make_sip_username(tenant_slug: str, worker_name: str, suffix: str | None = None) -> str:
|
||||||
|
base = re.sub(r"[^a-z0-9]+", "", worker_name.lower())[:12] or "radnik"
|
||||||
|
suffix = suffix or secrets.token_hex(2)
|
||||||
|
return f"{tenant_slug[:16]}-{base}-{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def make_sip_password() -> str:
|
||||||
|
return secrets.token_urlsafe(12)
|
||||||
|
|
||||||
|
|
||||||
|
def sip_domain() -> str:
|
||||||
|
"""SIP registrar host — derived from base_url; overridable later if split hosts."""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
return urlparse(get_settings().base_url).hostname or "localhost"
|
||||||
|
|
||||||
|
|
||||||
|
def provisioning_text(sip_username: str, sip_password: str) -> str:
|
||||||
|
"""Payload for the QR code: sip URI + explicit fields (readable by humans too)."""
|
||||||
|
domain = sip_domain()
|
||||||
|
return (
|
||||||
|
f"sip:{sip_username}@{domain}\n"
|
||||||
|
f"Server/Domain: {domain}\n"
|
||||||
|
f"Username: {sip_username}\n"
|
||||||
|
f"Password: {sip_password}\n"
|
||||||
|
f"Transport: UDP 5060"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def qr_png(payload: str) -> bytes:
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
img = qrcode.make(payload)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
12
gogo/templates/admin/_nav.html
Normal file
12
gogo/templates/admin/_nav.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<header>
|
||||||
|
<div class="logo">Gogo <span>Admin</span></div>
|
||||||
|
<nav>
|
||||||
|
<a href="/admin" class="{{ 'active' if active=='tenants' }}">Saloni</a>
|
||||||
|
<a href="/admin/numbers" class="{{ 'active' if active=='numbers' }}">Brojevi/SIM</a>
|
||||||
|
<a href="/admin/templates" class="{{ 'active' if active=='templates' }}">Prompt šablon</a>
|
||||||
|
<a href="/admin/playground" class="{{ 'active' if active=='playground' }}">Playground</a>
|
||||||
|
<a href="/admin/usage" class="{{ 'active' if active=='usage' }}">Potrošnja</a>
|
||||||
|
<a href="/admin/health" class="{{ 'active' if active=='health' }}">Sistem</a>
|
||||||
|
</nav>
|
||||||
|
<div class="right">{{ admin.email }} · <a href="/admin/logout">Odjava</a></div>
|
||||||
|
</header>
|
||||||
37
gogo/templates/admin/health.html
Normal file
37
gogo/templates/admin/health.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Sistem — Gogo Admin{% endblock %}
|
||||||
|
{% block nav %}{% include "admin/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Zdravlje sistema (§11)</h1>
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Servisi</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>Baza podataka</td><td><span class="badge {{ 'confirmed' if db_ok else 'rejected' }}">{{ 'OK' if db_ok else 'GREŠKA' }}</span></td></tr>
|
||||||
|
<tr><td>LLM API ključ</td><td><span class="badge {{ 'confirmed' if llm_configured else 'pending' }}">{{ 'postavljen' if llm_configured else 'nije postavljen' }}</span></td></tr>
|
||||||
|
<tr><td>Email (SMTP)</td><td><span class="badge {{ 'confirmed' if email_mode=='smtp' else 'pending' }}">{{ email_mode }}</span></td></tr>
|
||||||
|
<tr><td>SMS provajder</td><td><span class="badge {{ 'confirmed' if sms_mode=='gsm_gateway' else 'pending' }}">{{ sms_mode }}</span></td></tr>
|
||||||
|
<tr><td>Voice pipeline</td><td><span class="badge {{ voice_badge }}">{{ voice_status }}</span></td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Greške u slanju (zadnjih 7 dana)</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>Email greške</td><td>{{ email_errors }}</td></tr>
|
||||||
|
<tr><td>SMS greške</td><td>{{ sms_errors }}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if failed_rows %}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Zadnje greške</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Vrijeme</th><th>Tip</th><th>Prima</th><th>Greška</th></tr>
|
||||||
|
{% for f in failed_rows %}
|
||||||
|
<tr><td class="muted">{{ f.at.strftime('%d.%m. %H:%M') }}</td><td>{{ f.kind }}</td>
|
||||||
|
<td>{{ f.to }}</td><td class="muted">{{ f.error[:120] }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
16
gogo/templates/admin/login.html
Normal file
16
gogo/templates/admin/login.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Admin prijava — Gogo Telefon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width:380px;margin:60px auto">
|
||||||
|
<div class="card">
|
||||||
|
<h1 style="margin-top:0">Gogo <span style="color:var(--brand)">Admin</span></h1>
|
||||||
|
<form method="post" action="/admin/login">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" name="email" required autofocus>
|
||||||
|
<label>Lozinka</label>
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
<div style="margin-top:16px"><button style="width:100%">Prijavi se</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
48
gogo/templates/admin/numbers.html
Normal file
48
gogo/templates/admin/numbers.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Brojevi/SIM — Gogo Admin{% endblock %}
|
||||||
|
{% block nav %}{% include "admin/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Brojevi i SIM kartice (GSM gateway)</h1>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<tr><th>Broj</th><th>Gateway port</th><th>Operater</th><th>SIM status</th><th>Salon</th><th></th></tr>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<form method="post" action="/admin/numbers/{{ row.n.id }}">
|
||||||
|
<td><code>{{ row.n.msisdn }}</code></td>
|
||||||
|
<td style="width:110px"><input type="number" name="gateway_port" value="{{ row.n.gateway_port if row.n.gateway_port is not none }}"></td>
|
||||||
|
<td style="width:140px">
|
||||||
|
<select name="operator">
|
||||||
|
<option value="">?</option>
|
||||||
|
{% for op, label in operators.items() %}
|
||||||
|
<option value="{{ op }}" {{ 'selected' if row.n.operator==op }}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td style="width:140px">
|
||||||
|
<select name="sim_status">
|
||||||
|
{% for s in ['unassigned','active','blocked','testing'] %}
|
||||||
|
<option value="{{ s }}" {{ 'selected' if row.n.sim_status==s }}>{{ s }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.tenant_name or '—' }}</td>
|
||||||
|
<td style="white-space:nowrap"><button class="small">Sačuvaj</button></form>
|
||||||
|
<form method="post" action="/admin/numbers/{{ row.n.id }}/delete" style="display:inline"
|
||||||
|
onsubmit="return confirm('Obrisati broj?')"><button class="small red">Obriši</button></form></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr>
|
||||||
|
<form method="post" action="/admin/numbers">
|
||||||
|
<td><input type="text" name="msisdn" placeholder="+38765XXXXXX" required></td>
|
||||||
|
<td><input type="number" name="gateway_port" placeholder="1"></td>
|
||||||
|
<td><select name="operator"><option value="">?</option>
|
||||||
|
{% for op, label in operators.items() %}<option value="{{ op }}">{{ label }}</option>{% endfor %}
|
||||||
|
</select></td>
|
||||||
|
<td></td><td></td>
|
||||||
|
<td><button class="small green">Dodaj</button></td>
|
||||||
|
</form>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
51
gogo/templates/admin/playground.html
Normal file
51
gogo/templates/admin/playground.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Playground — Gogo Admin{% endblock %}
|
||||||
|
{% block nav %}{% include "admin/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Test playground (§11)</h1>
|
||||||
|
<p class="muted">Tekstualni razgovor s asistentom pojedinog salona — koristi njegov sastavljeni
|
||||||
|
prompt, usluge i provajdera termina. Zahtjevi kreirani ovdje se <b>zaista</b> šalju (koristite
|
||||||
|
demo salon za igranje).</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form method="post" action="/admin/playground">
|
||||||
|
<label>Salon</label>
|
||||||
|
<select name="tenant_id" {{ 'disabled' if history_json != '[]' }}>
|
||||||
|
{% for tn in tenants %}
|
||||||
|
<option value="{{ tn.id }}" {{ 'selected' if selected_tenant and tn.id==selected_tenant.id }}>{{ tn.name }} ({{ tn.scheduling_provider }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if history_json != '[]' %}<input type="hidden" name="tenant_id" value="{{ selected_tenant.id }}">{% endif %}
|
||||||
|
<label>Kanal</label>
|
||||||
|
<select name="channel">
|
||||||
|
<option value="voice" {{ 'selected' if channel=='voice' }}>voice (telefon)</option>
|
||||||
|
<option value="chat" {{ 'selected' if channel=='chat' }}>chat</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{% if transcript %}
|
||||||
|
<div class="transcript" style="margin:16px 0">
|
||||||
|
{% for t in transcript %}
|
||||||
|
<div class="t {{ t.role }}">{{ t.text }}</div>
|
||||||
|
{% if t.tool_calls %}<div class="tools">⚙ {% for tc in t.tool_calls %}{{ tc.name }}({{ tc.input | tojson }}) {% endfor %}</div>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<input type="hidden" name="history" value="{{ history_json | e }}">
|
||||||
|
<label>Poruka klijenta</label>
|
||||||
|
<input type="text" name="message" placeholder="npr. Htjela bih zakazati šišanje u srijedu" autofocus>
|
||||||
|
<div style="margin-top:12px">
|
||||||
|
<button>Pošalji</button>
|
||||||
|
<a class="btn ghost" href="/admin/playground">Novi razgovor</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if error %}<div class="msg error" style="margin-top:12px">{{ error }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if system_prompt %}
|
||||||
|
<details class="card">
|
||||||
|
<summary style="cursor:pointer;font-weight:600">Sastavljeni system prompt</summary>
|
||||||
|
<pre style="white-space:pre-wrap">{{ system_prompt }}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
46
gogo/templates/admin/templates.html
Normal file
46
gogo/templates/admin/templates.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Prompt šablon — Gogo Admin{% endblock %}
|
||||||
|
{% block nav %}{% include "admin/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Globalni prompt šablon (§6.3)</h1>
|
||||||
|
<p class="muted">Šablon vrijedi za sve salone odjednom. Dostupne oznake:
|
||||||
|
<code>{salon_profile}</code> <code>{working_hours}</code> <code>{services_table}</code>
|
||||||
|
<code>{notes}</code> <code>{price_mode}</code> <code>{greeting}</code>
|
||||||
|
<code>{current_datetime}</code></p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Nova verzija</h2>
|
||||||
|
<form method="post" action="/admin/templates">
|
||||||
|
<textarea name="body" style="min-height:340px;font-family:monospace">{{ draft_body }}</textarea>
|
||||||
|
<label>Bilješka o izmjeni</label>
|
||||||
|
<input type="text" name="notes" placeholder="npr. dodano pravilo o kućnim posjetama">
|
||||||
|
<label><input type="checkbox" name="publish" checked style="width:auto"> Objavi odmah (postaje aktivna verzija)</label>
|
||||||
|
<button style="margin-top:12px">Sačuvaj verziju</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Verzije</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Verzija</th><th>Datum</th><th>Bilješka</th><th>Status</th><th></th></tr>
|
||||||
|
{% for v in versions %}
|
||||||
|
<tr>
|
||||||
|
<td>v{{ v.version }}</td>
|
||||||
|
<td class="muted">{{ v.created_at.strftime('%d.%m.%Y. %H:%M') }}</td>
|
||||||
|
<td>{{ v.notes }}</td>
|
||||||
|
<td>{% if v.published %}<span class="badge confirmed">aktivna</span>{% endif %}</td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<a class="btn small ghost" href="/admin/templates?load={{ v.version }}">Učitaj u editor</a>
|
||||||
|
{% if not v.published %}
|
||||||
|
<form method="post" action="/admin/templates/{{ v.version }}/publish" style="display:inline">
|
||||||
|
<button class="small green">Objavi (rollback)</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="muted">Nema sačuvanih verzija — koristi se ugrađeni zadani šablon.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
109
gogo/templates/admin/tenant_edit.html
Normal file
109
gogo/templates/admin/tenant_edit.html
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ t.name if t else 'Novi salon' }} — Gogo Admin{% endblock %}
|
||||||
|
{% block nav %}{% include "admin/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="/admin">← Svi saloni</a></p>
|
||||||
|
<h1>{{ t.name if t else 'Novi salon' }}</h1>
|
||||||
|
|
||||||
|
<form method="post" action="{{ '/admin/tenants/' + (t.id|string) if t else '/admin/tenants/new' }}">
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Osnovno</h2>
|
||||||
|
<label>Naziv</label><input type="text" name="name" value="{{ t.name if t }}" required>
|
||||||
|
<label>Slug <small>(kratki id, bez razmaka)</small></label>
|
||||||
|
<input type="text" name="slug" value="{{ t.slug if t }}" {{ 'readonly' if t }} required>
|
||||||
|
<label>Grad</label><input type="text" name="city" value="{{ t.city if t }}">
|
||||||
|
<label>Status</label>
|
||||||
|
<select name="status">
|
||||||
|
<option value="active" {{ 'selected' if t and t.status=='active' }}>aktivan</option>
|
||||||
|
<option value="disabled" {{ 'selected' if t and t.status=='disabled' }}>isključen</option>
|
||||||
|
</select>
|
||||||
|
{% if not t %}
|
||||||
|
<label>Email vlasnika <small>(kreira se nalog)</small></label>
|
||||||
|
<input type="email" name="owner_email" required>
|
||||||
|
<label>Lozinka vlasnika</label>
|
||||||
|
<input type="text" name="owner_password" required>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Plan i naplata (§12)</h2>
|
||||||
|
<label>Plan</label>
|
||||||
|
<input type="text" name="plan" value="{{ t.plan if t else 'gogo_start' }}">
|
||||||
|
<label>Uključene minute mjesečno</label>
|
||||||
|
<input type="number" name="included_minutes" value="{{ t.included_minutes if t else 300 }}">
|
||||||
|
<label>Plaćeno do <small>(YYYY-MM-DD, prazno = nije plaćeno)</small></label>
|
||||||
|
<input type="text" name="paid_until" value="{{ t.paid_until.strftime('%Y-%m-%d') if t and t.paid_until }}">
|
||||||
|
<label><input type="checkbox" name="hard_cutoff" {{ 'checked' if t and t.hard_cutoff }} style="width:auto">
|
||||||
|
Tvrdi prekid na 100% minuta (zadano: isključeno — asistent uvijek odgovara)</label>
|
||||||
|
<label>LLM model <small>(prazno = globalno zadani)</small></label>
|
||||||
|
<input type="text" name="llm_model" value="{{ t.llm_model if t }}">
|
||||||
|
<label>TTL prijedloga (sati)</label>
|
||||||
|
<input type="number" name="proposal_ttl_hours" value="{{ t.proposal_ttl_hours if t else 24 }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Provajder termina (§8)</h2>
|
||||||
|
<label>Tip</label>
|
||||||
|
<select name="scheduling_provider">
|
||||||
|
<option value="google_calendar" {{ 'selected' if t and t.scheduling_provider=='google_calendar' }}>google_calendar</option>
|
||||||
|
<option value="partner_api" {{ 'selected' if t and t.scheduling_provider=='partner_api' }}>partner_api</option>
|
||||||
|
<option value="mock" {{ 'selected' if t and t.scheduling_provider=='mock' }}>mock (demo/test)</option>
|
||||||
|
</select>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>Partner: base URL</label>
|
||||||
|
<input type="url" name="partner_base_url" value="{{ pconfig.get('base_url','') }}" placeholder="https://booking.salon.ba/gogo/v1">
|
||||||
|
<label>Partner: API ključ <small>(prazno = ne mijenjaj)</small></label>
|
||||||
|
<input type="text" name="partner_api_key" placeholder="{{ '•••• postavljen' if pconfig.get('api_key_encrypted') else '' }}">
|
||||||
|
<label>Partner: webhook secret <small>(prazno = ne mijenjaj)</small></label>
|
||||||
|
<input type="text" name="partner_webhook_secret" placeholder="{{ '•••• postavljen' if pconfig.get('webhook_secret_encrypted') else '' }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label><input type="checkbox" name="catalog_sync" {{ 'checked' if pconfig.get('catalog_sync') }} style="width:auto"> Sinhronizuj usluge iz partnera (GET /services)</label>
|
||||||
|
<label><input type="checkbox" name="email_to_owner" {{ 'checked' if pconfig.get('email_to_owner') }} style="width:auto"> Šalji i email vlasniku (uz push u partnera)</label>
|
||||||
|
<label><input type="checkbox" name="polling_fallback" {{ 'checked' if pconfig.get('polling_fallback') }} style="width:auto"> Polling fallback (partner nema webhook)</label>
|
||||||
|
{% if t %}
|
||||||
|
<label>Webhook URL za partnera</label>
|
||||||
|
<code style="display:block;padding:8px">{{ base_url }}/webhooks/partner/{{ t.id }}</code>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Broj / SIM</h2>
|
||||||
|
<label>Dodijeljeni broj</label>
|
||||||
|
<select name="phone_number_id">
|
||||||
|
<option value="">— bez broja —</option>
|
||||||
|
{% for n in numbers %}
|
||||||
|
<option value="{{ n.id }}" {{ 'selected' if n.tenant_id == (t.id if t) }}>
|
||||||
|
{{ n.msisdn }} (port {{ n.gateway_port if n.gateway_port is not none else '?' }}, {{ n.operator or '?' }})
|
||||||
|
{%- if n.tenant_id and (not t or n.tenant_id != t.id) %} — ZAUZET{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="muted">Nove brojeve dodajete pod <a href="/admin/numbers">Brojevi/SIM</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Prompt override <small style="font-weight:400">(samo za neobične salone — inače prazno)</small></h2>
|
||||||
|
<textarea name="prompt_override" style="min-height:120px;font-family:monospace"
|
||||||
|
placeholder="Prazno = koristi globalni šablon">{{ override_body }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button>Sačuvaj</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if t and t.scheduling_provider == 'partner_api' %}
|
||||||
|
<div class="card" style="margin-top:20px">
|
||||||
|
<h2>Test veze s partnerom (§8.3)</h2>
|
||||||
|
<p class="muted">Pokreće živi <code>GET /availability</code> i dry-run
|
||||||
|
<code>POST /booking-requests</code>, prikazuje sirove odgovore.</p>
|
||||||
|
<form method="post" action="/admin/tenants/{{ t.id }}/test-connection">
|
||||||
|
<button class="ghost">Pokreni test</button>
|
||||||
|
</form>
|
||||||
|
{% if test_result %}<pre>{{ test_result }}</pre>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
27
gogo/templates/admin/tenants.html
Normal file
27
gogo/templates/admin/tenants.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Saloni — Gogo Admin{% endblock %}
|
||||||
|
{% block nav %}{% include "admin/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Saloni <a class="btn small" style="margin-left:12px" href="/admin/tenants/new">+ Novi salon</a></h1>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<tr><th>Salon</th><th>Provajder</th><th>Broj</th><th>Plaćeno do</th><th>Minute (mj)</th><th>Status</th><th></th></tr>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td><b>{{ row.tenant.name }}</b><br><span class="muted">{{ row.tenant.city }} · {{ row.tenant.slug }}</span></td>
|
||||||
|
<td>{{ row.tenant.scheduling_provider }}</td>
|
||||||
|
<td>{{ row.msisdn or '—' }}</td>
|
||||||
|
<td>{{ row.tenant.paid_until.strftime('%d.%m.%Y.') if row.tenant.paid_until else '—' }}</td>
|
||||||
|
<td>{{ row.used_minutes }}/{{ row.tenant.included_minutes }}</td>
|
||||||
|
<td><span class="badge {{ 'confirmed' if row.tenant.status=='active' else 'rejected' }}">{{ row.tenant.status }}</span></td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<a class="btn small ghost" href="/admin/tenants/{{ row.tenant.id }}">Uredi</a>
|
||||||
|
<form method="post" action="/admin/impersonate/{{ row.tenant.id }}" style="display:inline">
|
||||||
|
<button class="small">Otvori kao salon</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
26
gogo/templates/admin/usage.html
Normal file
26
gogo/templates/admin/usage.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Potrošnja — Gogo Admin{% endblock %}
|
||||||
|
{% block nav %}{% include "admin/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Potrošnja po salonima — {{ month }}</h1>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<tr><th>Salon</th><th>Minute asistenta</th><th>Pozivi</th><th>SMS</th><th>Chat</th>
|
||||||
|
<th>Zahtjevi (mj)</th><th>Procj. trošak (LLM+TTS)</th></tr>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr>
|
||||||
|
<td><b>{{ r.tenant.name }}</b></td>
|
||||||
|
<td>{{ r.used_minutes }}/{{ r.tenant.included_minutes }}
|
||||||
|
{% if r.over_100 %}<span class="badge rejected">100%+</span>
|
||||||
|
{% elif r.over_80 %}<span class="badge pending">80%+</span>{% endif %}</td>
|
||||||
|
<td>{{ r.calls }}</td>
|
||||||
|
<td>{{ r.sms }}</td>
|
||||||
|
<td>{{ r.chats }}</td>
|
||||||
|
<td>{{ r.requests }}</td>
|
||||||
|
<td>~{{ r.est_cost_km }} KM</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<p class="muted">Procjena troška: {{ cost_note }}</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
73
gogo/templates/base.html
Normal file
73
gogo/templates/base.html
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="bs">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Gogo Telefon{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
:root{--brand:#7c3aed;--brand-dark:#6d28d9;--ok:#16a34a;--warn:#d97706;--err:#dc2626;
|
||||||
|
--bg:#f4f4f5;--card:#fff;--line:#e4e4e7;--muted:#71717a;--text:#18181b}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{font-family:system-ui,sans-serif;margin:0;background:var(--bg);color:var(--text)}
|
||||||
|
a{color:var(--brand)}
|
||||||
|
header{background:#1e1b2e;color:#fff;padding:0 20px;display:flex;align-items:center;gap:24px;flex-wrap:wrap}
|
||||||
|
header .logo{font-weight:800;font-size:17px;padding:14px 0}
|
||||||
|
header .logo span{color:#a78bfa}
|
||||||
|
header nav{display:flex;gap:2px;flex-wrap:wrap}
|
||||||
|
header nav a{color:#c4b5fd;text-decoration:none;padding:16px 12px;font-size:14px}
|
||||||
|
header nav a.active,header nav a:hover{color:#fff;border-bottom:3px solid var(--brand)}
|
||||||
|
header .right{margin-left:auto;font-size:13px;color:#a1a1aa;padding:14px 0}
|
||||||
|
header .right a{color:#a1a1aa}
|
||||||
|
main{max-width:1080px;margin:24px auto;padding:0 16px}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:20px 24px;margin-bottom:20px}
|
||||||
|
.card h2{margin-top:0;font-size:17px}
|
||||||
|
h1{font-size:22px}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:14px}
|
||||||
|
th{color:var(--muted);text-align:left;font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.03em}
|
||||||
|
th,td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||||
|
tr:last-child td{border-bottom:0}
|
||||||
|
input[type=text],input[type=email],input[type=password],input[type=number],input[type=time],input[type=url],select,textarea{
|
||||||
|
width:100%;padding:8px 10px;border:1px solid #d4d4d8;border-radius:8px;font:inherit;background:#fff}
|
||||||
|
textarea{min-height:90px}
|
||||||
|
label{display:block;font-size:13px;font-weight:600;margin:12px 0 4px}
|
||||||
|
label small{font-weight:400;color:var(--muted)}
|
||||||
|
button,.btn{background:var(--brand);color:#fff;border:0;border-radius:8px;padding:9px 16px;
|
||||||
|
font:inherit;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block;font-size:14px}
|
||||||
|
button:hover,.btn:hover{background:var(--brand-dark)}
|
||||||
|
button.green,.btn.green{background:var(--ok)} button.red,.btn.red{background:var(--err)}
|
||||||
|
button.ghost,.btn.ghost{background:transparent;color:var(--brand);border:1px solid var(--brand)}
|
||||||
|
button.small,.btn.small{padding:5px 10px;font-size:13px;font-weight:500}
|
||||||
|
.badge{display:inline-block;border-radius:20px;padding:2px 10px;font-size:12px;font-weight:600}
|
||||||
|
.badge.pending{background:#fef3c7;color:#92400e}
|
||||||
|
.badge.resolved_by_owner,.badge.confirmed,.badge.human_answered,.badge.request_created{background:#dcfce7;color:#166534}
|
||||||
|
.badge.rejected,.badge.expired,.badge.abandoned{background:#fee2e2;color:#991b1b}
|
||||||
|
.badge.info_only,.badge.message_taken{background:#e0e7ff;color:#3730a3}
|
||||||
|
.msg{background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46;border-radius:8px;padding:10px 14px;margin-bottom:16px}
|
||||||
|
.msg.error{background:#fef2f2;border-color:#fecaca;color:#991b1b}
|
||||||
|
.banner{background:#fffbeb;border:1px solid #fde68a;color:#92400e;border-radius:8px;padding:10px 14px;margin-bottom:16px}
|
||||||
|
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
|
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||||||
|
@media(max-width:720px){.grid2,.grid3{grid-template-columns:1fr}}
|
||||||
|
.muted{color:var(--muted);font-size:13px}
|
||||||
|
.bar{background:var(--line);border-radius:10px;height:14px;overflow:hidden}
|
||||||
|
.bar>div{height:100%;background:var(--brand)}
|
||||||
|
.bar>div.warn{background:var(--warn)} .bar>div.over{background:var(--err)}
|
||||||
|
code,pre{background:#f4f4f5;border:1px solid var(--line);border-radius:6px;font-size:13px}
|
||||||
|
pre{padding:12px;overflow-x:auto}
|
||||||
|
code{padding:1px 6px}
|
||||||
|
.transcript{display:flex;flex-direction:column;gap:8px}
|
||||||
|
.transcript .t{max-width:75%;padding:8px 12px;border-radius:12px;font-size:14px;white-space:pre-wrap}
|
||||||
|
.transcript .t.assistant{background:#ede9fe;align-self:flex-start}
|
||||||
|
.transcript .t.user{background:#e4e4e7;align-self:flex-end}
|
||||||
|
.transcript .tools{font-size:12px;color:var(--muted);align-self:flex-start}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block nav %}{% endblock %}
|
||||||
|
<main>
|
||||||
|
{% if request.query_params.get('msg') %}<div class="msg">{{ request.query_params.get('msg') }}</div>{% endif %}
|
||||||
|
{% if request.query_params.get('err') %}<div class="msg error">{{ request.query_params.get('err') }}</div>{% endif %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
gogo/templates/dashboard/_nav.html
Normal file
19
gogo/templates/dashboard/_nav.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<header>
|
||||||
|
<div class="logo">Gogo <span>Telefon</span></div>
|
||||||
|
<nav>
|
||||||
|
<a href="/dash" class="{{ 'active' if active=='requests' }}">Zahtjevi</a>
|
||||||
|
<a href="/dash/calls" class="{{ 'active' if active=='calls' }}">Pozivi</a>
|
||||||
|
<a href="/dash/chats" class="{{ 'active' if active=='chats' }}">Chat</a>
|
||||||
|
<a href="/dash/usage" class="{{ 'active' if active=='usage' }}">Potrošnja</a>
|
||||||
|
<a href="/dash/settings" class="{{ 'active' if active=='settings' }}">Salon</a>
|
||||||
|
<a href="/dash/services" class="{{ 'active' if active=='services' }}">Usluge</a>
|
||||||
|
<a href="/dash/scheduling" class="{{ 'active' if active=='scheduling' }}">Kalendar</a>
|
||||||
|
<a href="/dash/telephony" class="{{ 'active' if active=='telephony' }}">Telefonija</a>
|
||||||
|
<a href="/dash/agent" class="{{ 'active' if active=='agent' }}">Asistent</a>
|
||||||
|
<a href="/dash/notifications" class="{{ 'active' if active=='notifications' }}">Obavještenja</a>
|
||||||
|
</nav>
|
||||||
|
<div class="right">
|
||||||
|
{{ owner.tenant.name }}{% if owner.impersonated_by %} <b>(admin pregled)</b>{% endif %}
|
||||||
|
· <a href="/logout">Odjava</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
50
gogo/templates/dashboard/agent.html
Normal file
50
gogo/templates/dashboard/agent.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Asistent — {{ owner.tenant.name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Virtuelni asistent</h1>
|
||||||
|
|
||||||
|
<form method="post" action="/dash/agent">
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Glas i cijene</h2>
|
||||||
|
<label>Glas asistenta</label>
|
||||||
|
<select name="tts_voice">
|
||||||
|
{% for v in voices %}
|
||||||
|
<option value="{{ v.id }}" {{ 'selected' if t.tts_voice==v.id }}>{{ v.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label>Kako asistent odgovara na pitanja o cijenama</label>
|
||||||
|
<select name="price_mode">
|
||||||
|
<option value="exact" {{ 'selected' if t.price_mode=='exact' }}>Kaže tačnu cijenu</option>
|
||||||
|
<option value="range" {{ 'selected' if t.price_mode=='range' }}>Kaže raspon (od–do)</option>
|
||||||
|
<option value="on_request" {{ 'selected' if t.price_mode=='on_request' }}>„Cijene na upit"</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Napomene za asistenta</h2>
|
||||||
|
<p class="muted">Činjenice o salonu koje asistent smije reći klijentima
|
||||||
|
(npr. „parking iza zgrade", „ne primamo djecu ispod 7 godina").</p>
|
||||||
|
<textarea name="agent_notes" style="min-height:120px">{{ t.agent_notes }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button>Sačuvaj</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:20px">
|
||||||
|
<h2>Chat widget za vašu web stranicu</h2>
|
||||||
|
<p class="muted">Zalijepite ovaj red koda pred kraj <code><body></code> taga vaše
|
||||||
|
stranice (ili ga pošaljite onome ko vam održava web):</p>
|
||||||
|
<pre><script src="{{ base_url }}/widget.js" data-gogo-key="{{ t.widget_public_key }}" async></script></pre>
|
||||||
|
<p class="muted">Isprobajte razgovor na <a href="/widget-demo/{{ t.widget_public_key }}" target="_blank">demo stranici</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Pozdravna poruka</h2>
|
||||||
|
<p class="muted">Pozdrav i ponašanje asistenta održava Gogo tim centralno — uvijek uključuje
|
||||||
|
obavještenje o snimanju razgovora. Trenutni pozdrav:</p>
|
||||||
|
<blockquote style="border-left:3px solid var(--brand);margin:8px 0;padding:4px 14px;color:var(--muted)">
|
||||||
|
{{ greeting }}
|
||||||
|
</blockquote>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
27
gogo/templates/dashboard/call_detail.html
Normal file
27
gogo/templates/dashboard/call_detail.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Poziv — Gogo Telefon{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="/dash/calls">← Svi pozivi</a></p>
|
||||||
|
<h1>Poziv {{ call.started_at.strftime('%d.%m.%Y. %H:%M') }}
|
||||||
|
<span class="badge {{ call.outcome }}">{{ outcome_labels.get(call.outcome, call.outcome) }}</span></h1>
|
||||||
|
<div class="card">
|
||||||
|
<p><b>Broj:</b> {{ call.caller_msisdn or '—' }} ·
|
||||||
|
<b>Trajanje:</b> {{ call.duration_s }}s ·
|
||||||
|
<b>Naplaćene minute asistenta:</b> {{ (call.agent_seconds_charged / 60) | round(1) }} min</p>
|
||||||
|
{% if call.recording_path %}
|
||||||
|
<audio controls src="/dash/calls/{{ call.id }}/audio" style="width:100%"></audio>
|
||||||
|
{% else %}<p class="muted">Snimka nije dostupna (obrisana nakon isteka perioda čuvanja ili poziv nije snimljen).</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if call.transcript %}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Transkript</h2>
|
||||||
|
<div class="transcript">
|
||||||
|
{% for t in call.transcript %}
|
||||||
|
<div class="t {{ t.role }}">{{ t.text }}</div>
|
||||||
|
{% if t.tool_calls %}<div class="tools">⚙ {% for tc in t.tool_calls %}{{ tc.name }} {% endfor %}</div>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
22
gogo/templates/dashboard/calls.html
Normal file
22
gogo/templates/dashboard/calls.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Pozivi — {{ owner.tenant.name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Historija poziva</h1>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<tr><th>Vrijeme</th><th>Broj</th><th>Trajanje</th><th>Ishod</th><th></th></tr>
|
||||||
|
{% for c in calls %}
|
||||||
|
<tr>
|
||||||
|
<td class="muted">{{ c.started_at.strftime('%d.%m. %H:%M') }}</td>
|
||||||
|
<td>{{ c.caller_msisdn or '—' }}</td>
|
||||||
|
<td>{{ c.duration_s // 60 }}:{{ '%02d' % (c.duration_s % 60) }}</td>
|
||||||
|
<td><span class="badge {{ c.outcome }}">{{ outcome_labels.get(c.outcome, c.outcome) }}</span></td>
|
||||||
|
<td><a class="btn small ghost" href="/dash/calls/{{ c.id }}">Detalji</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="muted">Još nema zabilježenih poziva.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
14
gogo/templates/dashboard/chat_detail.html
Normal file
14
gogo/templates/dashboard/chat_detail.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Chat razgovor — Gogo Telefon{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="/dash/chats">← Svi razgovori</a></p>
|
||||||
|
<h1>Chat {{ chat.started_at.strftime('%d.%m.%Y. %H:%M') }}</h1>
|
||||||
|
<div class="card">
|
||||||
|
<div class="transcript">
|
||||||
|
{% for m in messages %}
|
||||||
|
<div class="t {{ m.role }}">{{ m.content }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
22
gogo/templates/dashboard/chats.html
Normal file
22
gogo/templates/dashboard/chats.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Chat — {{ owner.tenant.name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Chat razgovori</h1>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<tr><th>Vrijeme</th><th>Poruka</th><th>Ishod</th><th></th></tr>
|
||||||
|
{% for row in sessions %}
|
||||||
|
<tr>
|
||||||
|
<td class="muted">{{ row.chat.started_at.strftime('%d.%m. %H:%M') }}</td>
|
||||||
|
<td class="muted">{{ row.preview }}</td>
|
||||||
|
<td>{% if row.chat.outcome %}<span class="badge {{ row.chat.outcome }}">{{ outcome_labels.get(row.chat.outcome, row.chat.outcome) }}</span>{% endif %}</td>
|
||||||
|
<td><a class="btn small ghost" href="/dash/chats/{{ row.chat.id }}">Detalji</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" class="muted">Još nema chat razgovora. Postavite widget na vašu web
|
||||||
|
stranicu — kod se nalazi pod <a href="/dash/agent">Asistent</a>.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
19
gogo/templates/dashboard/login.html
Normal file
19
gogo/templates/dashboard/login.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Prijava — Gogo Telefon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width:380px;margin:60px auto">
|
||||||
|
<div class="card">
|
||||||
|
<h1 style="margin-top:0">Gogo <span style="color:var(--brand)">Telefon</span></h1>
|
||||||
|
<p class="muted">Prijava za vlasnike salona</p>
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" name="email" required autofocus>
|
||||||
|
<label>Lozinka</label>
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
<div style="margin-top:16px"><button style="width:100%">Prijavi se</button></div>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="margin-top:16px">Nemate nalog? Gogo Telefon nalozi se otvaraju
|
||||||
|
uz pomoć našeg tima — javite nam se.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
28
gogo/templates/dashboard/notifications.html
Normal file
28
gogo/templates/dashboard/notifications.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Obavještenja — {{ owner.tenant.name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Obavještenja</h1>
|
||||||
|
<form method="post" action="/dash/notifications">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Email za zahtjeve</h2>
|
||||||
|
<p class="muted">Na ove adrese stižu novi zahtjevi za termine (jedna po redu):</p>
|
||||||
|
<textarea name="notify_emails" placeholder="vlasnica@salon.ba">{{ emails_text }}</textarea>
|
||||||
|
<label style="margin-top:16px"><input type="checkbox" name="sms_request_received"
|
||||||
|
{{ 'checked' if t.sms_request_received }} style="width:auto">
|
||||||
|
Pošalji klijentu SMS „primili smo vaš zahtjev" odmah po zaprimanju</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>SMS poruke klijentima</h2>
|
||||||
|
<p class="muted">Možete prilagoditi tekst poruka. Dostupne oznake:
|
||||||
|
<code>{salon}</code>, <code>{usluga}</code>, <code>{dan}</code>, <code>{datum}</code>,
|
||||||
|
<code>{vrijeme}</code>, <code>{link}</code>. Ostavite prazno za standardni tekst.</p>
|
||||||
|
{% for key, label, default in sms_rows %}
|
||||||
|
<label>{{ label }}</label>
|
||||||
|
<textarea name="sms_{{ key }}" placeholder="{{ default }}">{{ overrides.get(key, '') }}</textarea>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button>Sačuvaj</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
59
gogo/templates/dashboard/request_detail.html
Normal file
59
gogo/templates/dashboard/request_detail.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Zahtjev — {{ req.client_name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="/dash">← Svi zahtjevi</a></p>
|
||||||
|
<h1>Zahtjev — {{ req.client_name }}
|
||||||
|
<span class="badge {{ req.status }}">{{ status_labels[req.status] }}</span></h1>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Podaci</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td class="muted">Klijent</td><td><b>{{ req.client_name }}</b></td></tr>
|
||||||
|
<tr><td class="muted">Telefon</td><td>{{ req.client_phone }}</td></tr>
|
||||||
|
<tr><td class="muted">Usluga</td><td>{{ service_name }}</td></tr>
|
||||||
|
<tr><td class="muted">Izvor</td><td>{{ 'telefonski poziv' if req.source=='voice' else 'web chat' }}</td></tr>
|
||||||
|
{% if req.home_visit %}<tr><td class="muted">Kućna posjeta</td><td>DA — {{ req.address or 'adresa nije navedena' }}</td></tr>{% endif %}
|
||||||
|
{% if req.time_preference_text %}<tr><td class="muted">Željeno vrijeme</td><td>{{ req.time_preference_text }}</td></tr>{% endif %}
|
||||||
|
<tr><td class="muted">Zaprimljen</td><td>{{ req.created_at.strftime('%d.%m.%Y. %H:%M') }}</td></tr>
|
||||||
|
{% if req.resolved_at %}<tr><td class="muted">Zatvoren</td><td>{{ req.resolved_at.strftime('%d.%m.%Y. %H:%M') }}</td></tr>{% endif %}
|
||||||
|
</table>
|
||||||
|
{% if req.summary %}<p><b>Sažetak:</b> {{ req.summary }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Akcije</h2>
|
||||||
|
{% if req.status == 'pending' %}
|
||||||
|
<form method="post" action="/dash/requests/{{ req.id }}/resolve">
|
||||||
|
<button class="green" style="width:100%">✓ Riješeno — kontaktirao/la sam klijenta</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted">Klijentu se ne šalje SMS — dogovorili ste se direktno.</p>
|
||||||
|
{% for s in slots %}
|
||||||
|
<form method="post" action="/dash/requests/{{ req.id }}/confirm/{{ loop.index0 }}" style="margin-top:8px">
|
||||||
|
<button style="width:100%">Potvrdi {{ s }} + pošalji SMS</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
<form method="post" action="/dash/requests/{{ req.id }}/reject" style="margin-top:8px">
|
||||||
|
<textarea name="sms_body" title="SMS koji će klijent dobiti">{{ rejection_sms }}</textarea>
|
||||||
|
<button class="red" style="width:100%;margin-top:8px">Odbij zahtjev i pošalji SMS</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Zahtjev je zatvoren.
|
||||||
|
{% if req.confirmed_slot %}Potvrđen termin: {{ confirmed_label }}.{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if transcript %}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Razgovor</h2>
|
||||||
|
<div class="transcript">
|
||||||
|
{% for t in transcript %}
|
||||||
|
<div class="t {{ t.role }}">{{ t.text }}</div>
|
||||||
|
{% if t.tool_calls %}<div class="tools">⚙ {% for tc in t.tool_calls %}{{ tc.name }} {% endfor %}</div>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
37
gogo/templates/dashboard/requests.html
Normal file
37
gogo/templates/dashboard/requests.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Zahtjevi — {{ owner.tenant.name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Zahtjevi za termine</h1>
|
||||||
|
{% if usage.over_80 %}
|
||||||
|
<div class="banner">Iskorišteno je {{ usage.pct }}% uključenih minuta asistenta ovaj mjesec
|
||||||
|
({{ usage.used_minutes }}/{{ usage.included_minutes }} min).</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<tr><th>Datum</th><th>Klijent</th><th>Usluga</th><th>Termini</th><th>Status</th><th></th></tr>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="muted">{{ r.req.created_at.strftime('%d.%m. %H:%M') }}<br>
|
||||||
|
<span class="badge {{ r.req.source }}">{{ 'poziv' if r.req.source=='voice' else 'chat' }}</span></td>
|
||||||
|
<td><b>{{ r.req.client_name }}</b><br><span class="muted">{{ r.req.client_phone }}</span></td>
|
||||||
|
<td>{{ r.service_name }}{% if r.req.home_visit %}<br><span class="muted">🏠 {{ r.req.address or 'kućna posjeta' }}</span>{% endif %}</td>
|
||||||
|
<td>{% for s in r.slots %}{{ s }}<br>{% endfor %}
|
||||||
|
{% if r.req.time_preference_text and not r.slots %}<span class="muted">{{ r.req.time_preference_text }}</span>{% endif %}</td>
|
||||||
|
<td><span class="badge {{ r.req.status }}">{{ status_labels[r.req.status] }}</span></td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<a class="btn small ghost" href="/dash/requests/{{ r.req.id }}">Detalji</a>
|
||||||
|
{% if r.req.status == 'pending' %}
|
||||||
|
<form method="post" action="/dash/requests/{{ r.req.id }}/resolve" style="display:inline">
|
||||||
|
<button class="small green" title="Kontaktirao/la sam klijenta — zatvori bez SMS-a">Riješeno</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="muted">Još nema zahtjeva. Kad asistent primi zahtjev za termin,
|
||||||
|
pojaviće se ovdje i stići će vam email.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
86
gogo/templates/dashboard/scheduling.html
Normal file
86
gogo/templates/dashboard/scheduling.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Kalendar — {{ owner.tenant.name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Kalendar i termini</h1>
|
||||||
|
|
||||||
|
{% if t.scheduling_provider == 'partner_api' %}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Vaš booking softver</h2>
|
||||||
|
<p>Gogo je povezan direktno s vašim booking softverom — slobodni termini se čitaju iz njega,
|
||||||
|
a zahtjevi stižu u njega. Tu ih vaše osoblje potvrđuje kao i inače.</p>
|
||||||
|
<table>
|
||||||
|
<tr><td class="muted">Status veze</td>
|
||||||
|
<td>{% if partner_ok is none %}<span class="badge pending">nije testirano</span>
|
||||||
|
{% elif partner_ok %}<span class="badge confirmed">povezano</span>
|
||||||
|
{% else %}<span class="badge rejected">greška veze</span>{% endif %}</td></tr>
|
||||||
|
<tr><td class="muted">Zadnja sinhronizacija usluga</td><td>{{ last_sync or '—' }}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="muted">Podešavanje veze vrši Gogo tim — javite nam se ako nešto ne radi.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif t.scheduling_provider == 'google_calendar' %}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Google kalendar</h2>
|
||||||
|
{% if connection %}
|
||||||
|
<p>Povezan nalog: <b>{{ connection.google_account }}</b>
|
||||||
|
<span class="badge confirmed">aktivno</span></p>
|
||||||
|
<p class="muted">Gogo čita samo zauzetost (slobodno/zauzeto) — nikad sadržaj vaših
|
||||||
|
događaja i nikad ne upisuje u kalendar.</p>
|
||||||
|
<form method="post" action="/dash/scheduling/google/disconnect"
|
||||||
|
onsubmit="return confirm('Prekinuti vezu s Google kalendarom?')">
|
||||||
|
<button class="red small">Prekini vezu</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Povežite Google nalog da bi asistent nudio termine samo kad ste zaista
|
||||||
|
slobodni. Gogo traži <b>samo</b> pristup zauzetosti (free/busy) — ne vidi sadržaj vaših
|
||||||
|
događaja i ne može ništa upisivati.</p>
|
||||||
|
{% if google_configured %}
|
||||||
|
<a class="btn" href="/dash/scheduling/google/start">Poveži Google kalendar</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="banner">Google prijava još nije podešena na ovom serveru — javite se Gogo timu.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Kalendari po uslugama</h2>
|
||||||
|
<p class="muted">Ako vodite više kalendara (npr. po radnicama ili vrstama usluga), ovdje
|
||||||
|
odredite koji kalendar vrijedi za koju uslugu. ID kalendara nađete u Google Calendar
|
||||||
|
postavkama („Integrate calendar" → Calendar ID); glavni kalendar je <code>primary</code>.</p>
|
||||||
|
<form method="post" action="/dash/scheduling/mappings">
|
||||||
|
<table>
|
||||||
|
<tr><th>Usluga</th><th>Google Calendar ID</th></tr>
|
||||||
|
<tr><td><i>Zadani kalendar (sve usluge)</i></td>
|
||||||
|
<td><input type="text" name="default" value="{{ mappings.get('default','primary') }}"></td></tr>
|
||||||
|
{% for s in services %}
|
||||||
|
<tr><td>{{ s.name }}</td>
|
||||||
|
<td><input type="text" name="svc_{{ s.id }}" value="{{ mappings.get(s.id|string, '') }}"
|
||||||
|
placeholder="(zadani)"></td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<button style="margin-top:12px">Sačuvaj</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Pravila nuđenja termina</h2>
|
||||||
|
<form method="post" action="/dash/scheduling/buffers">
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>Najranije nuđenje <small>(sati od trenutka poziva)</small></label>
|
||||||
|
<input type="number" name="min_notice_hours" value="{{ t.min_notice_hours }}" min="0" max="72">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Najdalje nuđenje <small>(dana unaprijed)</small></label>
|
||||||
|
<input type="number" name="max_days_ahead" value="{{ t.max_days_ahead }}" min="1" max="60">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button style="margin-top:12px">Sačuvaj</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card"><p class="muted">Demo režim (mock kalendar) — termini se računaju samo iz
|
||||||
|
radnog vremena.</p></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
85
gogo/templates/dashboard/services.html
Normal file
85
gogo/templates/dashboard/services.html
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Usluge — {{ owner.tenant.name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Usluge i cjenovnik</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<tr><th>Usluga</th><th>Trajanje</th><th>Cijena (KM)</th><th>Kućna posjeta</th><th>Napomena za asistenta</th><th></th></tr>
|
||||||
|
{% for s in services %}
|
||||||
|
<tr>
|
||||||
|
<form method="post" action="/dash/services/{{ s.id }}">
|
||||||
|
<td><input type="text" name="name" value="{{ s.name }}"></td>
|
||||||
|
<td style="width:90px"><input type="number" name="duration_min" value="{{ s.duration_min }}" min="5" step="5"></td>
|
||||||
|
<td style="width:150px;white-space:nowrap">
|
||||||
|
<input type="number" name="price_min" value="{{ s.price_min if s.price_min is not none }}" step="0.5" style="width:65px;display:inline-block"> –
|
||||||
|
<input type="number" name="price_max" value="{{ s.price_max if s.price_max is not none }}" step="0.5" style="width:65px;display:inline-block">
|
||||||
|
</td>
|
||||||
|
<td style="width:60px;text-align:center"><input type="checkbox" name="home_visit" {{ 'checked' if s.home_visit }}></td>
|
||||||
|
<td><input type="text" name="agent_note" value="{{ s.agent_note }}"></td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<button class="small">Sačuvaj</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/dash/services/{{ s.id }}/delete" style="display:inline"
|
||||||
|
onsubmit="return confirm('Obrisati uslugu {{ s.name }}?')">
|
||||||
|
<button class="small red">Obriši</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr>
|
||||||
|
<form method="post" action="/dash/services">
|
||||||
|
<td><input type="text" name="name" placeholder="Nova usluga…" required></td>
|
||||||
|
<td><input type="number" name="duration_min" value="30" min="5" step="5"></td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<input type="number" name="price_min" step="0.5" style="width:65px;display:inline-block"> –
|
||||||
|
<input type="number" name="price_max" step="0.5" style="width:65px;display:inline-block">
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" name="home_visit"></td>
|
||||||
|
<td><input type="text" name="agent_note"></td>
|
||||||
|
<td><button class="small green">Dodaj</button></td>
|
||||||
|
</form>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Zalijepi cjenovnik</h2>
|
||||||
|
<p class="muted">Zalijepite cijeli cjenovnik kako god ga imate (sa web stranice, iz Worda,
|
||||||
|
sa Facebooka…). Gogo će ga pretvoriti u tabelu — vi pregledate, ispravite trajanja i potvrdite.</p>
|
||||||
|
<form method="post" action="/dash/services/parse">
|
||||||
|
<textarea name="text" style="min-height:140px" placeholder="npr.
|
||||||
|
Šišanje i feniranje ......... 25-35 KM
|
||||||
|
Farbanje ..................... od 60 KM
|
||||||
|
Manikir ...................... 20 KM"></textarea>
|
||||||
|
<button class="ghost" style="margin-top:8px">Pročitaj cjenovnik</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if parsed %}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Provjeri i potvrdi</h2>
|
||||||
|
<p class="muted">Ovako je Gogo pročitao vaš cjenovnik. Ispravite šta treba pa potvrdite —
|
||||||
|
usluge će biti <b>dodane</b> postojećim.</p>
|
||||||
|
<form method="post" action="/dash/services/import">
|
||||||
|
<table>
|
||||||
|
<tr><th></th><th>Usluga</th><th>Trajanje (min)</th><th>Cijena od</th><th>Cijena do</th><th>Kućna</th><th>Napomena</th></tr>
|
||||||
|
{% for p in parsed %}
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" name="row_{{ loop.index0 }}" checked></td>
|
||||||
|
<td><input type="text" name="name_{{ loop.index0 }}" value="{{ p.name }}"></td>
|
||||||
|
<td><input type="number" name="duration_{{ loop.index0 }}" value="{{ p.duration_min }}" min="5" step="5"></td>
|
||||||
|
<td><input type="number" name="pmin_{{ loop.index0 }}" value="{{ p.price_min if p.price_min is not none }}" step="0.5"></td>
|
||||||
|
<td><input type="number" name="pmax_{{ loop.index0 }}" value="{{ p.price_max if p.price_max is not none }}" step="0.5"></td>
|
||||||
|
<td style="text-align:center"><input type="checkbox" name="home_{{ loop.index0 }}" {{ 'checked' if p.home_visit }}></td>
|
||||||
|
<td><input type="text" name="note_{{ loop.index0 }}" value="{{ p.note or '' }}"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<input type="hidden" name="count" value="{{ parsed | length }}">
|
||||||
|
<button class="green" style="margin-top:12px">Dodaj označene usluge</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
43
gogo/templates/dashboard/settings.html
Normal file
43
gogo/templates/dashboard/settings.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Salon — {{ owner.tenant.name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Profil salona</h1>
|
||||||
|
<form method="post" action="/dash/settings">
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Osnovni podaci</h2>
|
||||||
|
<label>Naziv salona</label>
|
||||||
|
<input type="text" name="name" value="{{ t.name }}" required>
|
||||||
|
<label>Adresa</label>
|
||||||
|
<input type="text" name="address" value="{{ t.address }}">
|
||||||
|
<label>Grad</label>
|
||||||
|
<input type="text" name="city" value="{{ t.city }}">
|
||||||
|
<label>Telefon salona <small>(vaš postojeći broj)</small></label>
|
||||||
|
<input type="text" name="phone" value="{{ t.phone }}">
|
||||||
|
<label>Web stranica</label>
|
||||||
|
<input type="text" name="website" value="{{ t.website }}">
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Radno vrijeme</h2>
|
||||||
|
<p class="muted">Format: <code>09:00-13:00, 14:00-18:00</code> (pauza = dva intervala).
|
||||||
|
Prazno = neradni dan.</p>
|
||||||
|
{% for key, label in day_labels %}
|
||||||
|
<label>{{ label }}</label>
|
||||||
|
<input type="text" name="wh_{{ key }}" value="{{ hours_text[key] }}" placeholder="npr. 09:00-18:00">
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button>Sačuvaj</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:20px">
|
||||||
|
<h2>Zalijepi radno vrijeme</h2>
|
||||||
|
<p class="muted">Zalijepite radno vrijeme kako god ga imate zapisano (sa web stranice,
|
||||||
|
Facebooka…) — Gogo će ga pročitati, a vi samo provjerite i sačuvate.</p>
|
||||||
|
<form method="post" action="/dash/settings/parse-hours">
|
||||||
|
<textarea name="text" placeholder="npr. pon-pet 9-18 (pauza 13-14), subota 9-14, nedjelja ne radimo"></textarea>
|
||||||
|
<button class="ghost" style="margin-top:8px">Pročitaj radno vrijeme</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
80
gogo/templates/dashboard/telephony.html
Normal file
80
gogo/templates/dashboard/telephony.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Telefonija — {{ owner.tenant.name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Telefonija</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Vaš Gogo broj</h2>
|
||||||
|
{% if number %}
|
||||||
|
<p style="font-size:22px;margin:4px 0"><b>{{ number.msisdn }}</b></p>
|
||||||
|
<p class="muted">Na ovaj broj se prosljeđuju pozivi s vašeg postojećeg broja kad se niko
|
||||||
|
ne javi. Vaši klijenti i dalje zovu vaš stari broj — ništa se ne mijenja.</p>
|
||||||
|
<h2 style="margin-top:20px">Uključivanje prosljeđivanja</h2>
|
||||||
|
<p class="muted">Ukucajte ove kodove na telefonu na kojem je SIM vašeg salonskog broja
|
||||||
|
(poziv se pokreće tipkom za zvanje). Odaberite svog operatera:</p>
|
||||||
|
{% for f in forwarding %}
|
||||||
|
<details {{ 'open' if loop.first }}>
|
||||||
|
<summary style="cursor:pointer;font-weight:600;padding:6px 0">{{ f.operator_label }}</summary>
|
||||||
|
<table>
|
||||||
|
<tr><td class="muted">Kad se ne javite ({{ t.ring_timeout_s }}s)</td><td><code>{{ f.activate_no_answer }}</code></td></tr>
|
||||||
|
<tr><td class="muted">Kad je zauzeto</td><td><code>{{ f.activate_busy }}</code></td></tr>
|
||||||
|
<tr><td class="muted">Kad ste nedostupni</td><td><code>{{ f.activate_unreachable }}</code></td></tr>
|
||||||
|
<tr><td class="muted">Isključi sva prosljeđivanja</td><td><code>{{ f.deactivate_all }}</code></td></tr>
|
||||||
|
<tr><td class="muted">Provjera statusa</td><td><code>{{ f.check_status }}</code></td></tr>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="banner">Broj vam još nije dodijeljen — Gogo tim će to obaviti pri uključenju.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Zvonjenje osoblju prije asistenta</h2>
|
||||||
|
<form method="post" action="/dash/telephony">
|
||||||
|
<label><input type="checkbox" name="ring_first_enabled" {{ 'checked' if t.ring_first_enabled }}
|
||||||
|
style="width:auto"> Prvo zvoni osoblju, asistent se javlja tek ako se niko ne javi</label>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>Koliko dugo zvoni osoblju <small>(sekundi)</small></label>
|
||||||
|
<input type="number" name="ring_timeout_s" value="{{ t.ring_timeout_s }}" min="5" max="60">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Način zvonjenja</label>
|
||||||
|
<select name="ring_strategy">
|
||||||
|
<option value="ring_all" {{ 'selected' if t.ring_strategy=='ring_all' }}>Svi odjednom</option>
|
||||||
|
<option value="sequential" {{ 'selected' if t.ring_strategy=='sequential' }}>Jedan po jedan</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button style="margin-top:12px">Sačuvaj</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Osoblje (aplikacija za javljanje)</h2>
|
||||||
|
<p class="muted">Svaka radnica instalira besplatnu aplikaciju (preporučujemo
|
||||||
|
<b>Linphone</b>, može i Zoiper/Groundwire) i skenira svoj QR kod ili prepiše podatke.
|
||||||
|
Kad zazvoni salon, zazvoni i aplikacija.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Ime</th><th>SIP korisničko ime</th><th>Podešavanje</th><th></th></tr>
|
||||||
|
{% for w in workers %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ w.name }}</td>
|
||||||
|
<td><code>{{ w.sip_username }}</code></td>
|
||||||
|
<td><a class="btn small ghost" href="/dash/workers/{{ w.id }}/setup">QR + podaci</a></td>
|
||||||
|
<td><form method="post" action="/dash/workers/{{ w.id }}/delete"
|
||||||
|
onsubmit="return confirm('Ukloniti {{ w.name }} iz zvonjenja?')">
|
||||||
|
<button class="small red">Ukloni</button></form></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" class="muted">Još nema dodanog osoblja.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<form method="post" action="/dash/workers" style="margin-top:12px;display:flex;gap:8px">
|
||||||
|
<input type="text" name="name" placeholder="Ime radnice/radnika" required style="max-width:260px">
|
||||||
|
<button class="green">Dodaj</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
29
gogo/templates/dashboard/usage.html
Normal file
29
gogo/templates/dashboard/usage.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Potrošnja — {{ owner.tenant.name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Potrošnja — {{ usage.month }}</h1>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Minute virtualnog asistenta</h2>
|
||||||
|
<div class="bar"><div class="{{ 'over' if usage.over_100 else ('warn' if usage.over_80 else '') }}"
|
||||||
|
style="width:{{ usage.pct }}%"></div></div>
|
||||||
|
<p><b>{{ usage.used_minutes }}</b> od <b>{{ usage.included_minutes }}</b> uključenih minuta
|
||||||
|
({{ usage.pct }}%)</p>
|
||||||
|
{% if usage.over_100 %}
|
||||||
|
<div class="banner">Prekoračili ste uključene minute. Asistent i dalje odgovara na pozive —
|
||||||
|
javiće vam se Gogo tim oko proširenja paketa.</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="muted">Vrijeme kad se javi vaše osoblje ne troši minute; chat se ne naplaćuje po
|
||||||
|
minutama.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid3">
|
||||||
|
<div class="card"><h2>Pozivi asistenta</h2><p style="font-size:28px;margin:0"><b>{{ usage.calls }}</b></p></div>
|
||||||
|
<div class="card"><h2>Poslani SMS</h2><p style="font-size:28px;margin:0"><b>{{ usage.sms_sent }}</b></p></div>
|
||||||
|
<div class="card"><h2>Chat razgovori</h2><p style="font-size:28px;margin:0"><b>{{ usage.chat_sessions }}</b></p></div>
|
||||||
|
</div>
|
||||||
|
{% if owner.tenant.paid_until %}
|
||||||
|
<div class="card"><h2>Pretplata</h2>
|
||||||
|
<p>Plaćeno do: <b>{{ owner.tenant.paid_until.strftime('%d.%m.%Y.') }}</b> —
|
||||||
|
paket „Gogo Start" ({{ owner.tenant.included_minutes }} min/mj).</p></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
25
gogo/templates/dashboard/worker_setup.html
Normal file
25
gogo/templates/dashboard/worker_setup.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Podešavanje aplikacije — {{ worker.name }}{% endblock %}
|
||||||
|
{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="/dash/telephony">← Telefonija</a></p>
|
||||||
|
<h1>Aplikacija za: {{ worker.name }}</h1>
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card" style="text-align:center">
|
||||||
|
<h2>Skeniraj QR kod</h2>
|
||||||
|
<img src="/dash/workers/{{ worker.id }}/qr.png" alt="QR za podešavanje" style="max-width:260px">
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Ili prepiši ručno</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td class="muted">Server / domena</td><td><code>{{ domain }}</code></td></tr>
|
||||||
|
<tr><td class="muted">Korisničko ime</td><td><code>{{ worker.sip_username }}</code></td></tr>
|
||||||
|
<tr><td class="muted">Lozinka</td><td><code>{{ worker.sip_password }}</code></td></tr>
|
||||||
|
<tr><td class="muted">Transport</td><td><code>UDP, port 5060</code></td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="muted" style="margin-top:16px">Koraci u Linphone aplikaciji: Assistant →
|
||||||
|
„Use SIP account" → unesite podatke → Save. Provjerite da aplikacija ima dozvolu za
|
||||||
|
obavještenja da bi zvonila.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
15
gogo/templates/widget_demo.html
Normal file
15
gogo/templates/widget_demo.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="bs">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Gogo widget demo</title>
|
||||||
|
<style>body{font-family:system-ui,sans-serif;max-width:640px;margin:60px auto;padding:0 20px;color:#333}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Demo stranica salona</h1>
|
||||||
|
<p>Ovako izgleda Gogo chat widget na web stranici salona — kliknite na ljubičasti balončić
|
||||||
|
u donjem desnom uglu.</p>
|
||||||
|
<script src="/widget.js" data-gogo-key="{{ key }}" async></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
80
gogo/web.py
Normal file
80
gogo/web.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Shared web helpers: Jinja templates, redirects, Bosnian labels."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
||||||
|
|
||||||
|
STATUS_LABELS = {
|
||||||
|
"pending": "na čekanju",
|
||||||
|
"resolved_by_owner": "riješeno",
|
||||||
|
"confirmed": "potvrđeno",
|
||||||
|
"rejected": "odbijeno",
|
||||||
|
"expired": "isteklo",
|
||||||
|
}
|
||||||
|
|
||||||
|
OUTCOME_LABELS = {
|
||||||
|
"human_answered": "javilo se osoblje",
|
||||||
|
"request_created": "zahtjev kreiran",
|
||||||
|
"info_only": "informacija",
|
||||||
|
"message_taken": "poruka",
|
||||||
|
"abandoned": "prekinut",
|
||||||
|
}
|
||||||
|
|
||||||
|
DAY_LABELS = [
|
||||||
|
("mon", "Ponedjeljak"),
|
||||||
|
("tue", "Utorak"),
|
||||||
|
("wed", "Srijeda"),
|
||||||
|
("thu", "Četvrtak"),
|
||||||
|
("fri", "Petak"),
|
||||||
|
("sat", "Subota"),
|
||||||
|
("sun", "Nedjelja"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Curated TTS voices (§10.5) — final list confirmed by the Phase-0 PoC bake-off.
|
||||||
|
TTS_VOICES = [
|
||||||
|
{"id": "azure:sr-RS-SophieNeural", "label": "Sofija — ženski, topao (Azure)"},
|
||||||
|
{"id": "azure:sr-RS-NicholasNeural", "label": "Nikola — muški, smiren (Azure)"},
|
||||||
|
{"id": "azure:hr-HR-GabrijelaNeural", "label": "Gabrijela — ženski, vedar (Azure)"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def redirect(url: str, msg: str = "", err: str = "") -> RedirectResponse:
|
||||||
|
if msg:
|
||||||
|
url += ("&" if "?" in url else "?") + "msg=" + quote(msg)
|
||||||
|
if err:
|
||||||
|
url += ("&" if "?" in url else "?") + "err=" + quote(err)
|
||||||
|
return RedirectResponse(url, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
def hours_to_text(working_hours: dict) -> dict[str, str]:
|
||||||
|
"""{'mon': [['09:00','13:00'],['14:00','18:00']]} → {'mon': '09:00-13:00, 14:00-18:00'}"""
|
||||||
|
out = {}
|
||||||
|
for key, _label in DAY_LABELS:
|
||||||
|
intervals = (working_hours or {}).get(key, [])
|
||||||
|
out[key] = ", ".join(f"{a}-{b}" for a, b in intervals)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def text_to_intervals(text: str) -> list[list[str]]:
|
||||||
|
"""'09:00-13:00, 14:00-18:00' → [['09:00','13:00'],['14:00','18:00']]; '' → []"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
intervals = []
|
||||||
|
for part in text.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
m = re.match(r"^(\d{1,2})(?::(\d{2}))?\s*[-–]\s*(\d{1,2})(?::(\d{2}))?$", part)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"Neispravan format: '{part}' (očekivano npr. 09:00-18:00)")
|
||||||
|
h1, m1, h2, m2 = m.groups()
|
||||||
|
a = f"{int(h1):02d}:{m1 or '00'}"
|
||||||
|
b = f"{int(h2):02d}:{m2 or '00'}"
|
||||||
|
intervals.append([a, b])
|
||||||
|
return intervals
|
||||||
154
tests/test_chat_widget.py
Normal file
154
tests/test_chat_widget.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Chat widget e2e (M3): WebSocket booking flow → request in DB + proposal email.
|
||||||
|
|
||||||
|
Uses Starlette's sync TestClient for WebSocket support; the scripted LLM drives
|
||||||
|
the same agent loop as production.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from gogo.agent.llm import LLMResponse, ScriptedLLM, ToolUse, set_llm
|
||||||
|
from gogo.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def make_client() -> TestClient:
|
||||||
|
return TestClient(create_app())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fast_chat(monkeypatch):
|
||||||
|
"""Scripted LLM answers instantly — disable the per-second message gate."""
|
||||||
|
from gogo.chat import router as chat_router
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat_router, "MIN_SECONDS_BETWEEN_MESSAGES", 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_widget_js_served(gogo_client, session):
|
||||||
|
resp = await gogo_client.get("/widget.js")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "gogo-bubble" in resp.text
|
||||||
|
assert resp.headers["content-type"].startswith("application/javascript")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_rejects_unknown_key(session, tenant):
|
||||||
|
client = make_client()
|
||||||
|
# server closes before accepting → the connect itself raises with our code
|
||||||
|
with pytest.raises(WebSocketDisconnect) as exc: # noqa: SIM117
|
||||||
|
with client.websocket_connect("/widget/ws?key=nepostojeci"):
|
||||||
|
pass
|
||||||
|
assert exc.value.code == 4404
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_booking_flow_creates_request_and_email(session, tenant, emails, sms):
|
||||||
|
"""Full widget booking: greeting → messages → submit_booking_request →
|
||||||
|
request stored, proposal email sent, chat transcript persisted."""
|
||||||
|
import anyio
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from gogo.models import BookingRequest, ChatMessage
|
||||||
|
|
||||||
|
set_llm(
|
||||||
|
ScriptedLLM(
|
||||||
|
[
|
||||||
|
LLMResponse(text="Može! Koji je vaš broj telefona i ime?"),
|
||||||
|
LLMResponse(
|
||||||
|
text="",
|
||||||
|
tool_uses=[
|
||||||
|
ToolUse(
|
||||||
|
id="t1",
|
||||||
|
name="submit_booking_request",
|
||||||
|
input={
|
||||||
|
"service_name_raw": "manikir",
|
||||||
|
"client_name": "Lejla",
|
||||||
|
"client_phone": "+38761555444",
|
||||||
|
"time_preference_text": "petak poslijepodne",
|
||||||
|
"summary": "Manikir, petak poslijepodne.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
stop_reason="tool_use",
|
||||||
|
),
|
||||||
|
LLMResponse(
|
||||||
|
text="Zahtjev sam proslijedila salonu — kontaktiraće vas radi potvrde!"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
client = make_client()
|
||||||
|
with client.websocket_connect(f"/widget/ws?key={tenant.widget_public_key}") as ws:
|
||||||
|
greeting = ws.receive_json()
|
||||||
|
assert greeting["type"] == "greeting"
|
||||||
|
assert "Gogo" in greeting["text"]
|
||||||
|
resume_token = greeting["session"]
|
||||||
|
assert resume_token
|
||||||
|
|
||||||
|
ws.send_json({"type": "message", "text": "Može manikir u petak?", "website": ""})
|
||||||
|
assert ws.receive_json()["type"] == "typing"
|
||||||
|
reply = ws.receive_json()
|
||||||
|
assert reply["type"] == "message"
|
||||||
|
assert "broj telefona" in reply["text"]
|
||||||
|
|
||||||
|
ws.send_json({"type": "message", "text": "Lejla, 061 555 444"})
|
||||||
|
assert ws.receive_json()["type"] == "typing"
|
||||||
|
reply2 = ws.receive_json()
|
||||||
|
assert "proslijedila" in reply2["text"]
|
||||||
|
|
||||||
|
# verify persisted state via a fresh event loop session
|
||||||
|
async def check():
|
||||||
|
import gogo.db as db
|
||||||
|
|
||||||
|
async with db.get_sessionmaker()() as s:
|
||||||
|
req = (await s.execute(select(BookingRequest))).scalar_one()
|
||||||
|
assert req.client_name == "Lejla"
|
||||||
|
assert req.source == "chat"
|
||||||
|
assert req.chat_session_id is not None
|
||||||
|
msgs = (await s.execute(select(ChatMessage))).scalars().all()
|
||||||
|
roles = [m.role for m in msgs]
|
||||||
|
assert roles.count("user") == 2
|
||||||
|
assert roles.count("assistant") >= 3 # greeting + 2 replies
|
||||||
|
|
||||||
|
anyio.run(check)
|
||||||
|
assert len(emails) == 1 # proposal email to the owner
|
||||||
|
assert "Lejla" in emails[0].subject
|
||||||
|
finally:
|
||||||
|
set_llm(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_honeypot_drops_message(session, tenant, emails):
|
||||||
|
set_llm(ScriptedLLM([LLMResponse(text="NE BI SMIO STIĆI")]))
|
||||||
|
try:
|
||||||
|
client = make_client()
|
||||||
|
with client.websocket_connect(f"/widget/ws?key={tenant.widget_public_key}") as ws:
|
||||||
|
ws.receive_json() # greeting
|
||||||
|
# bot fills the hidden field → silently ignored
|
||||||
|
ws.send_json({"type": "message", "text": "spam", "website": "http://spam.example"})
|
||||||
|
# then a real empty check: send another honeypot message; no replies expected
|
||||||
|
ws.send_json({"type": "message", "text": "spam2", "website": "x"})
|
||||||
|
# close cleanly — if the agent had run, ScriptedLLM would have been consumed
|
||||||
|
assert True
|
||||||
|
finally:
|
||||||
|
set_llm(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_resume_restores_history(session, tenant, emails):
|
||||||
|
set_llm(ScriptedLLM([LLMResponse(text="Prvi odgovor.")]))
|
||||||
|
try:
|
||||||
|
client = make_client()
|
||||||
|
with client.websocket_connect(f"/widget/ws?key={tenant.widget_public_key}") as ws:
|
||||||
|
token = ws.receive_json()["session"]
|
||||||
|
ws.send_json({"type": "message", "text": "Zdravo!"})
|
||||||
|
ws.receive_json() # typing
|
||||||
|
ws.receive_json() # reply
|
||||||
|
|
||||||
|
with client.websocket_connect(
|
||||||
|
f"/widget/ws?key={tenant.widget_public_key}&resume={token}"
|
||||||
|
) as ws:
|
||||||
|
data = ws.receive_json()
|
||||||
|
assert data["type"] == "resume"
|
||||||
|
texts = [m["text"] for m in data["messages"]]
|
||||||
|
assert "Zdravo!" in texts
|
||||||
|
assert "Prvi odgovor." in texts
|
||||||
|
finally:
|
||||||
|
set_llm(None)
|
||||||
344
tests/test_web_dashboard.py
Normal file
344
tests/test_web_dashboard.py
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
"""Owner dashboard + super-admin panel over HTTP (M3)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from gogo.auth import hash_password
|
||||||
|
from gogo.domain import BookingStatus
|
||||||
|
from gogo.models import Admin, PromptTemplate, Service, Tenant, User
|
||||||
|
from tests.test_proposal_lifecycle import create as create_request
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def owner_user(session, tenant):
|
||||||
|
user = User(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
email="vlasnica@salon-merima.ba",
|
||||||
|
password_hash=hash_password("tajna123"),
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def admin_user(session):
|
||||||
|
admin = Admin(email="admin@gogotelefon.ba", password_hash=hash_password("admin123"))
|
||||||
|
session.add(admin)
|
||||||
|
await session.commit()
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
async def login_owner(client, email="vlasnica@salon-merima.ba", password="tajna123"):
|
||||||
|
resp = await client.post("/login", data={"email": email, "password": password})
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert resp.headers["location"] == "/dash"
|
||||||
|
|
||||||
|
|
||||||
|
async def login_admin(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/login", data={"email": "admin@gogotelefon.ba", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
# -- owner auth ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dashboard_requires_login(gogo_client, session, tenant):
|
||||||
|
resp = await gogo_client.get("/dash")
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert resp.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_wrong_password(gogo_client, session, owner_user):
|
||||||
|
resp = await gogo_client.post(
|
||||||
|
"/login", data={"email": owner_user.email, "password": "pogresna"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert "/login" in resp.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_and_requests_list(gogo_client, session, tenant, owner_user, emails, sms):
|
||||||
|
await create_request(session, tenant)
|
||||||
|
await session.commit()
|
||||||
|
await login_owner(gogo_client)
|
||||||
|
resp = await gogo_client.get("/dash")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Amra Hodžić" in resp.text
|
||||||
|
assert "na čekanju" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dashboard_resolve_action(gogo_client, session, tenant, owner_user, emails, sms):
|
||||||
|
req, _ = await create_request(session, tenant)
|
||||||
|
await session.commit()
|
||||||
|
await login_owner(gogo_client)
|
||||||
|
resp = await gogo_client.post(f"/dash/requests/{req.id}/resolve")
|
||||||
|
assert resp.status_code == 303
|
||||||
|
await session.refresh(req)
|
||||||
|
assert req.status == BookingStatus.resolved_by_owner.value
|
||||||
|
assert sms == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_detail_confirm(gogo_client, session, tenant, owner_user, emails, sms):
|
||||||
|
req, _ = await create_request(session, tenant)
|
||||||
|
await session.commit()
|
||||||
|
await login_owner(gogo_client)
|
||||||
|
resp = await gogo_client.get(f"/dash/requests/{req.id}")
|
||||||
|
assert "Potvrdi" in resp.text
|
||||||
|
resp = await gogo_client.post(f"/dash/requests/{req.id}/confirm/0")
|
||||||
|
assert resp.status_code == 303
|
||||||
|
await session.refresh(req)
|
||||||
|
assert req.status == BookingStatus.confirmed.value
|
||||||
|
assert len(sms) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -- settings -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_settings_save_hours(gogo_client, session, tenant, owner_user):
|
||||||
|
await login_owner(gogo_client)
|
||||||
|
data = {
|
||||||
|
"name": "Salon Merima",
|
||||||
|
"address": "Nova adresa 5",
|
||||||
|
"city": "Banja Luka",
|
||||||
|
"phone": "+38751000000",
|
||||||
|
"website": "",
|
||||||
|
"wh_mon": "09:00-13:00, 14:00-18:00",
|
||||||
|
"wh_tue": "9-18",
|
||||||
|
"wh_wed": "09:00-18:00",
|
||||||
|
"wh_thu": "09:00-18:00",
|
||||||
|
"wh_fri": "09:00-18:00",
|
||||||
|
"wh_sat": "09:00-14:00",
|
||||||
|
"wh_sun": "",
|
||||||
|
}
|
||||||
|
resp = await gogo_client.post("/dash/settings", data=data)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
await session.refresh(tenant)
|
||||||
|
assert tenant.address == "Nova adresa 5"
|
||||||
|
assert tenant.working_hours["mon"] == [["09:00", "13:00"], ["14:00", "18:00"]]
|
||||||
|
assert tenant.working_hours["tue"] == [["09:00", "18:00"]]
|
||||||
|
assert tenant.working_hours["sun"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_settings_bad_hours_rejected(gogo_client, session, tenant, owner_user):
|
||||||
|
await login_owner(gogo_client)
|
||||||
|
data = {"name": "Salon Merima", "wh_mon": "od devet do šest"}
|
||||||
|
resp = await gogo_client.post("/dash/settings", data=data)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert "err=" in resp.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_services_crud(gogo_client, session, tenant, owner_user):
|
||||||
|
await login_owner(gogo_client)
|
||||||
|
resp = await gogo_client.post(
|
||||||
|
"/dash/services",
|
||||||
|
data={"name": "Nova usluga", "duration_min": "60", "price_min": "40",
|
||||||
|
"price_max": "50", "agent_note": "napomena"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
svc = (
|
||||||
|
await session.execute(select(Service).where(Service.name == "Nova usluga"))
|
||||||
|
).scalar_one()
|
||||||
|
assert svc.duration_min == 60
|
||||||
|
assert svc.price_min == 40
|
||||||
|
|
||||||
|
resp = await gogo_client.post(f"/dash/services/{svc.id}/delete")
|
||||||
|
assert resp.status_code == 303
|
||||||
|
await session.refresh(svc)
|
||||||
|
assert svc.active is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paste_price_list_with_scripted_llm(
|
||||||
|
gogo_client, session, tenant, owner_user, monkeypatch
|
||||||
|
):
|
||||||
|
from gogo.agent.llm import LLMResponse, ScriptedLLM, ToolUse, set_llm
|
||||||
|
|
||||||
|
set_llm(
|
||||||
|
ScriptedLLM(
|
||||||
|
[
|
||||||
|
LLMResponse(
|
||||||
|
text="",
|
||||||
|
tool_uses=[
|
||||||
|
ToolUse(
|
||||||
|
id="t1",
|
||||||
|
name="save_services",
|
||||||
|
input={
|
||||||
|
"services": [
|
||||||
|
{"name": "Trajna", "duration_min": 90,
|
||||||
|
"price_min": 40, "price_max": 60},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
stop_reason="tool_use",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await login_owner(gogo_client)
|
||||||
|
resp = await gogo_client.post(
|
||||||
|
"/dash/services/parse", data={"text": "Trajna 40-60KM"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Provjeri i potvrdi" in resp.text
|
||||||
|
assert "Trajna" in resp.text
|
||||||
|
|
||||||
|
# confirm the parsed row → imported
|
||||||
|
resp = await gogo_client.post(
|
||||||
|
"/dash/services/import",
|
||||||
|
data={"count": "1", "row_0": "on", "name_0": "Trajna",
|
||||||
|
"duration_0": "90", "pmin_0": "40", "pmax_0": "60", "note_0": ""},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
svc = (
|
||||||
|
await session.execute(select(Service).where(Service.name == "Trajna"))
|
||||||
|
).scalar_one()
|
||||||
|
assert svc.duration_min == 90
|
||||||
|
finally:
|
||||||
|
set_llm(None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_workers_add_and_qr(gogo_client, session, tenant, owner_user):
|
||||||
|
await login_owner(gogo_client)
|
||||||
|
resp = await gogo_client.post("/dash/workers", data={"name": "Merima"})
|
||||||
|
assert resp.status_code == 303
|
||||||
|
from gogo.models import Worker
|
||||||
|
|
||||||
|
worker = (await session.execute(select(Worker))).scalar_one()
|
||||||
|
assert worker.name == "Merima"
|
||||||
|
assert worker.sip_username.startswith("salon-merima-")
|
||||||
|
|
||||||
|
resp = await gogo_client.get(f"/dash/workers/{worker.id}/qr.png")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "image/png"
|
||||||
|
assert resp.content[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_notifications_save(gogo_client, session, tenant, owner_user):
|
||||||
|
await login_owner(gogo_client)
|
||||||
|
resp = await gogo_client.post(
|
||||||
|
"/dash/notifications",
|
||||||
|
data={
|
||||||
|
"notify_emails": "a@b.ba\nc@d.ba",
|
||||||
|
"sms_request_received": "on",
|
||||||
|
"sms_confirmation": "Vaš termin: {usluga} {dan} u {vrijeme}h. {salon}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
await session.refresh(tenant)
|
||||||
|
assert tenant.notify_emails == ["a@b.ba", "c@d.ba"]
|
||||||
|
assert tenant.sms_request_received is True
|
||||||
|
assert "confirmation" in tenant.sms_templates
|
||||||
|
|
||||||
|
|
||||||
|
# -- super-admin ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_create_tenant_and_owner(gogo_client, session, admin_user):
|
||||||
|
await login_admin(gogo_client)
|
||||||
|
resp = await gogo_client.post(
|
||||||
|
"/admin/tenants/new",
|
||||||
|
data={
|
||||||
|
"name": "Salon Aida", "slug": "salon-aida", "city": "Sarajevo",
|
||||||
|
"status": "active", "plan": "gogo_start", "included_minutes": "300",
|
||||||
|
"paid_until": "2026-12-31", "proposal_ttl_hours": "24",
|
||||||
|
"scheduling_provider": "partner_api",
|
||||||
|
"partner_base_url": "http://partner.test",
|
||||||
|
"partner_api_key": "test-partner-key",
|
||||||
|
"partner_webhook_secret": "test-webhook-secret",
|
||||||
|
"owner_email": "aida@salon.ba", "owner_password": "lozinka1",
|
||||||
|
"phone_number_id": "", "llm_model": "", "prompt_override": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 303, resp.text
|
||||||
|
t = (
|
||||||
|
await session.execute(select(Tenant).where(Tenant.slug == "salon-aida"))
|
||||||
|
).scalar_one()
|
||||||
|
assert t.scheduling_provider == "partner_api"
|
||||||
|
assert t.paid_until is not None
|
||||||
|
user = (
|
||||||
|
await session.execute(select(User).where(User.tenant_id == t.id))
|
||||||
|
).scalar_one()
|
||||||
|
assert user.email == "aida@salon.ba"
|
||||||
|
|
||||||
|
from gogo.models import ProviderConfig
|
||||||
|
|
||||||
|
cfg = (
|
||||||
|
await session.execute(select(ProviderConfig).where(ProviderConfig.tenant_id == t.id))
|
||||||
|
).scalar_one()
|
||||||
|
assert cfg.config["base_url"] == "http://partner.test"
|
||||||
|
assert cfg.config["api_key_encrypted"] # stored encrypted
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_connectivity_test_against_fake_partner(
|
||||||
|
gogo_client, session, admin_user, partner_tenant, wire_fake_partner
|
||||||
|
):
|
||||||
|
wire_fake_partner.slots["SRV-12"] = [
|
||||||
|
{"start": "2026-07-15T14:30:00+02:00", "end": "2026-07-15T15:15:00+02:00"}
|
||||||
|
]
|
||||||
|
await login_admin(gogo_client)
|
||||||
|
resp = await gogo_client.post(f"/admin/tenants/{partner_tenant.id}/test-connection")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "GET /availability" in resp.text
|
||||||
|
assert "dry_run" in resp.text or "dry-run" in resp.text
|
||||||
|
assert "ok=True" in resp.text
|
||||||
|
# dry-run must not create anything in the partner system
|
||||||
|
assert wire_fake_partner.requests == {}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_prompt_template_versioning(gogo_client, session, admin_user, tenant):
|
||||||
|
await login_admin(gogo_client)
|
||||||
|
resp = await gogo_client.post(
|
||||||
|
"/admin/templates",
|
||||||
|
data={"body": "V1 {salon_profile} {greeting}", "notes": "prva", "publish": "on"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
resp = await gogo_client.post(
|
||||||
|
"/admin/templates",
|
||||||
|
data={"body": "V2 {salon_profile} {greeting}", "notes": "druga", "publish": "on"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
|
||||||
|
versions = (await session.execute(select(PromptTemplate))).scalars().all()
|
||||||
|
published = [v for v in versions if v.published]
|
||||||
|
assert len(versions) == 2
|
||||||
|
assert len(published) == 1 and published[0].version == 2
|
||||||
|
|
||||||
|
# composed prompt uses the published template
|
||||||
|
from gogo.agent.prompt import compose_system_prompt
|
||||||
|
|
||||||
|
prompt = await compose_system_prompt(session, tenant, "voice")
|
||||||
|
assert prompt.startswith("V2")
|
||||||
|
|
||||||
|
# rollback to v1
|
||||||
|
resp = await gogo_client.post("/admin/templates/1/publish")
|
||||||
|
assert resp.status_code == 303
|
||||||
|
session.expire_all()
|
||||||
|
await session.refresh(tenant) # async-safe reload (expired attrs can't lazy-load)
|
||||||
|
prompt = await compose_system_prompt(session, tenant, "voice")
|
||||||
|
assert prompt.startswith("V1")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_impersonation(gogo_client, session, admin_user, tenant):
|
||||||
|
await login_admin(gogo_client)
|
||||||
|
resp = await gogo_client.post(f"/admin/impersonate/{tenant.id}")
|
||||||
|
assert resp.status_code == 303
|
||||||
|
resp = await gogo_client.get("/dash")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "admin pregled" in resp.text
|
||||||
|
assert tenant.name in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_requires_login(gogo_client, session):
|
||||||
|
resp = await gogo_client.get("/admin")
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert resp.headers["location"] == "/admin/login"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_health_page(gogo_client, session, admin_user):
|
||||||
|
await login_admin(gogo_client)
|
||||||
|
resp = await gogo_client.get("/admin/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Baza podataka" in resp.text
|
||||||
Reference in New Issue
Block a user