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:
2026-07-11 10:51:57 +02:00
parent 0d0838e6e2
commit 646917c322
46 changed files with 4402 additions and 8 deletions

0
gogo/admin/__init__.py Normal file
View File

795
gogo/admin/router.py Normal file
View 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
View 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
View File

195
gogo/chat/router.py Normal file
View 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, []

View File

125
gogo/dashboard/parse.py Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,27 @@ _sessionmaker: async_sessionmaker[AsyncSession] | None = None
def get_engine():
global _engine, _sessionmaker
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)
return _engine

View File

@@ -29,10 +29,24 @@ async def lifespan(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.include_router(health.router)
app.include_router(actions.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

104
gogo/metering.py Normal file
View 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,
}

View File

@@ -10,17 +10,11 @@ import sys
from sqlalchemy import select
from gogo.auth import hash_password
from gogo.crypto import encrypt
from gogo.db import Base, get_engine, get_sessionmaker
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 = {
"mon": [["09:00", "18:00"]],
"tue": [["09:00", "18:00"]],

153
gogo/static/widget.js Normal file
View 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 = "";
});
})();

View File

View 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
View 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()

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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
View 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>

View 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>

View 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 (oddo)</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>&lt;body&gt;</code> taga vaše
stranice (ili ga pošaljite onome ko vam održava web):</p>
<pre>&lt;script src="{{ base_url }}/widget.js" data-gogo-key="{{ t.widget_public_key }}" async&gt;&lt;/script&gt;</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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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
View 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