diff --git a/gogo/admin/__init__.py b/gogo/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gogo/admin/router.py b/gogo/admin/router.py new file mode 100644 index 0000000..427f5bc --- /dev/null +++ b/gogo/admin/router.py @@ -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", + ), + ) diff --git a/gogo/auth.py b/gogo/auth.py new file mode 100644 index 0000000..0b36098 --- /dev/null +++ b/gogo/auth.py @@ -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"}) diff --git a/gogo/chat/__init__.py b/gogo/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gogo/chat/router.py b/gogo/chat/router.py new file mode 100644 index 0000000..19b2063 --- /dev/null +++ b/gogo/chat/router.py @@ -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, [] diff --git a/gogo/dashboard/__init__.py b/gogo/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gogo/dashboard/parse.py b/gogo/dashboard/parse.py new file mode 100644 index 0000000..52f7c4d --- /dev/null +++ b/gogo/dashboard/parse.py @@ -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 diff --git a/gogo/dashboard/router.py b/gogo/dashboard/router.py new file mode 100644 index 0000000..8a5c5b2 --- /dev/null +++ b/gogo/dashboard/router.py @@ -0,0 +1,1105 @@ +"""Owner dashboard (§10) — Bosnian UI, server-rendered. + +Settings: profile/hours (with paste-to-parse), services CRUD + 'Zalijepi +cjenovnik', scheduling per provider, telephony (forwarding codes, ring group, +worker QR), agent (voice/price mode/notes/widget snippet), notifications. +Review: booking requests (same actions as email), call/chat history, usage. +""" + +from __future__ import annotations + +import json +import uuid + +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import FileResponse, HTMLResponse, Response +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from gogo.agent.prompt import compose_greeting +from gogo.auth import ( + OwnerContext, + clear_session, + current_owner, + read_session, + set_session, + verify_password, +) +from gogo.config import get_settings +from gogo.db import get_session +from gogo.domain import BookingStatus, Slot +from gogo.i18n import fmt_slot +from gogo.metering import usage_summary +from gogo.models import ( + BookingRequest, + CalendarConnection, + CalendarMapping, + Call, + ChatMessage, + ChatSession, + PhoneNumber, + Service, + Tenant, + User, + Worker, +) +from gogo.proposals import engine as proposal_engine +from gogo.sms.templates import DEFAULT_TEMPLATES +from gogo.telephony import sip +from gogo.telephony.forwarding import OPERATORS, forwarding_codes +from gogo.web import ( + DAY_LABELS, + OUTCOME_LABELS, + STATUS_LABELS, + TTS_VOICES, + hours_to_text, + redirect, + templates, + text_to_intervals, +) + +router = APIRouter() + + +def ctx(request: Request, owner: OwnerContext, **extra) -> dict: + return { + "request": request, + "owner": owner, + "t": owner.tenant, + "status_labels": STATUS_LABELS, + "outcome_labels": OUTCOME_LABELS, + **extra, + } + + +# -- auth --------------------------------------------------------------------- + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + return templates.TemplateResponse(request, "dashboard/login.html", {"request": request}) + + +@router.post("/login") +async def login( + request: Request, + email: str = Form(...), + password: str = Form(...), + session: AsyncSession = Depends(get_session), +): + user = ( + await session.execute(select(User).where(User.email == email.strip().lower())) + ).scalar_one_or_none() + if user is None or not user.active or not verify_password(password, user.password_hash): + return redirect("/login", err="Pogrešan email ili lozinka.") + resp = redirect("/dash") + set_session(resp, {"kind": "owner", "user_id": str(user.id)}) + return resp + + +@router.get("/logout") +async def logout(request: Request): + data = read_session(request) or {} + resp = redirect("/admin" if data.get("kind") == "admin" else "/login") + if data.get("kind") == "admin": + data.pop("impersonate_tenant", None) + set_session(resp, data) + else: + clear_session(resp) + return resp + + +# -- review: requests --------------------------------------------------------- + + +async def _request_row(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> dict: + service_name = req.service_name_raw or "—" + if req.service_id: + svc = ( + await session.execute(select(Service).where(Service.id == req.service_id)) + ).scalar_one_or_none() + if svc: + service_name = svc.name + slots = [ + fmt_slot(Slot.model_validate(s).start, tenant.timezone) for s in (req.slots or []) + ] + return {"req": req, "service_name": service_name, "slots": slots} + + +@router.get("/dash", response_class=HTMLResponse) +async def requests_list( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + reqs = ( + ( + await session.execute( + select(BookingRequest) + .where(BookingRequest.tenant_id == owner.tenant.id) + .order_by(desc(BookingRequest.created_at)) + .limit(100) + ) + ) + .scalars() + .all() + ) + rows = [await _request_row(session, owner.tenant, r) for r in reqs] + usage = await usage_summary(session, owner.tenant) + await session.commit() + return templates.TemplateResponse( + request, + "dashboard/requests.html", + ctx(request, owner, rows=rows, usage=usage, active="requests"), + ) + + +async def _load_request( + session: AsyncSession, owner: OwnerContext, request_id: str +) -> BookingRequest | None: + try: + rid = uuid.UUID(request_id) + except ValueError: + return None + return ( + await session.execute( + select(BookingRequest).where( + BookingRequest.id == rid, BookingRequest.tenant_id == owner.tenant.id + ) + ) + ).scalar_one_or_none() + + +@router.get("/dash/requests/{request_id}", response_class=HTMLResponse) +async def request_detail( + request_id: str, + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + req = await _load_request(session, owner, request_id) + if req is None: + return redirect("/dash", err="Zahtjev nije pronađen.") + row = await _request_row(session, owner.tenant, req) + from gogo.sms.templates import render_sms + + transcript = await _transcript_for_request(session, req) + confirmed_label = "" + if req.confirmed_slot: + confirmed_label = fmt_slot( + Slot.model_validate(req.confirmed_slot).start, owner.tenant.timezone + ) + return templates.TemplateResponse( + request, + "dashboard/request_detail.html", + ctx( + request, + owner, + req=req, + service_name=row["service_name"], + slots=row["slots"], + rejection_sms=render_sms(owner.tenant, "rejection"), + transcript=transcript, + confirmed_label=confirmed_label, + active="requests", + ), + ) + + +async def _transcript_for_request(session: AsyncSession, req: BookingRequest) -> list[dict]: + if req.call_id: + call = ( + await session.execute(select(Call).where(Call.id == req.call_id)) + ).scalar_one_or_none() + if call and call.transcript: + return call.transcript + if req.chat_session_id: + msgs = ( + ( + await session.execute( + select(ChatMessage) + .where(ChatMessage.session_id == req.chat_session_id) + .order_by(ChatMessage.at) + ) + ) + .scalars() + .all() + ) + return [{"role": m.role, "text": m.content} for m in msgs] + return [] + + +@router.post("/dash/requests/{request_id}/resolve") +async def request_resolve( + request_id: str, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + req = await _load_request(session, owner, request_id) + if req is None or req.status != BookingStatus.pending.value: + return redirect("/dash", err="Zahtjev je već obrađen.") + await proposal_engine.resolve_request(session, owner.tenant, req) + await session.commit() + return redirect("/dash", msg="Zahtjev označen kao riješen — klijentu nije poslan SMS.") + + +@router.post("/dash/requests/{request_id}/confirm/{slot_index}") +async def request_confirm( + request_id: str, + slot_index: int, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + req = await _load_request(session, owner, request_id) + if req is None or req.status != BookingStatus.pending.value: + return redirect("/dash", err="Zahtjev je već obrađen.") + slots = [Slot.model_validate(s) for s in (req.slots or [])] + if slot_index >= len(slots): + return redirect(f"/dash/requests/{request_id}", err="Termin ne postoji.") + ok = await proposal_engine.confirm_request(session, owner.tenant, req, slots[slot_index]) + await session.commit() + if not ok: + return redirect( + f"/dash/requests/{request_id}", + err="Termin je u međuvremenu zauzet u kalendaru — provjerite pa pokušajte ponovo.", + ) + return redirect(f"/dash/requests/{request_id}", msg="Termin potvrđen, SMS poslan klijentu.") + + +@router.post("/dash/requests/{request_id}/reject") +async def request_reject( + request_id: str, + sms_body: str = Form(""), + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + req = await _load_request(session, owner, request_id) + if req is None or req.status != BookingStatus.pending.value: + return redirect("/dash", err="Zahtjev je već obrađen.") + await proposal_engine.reject_request( + session, owner.tenant, req, custom_sms=sms_body.strip() or None + ) + await session.commit() + return redirect("/dash", msg="Zahtjev odbijen, klijent je obaviješten SMS-om.") + + +# transcript short-link used in proposal emails: /t/{request_id} +@router.get("/t/{request_id}") +async def transcript_link( + request_id: str, + owner: OwnerContext = Depends(current_owner), +): + return redirect(f"/dash/requests/{request_id}") + + +# -- review: calls / chats / usage --------------------------------------------- + + +@router.get("/dash/calls", response_class=HTMLResponse) +async def calls_list( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + calls = ( + ( + await session.execute( + select(Call) + .where(Call.tenant_id == owner.tenant.id) + .order_by(desc(Call.started_at)) + .limit(200) + ) + ) + .scalars() + .all() + ) + return templates.TemplateResponse( + request, "dashboard/calls.html", ctx(request, owner, calls=calls, active="calls") + ) + + +@router.get("/dash/calls/{call_id}", response_class=HTMLResponse) +async def call_detail( + call_id: str, + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + call = ( + await session.execute( + select(Call).where( + Call.id == uuid.UUID(call_id), Call.tenant_id == owner.tenant.id + ) + ) + ).scalar_one_or_none() + if call is None: + return redirect("/dash/calls", err="Poziv nije pronađen.") + return templates.TemplateResponse( + request, "dashboard/call_detail.html", ctx(request, owner, call=call, active="calls") + ) + + +@router.get("/dash/calls/{call_id}/audio") +async def call_audio( + call_id: str, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + call = ( + await session.execute( + select(Call).where( + Call.id == uuid.UUID(call_id), Call.tenant_id == owner.tenant.id + ) + ) + ).scalar_one_or_none() + if call is None or not call.recording_path: + return Response(status_code=404) + return FileResponse(call.recording_path, media_type="audio/wav") + + +@router.get("/dash/chats", response_class=HTMLResponse) +async def chats_list( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + chats = ( + ( + await session.execute( + select(ChatSession) + .where(ChatSession.tenant_id == owner.tenant.id) + .order_by(desc(ChatSession.started_at)) + .limit(200) + ) + ) + .scalars() + .all() + ) + rows = [] + for chat in chats: + first_user = ( + await session.execute( + select(ChatMessage) + .where(ChatMessage.session_id == chat.id, ChatMessage.role == "user") + .order_by(ChatMessage.at) + .limit(1) + ) + ).scalar_one_or_none() + preview = (first_user.content[:80] + "…") if first_user and len(first_user.content) > 80 \ + else (first_user.content if first_user else "(bez poruka)") + rows.append({"chat": chat, "preview": preview}) + return templates.TemplateResponse( + request, "dashboard/chats.html", ctx(request, owner, sessions=rows, active="chats") + ) + + +@router.get("/dash/chats/{chat_id}", response_class=HTMLResponse) +async def chat_detail( + chat_id: str, + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + chat = ( + await session.execute( + select(ChatSession).where( + ChatSession.id == uuid.UUID(chat_id), + ChatSession.tenant_id == owner.tenant.id, + ) + ) + ).scalar_one_or_none() + if chat is None: + return redirect("/dash/chats", err="Razgovor nije pronađen.") + messages = ( + ( + await session.execute( + select(ChatMessage) + .where(ChatMessage.session_id == chat.id) + .order_by(ChatMessage.at) + ) + ) + .scalars() + .all() + ) + return templates.TemplateResponse( + request, + "dashboard/chat_detail.html", + ctx(request, owner, chat=chat, messages=messages, active="chats"), + ) + + +@router.get("/dash/usage", response_class=HTMLResponse) +async def usage_page( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + usage = await usage_summary(session, owner.tenant) + await session.commit() + return templates.TemplateResponse( + request, "dashboard/usage.html", ctx(request, owner, usage=usage, active="usage") + ) + + +# -- settings: profile + hours -------------------------------------------------- + + +@router.get("/dash/settings", response_class=HTMLResponse) +async def settings_page( + request: Request, + owner: OwnerContext = Depends(current_owner), +): + return templates.TemplateResponse( + request, + "dashboard/settings.html", + ctx( + request, + owner, + hours_text=hours_to_text(owner.tenant.working_hours), + day_labels=DAY_LABELS, + active="settings", + ), + ) + + +@router.post("/dash/settings") +async def settings_save( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + form = await request.form() + t = await session.merge(owner.tenant) + t.name = str(form.get("name", t.name)).strip() or t.name + t.address = str(form.get("address", "")).strip() + t.city = str(form.get("city", "")).strip() + t.phone = str(form.get("phone", "")).strip() + t.website = str(form.get("website", "")).strip() + hours = {} + try: + for key, _label in DAY_LABELS: + hours[key] = text_to_intervals(str(form.get(f"wh_{key}", ""))) + except ValueError as e: + await session.commit() + return redirect("/dash/settings", err=str(e)) + t.working_hours = hours + await session.commit() + return redirect("/dash/settings", msg="Sačuvano.") + + +@router.post("/dash/settings/parse-hours") +async def settings_parse_hours( + text: str = Form(...), + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + from gogo.dashboard.parse import parse_working_hours + + hours = await parse_working_hours(text) + if hours is None: + return redirect( + "/dash/settings", err="Nisam uspio pročitati radno vrijeme — unesite ga ručno." + ) + t = await session.merge(owner.tenant) + t.working_hours = hours + await session.commit() + return redirect("/dash/settings", msg="Radno vrijeme pročitano — provjerite i po potrebi ispravite.") + + +# -- settings: services ---------------------------------------------------------- + + +@router.get("/dash/services", response_class=HTMLResponse) +async def services_page( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + services = ( + ( + await session.execute( + select(Service) + .where(Service.tenant_id == owner.tenant.id, Service.active.is_(True)) + .order_by(Service.name) + ) + ) + .scalars() + .all() + ) + return templates.TemplateResponse( + request, + "dashboard/services.html", + ctx(request, owner, services=services, parsed=None, active="services"), + ) + + +def _num_or_none(v) -> float | None: + try: + s = str(v).strip() + return float(s) if s else None + except ValueError: + return None + + +@router.post("/dash/services") +async def service_add( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + form = await request.form() + session.add( + Service( + tenant_id=owner.tenant.id, + name=str(form.get("name", "")).strip(), + duration_min=int(form.get("duration_min") or 30), + price_min=_num_or_none(form.get("price_min")), + price_max=_num_or_none(form.get("price_max")), + home_visit=bool(form.get("home_visit")), + agent_note=str(form.get("agent_note", "")).strip(), + ) + ) + await session.commit() + return redirect("/dash/services", msg="Usluga dodana.") + + +@router.post("/dash/services/{service_id:uuid}") +async def service_update( + service_id: uuid.UUID, + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + form = await request.form() + svc = ( + await session.execute( + select(Service).where( + Service.id == service_id, Service.tenant_id == owner.tenant.id + ) + ) + ).scalar_one_or_none() + if svc is None: + return redirect("/dash/services", err="Usluga nije pronađena.") + svc.name = str(form.get("name", svc.name)).strip() or svc.name + svc.duration_min = int(form.get("duration_min") or svc.duration_min) + svc.price_min = _num_or_none(form.get("price_min")) + svc.price_max = _num_or_none(form.get("price_max")) + svc.home_visit = bool(form.get("home_visit")) + svc.agent_note = str(form.get("agent_note", "")).strip() + await session.commit() + return redirect("/dash/services", msg="Sačuvano.") + + +@router.post("/dash/services/{service_id}/delete") +async def service_delete( + service_id: str, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + svc = ( + await session.execute( + select(Service).where( + Service.id == uuid.UUID(service_id), Service.tenant_id == owner.tenant.id + ) + ) + ).scalar_one_or_none() + if svc: + svc.active = False # soft delete: history references remain valid + await session.commit() + return redirect("/dash/services", msg="Usluga uklonjena.") + + +@router.post("/dash/services/parse", response_class=HTMLResponse) +async def services_parse( + request: Request, + text: str = Form(...), + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + from gogo.dashboard.parse import parse_price_list + + parsed = await parse_price_list(text) + if not parsed: + return redirect( + "/dash/services", err="Nisam uspio pročitati cjenovnik — dodajte usluge ručno." + ) + services = ( + ( + await session.execute( + select(Service) + .where(Service.tenant_id == owner.tenant.id, Service.active.is_(True)) + .order_by(Service.name) + ) + ) + .scalars() + .all() + ) + return templates.TemplateResponse( + request, + "dashboard/services.html", + ctx(request, owner, services=services, parsed=parsed, active="services"), + ) + + +@router.post("/dash/services/import") +async def services_import( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + form = await request.form() + count = int(form.get("count") or 0) + added = 0 + for i in range(count): + if not form.get(f"row_{i}"): + continue + name = str(form.get(f"name_{i}", "")).strip() + if not name: + continue + session.add( + Service( + tenant_id=owner.tenant.id, + name=name, + duration_min=int(form.get(f"duration_{i}") or 30), + price_min=_num_or_none(form.get(f"pmin_{i}")), + price_max=_num_or_none(form.get(f"pmax_{i}")), + home_visit=bool(form.get(f"home_{i}")), + agent_note=str(form.get(f"note_{i}", "")).strip(), + ) + ) + added += 1 + await session.commit() + return redirect("/dash/services", msg=f"Dodano {added} usluga.") + + +# -- settings: scheduling --------------------------------------------------------- + + +@router.get("/dash/scheduling", response_class=HTMLResponse) +async def scheduling_page( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + t = owner.tenant + connection = ( + await session.execute( + select(CalendarConnection).where(CalendarConnection.tenant_id == t.id) + ) + ).scalar_one_or_none() + mappings_rows = ( + (await session.execute(select(CalendarMapping).where(CalendarMapping.tenant_id == t.id))) + .scalars() + .all() + ) + mappings = {} + for m in mappings_rows: + mappings["default" if m.service_id is None else str(m.service_id)] = m.google_calendar_id + services = ( + ( + await session.execute( + select(Service) + .where(Service.tenant_id == t.id, Service.active.is_(True)) + .order_by(Service.name) + ) + ) + .scalars() + .all() + ) + s = get_settings() + return templates.TemplateResponse( + request, + "dashboard/scheduling.html", + ctx( + request, + owner, + connection=connection, + mappings=mappings, + services=services, + google_configured=bool(s.google_client_id and s.google_client_secret), + partner_ok=None, + last_sync=None, + active="scheduling", + ), + ) + + +@router.post("/dash/scheduling/mappings") +async def scheduling_mappings_save( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + form = await request.form() + t = owner.tenant + existing = ( + (await session.execute(select(CalendarMapping).where(CalendarMapping.tenant_id == t.id))) + .scalars() + .all() + ) + for m in existing: + await session.delete(m) + default = str(form.get("default", "")).strip() + if default: + session.add( + CalendarMapping(tenant_id=t.id, service_id=None, google_calendar_id=default) + ) + for key, value in form.items(): + if key.startswith("svc_") and str(value).strip(): + session.add( + CalendarMapping( + tenant_id=t.id, + service_id=uuid.UUID(key.removeprefix("svc_")), + google_calendar_id=str(value).strip(), + ) + ) + await session.commit() + return redirect("/dash/scheduling", msg="Sačuvano.") + + +@router.post("/dash/scheduling/buffers") +async def scheduling_buffers_save( + min_notice_hours: int = Form(...), + max_days_ahead: int = Form(...), + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + t = await session.merge(owner.tenant) + t.min_notice_hours = max(0, min(72, min_notice_hours)) + t.max_days_ahead = max(1, min(60, max_days_ahead)) + await session.commit() + return redirect("/dash/scheduling", msg="Sačuvano.") + + +# Google OAuth (free/busy read-only scope, §8.1) +GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +FREEBUSY_SCOPE = "https://www.googleapis.com/auth/calendar.freebusy" + + +@router.get("/dash/scheduling/google/start") +async def google_oauth_start(owner: OwnerContext = Depends(current_owner)): + from urllib.parse import urlencode + + s = get_settings() + params = { + "client_id": s.google_client_id, + "redirect_uri": f"{s.base_url}/oauth/google/callback", + "response_type": "code", + "scope": f"openid email {FREEBUSY_SCOPE}", + "access_type": "offline", + "prompt": "consent", + "state": str(owner.tenant.id), + } + return redirect(f"{GOOGLE_AUTH_URL}?{urlencode(params)}") + + +@router.get("/oauth/google/callback") +async def google_oauth_callback( + code: str = "", + state: str = "", + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + import httpx + + from gogo.crypto import encrypt + + if not code or state != str(owner.tenant.id): + return redirect("/dash/scheduling", err="Google prijava nije uspjela.") + s = get_settings() + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + GOOGLE_TOKEN_URL, + data={ + "client_id": s.google_client_id, + "client_secret": s.google_client_secret, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": f"{s.base_url}/oauth/google/callback", + }, + ) + if resp.status_code != 200: + return redirect("/dash/scheduling", err="Google prijava nije uspjela (token).") + tokens = resp.json() + email = "" + id_token = tokens.get("id_token", "") + if id_token.count(".") == 2: # decode JWT payload without verification (email is cosmetic) + import base64 + + try: + payload = id_token.split(".")[1] + payload += "=" * (-len(payload) % 4) + email = json.loads(base64.urlsafe_b64decode(payload)).get("email", "") + except Exception: # noqa: BLE001 + email = "" + from datetime import UTC, datetime, timedelta + + token_data = { + "access_token": tokens.get("access_token", ""), + "refresh_token": tokens.get("refresh_token", ""), + "expiry": ( + datetime.now(UTC) + timedelta(seconds=tokens.get("expires_in", 3600)) + ).isoformat(), + } + existing = ( + await session.execute( + select(CalendarConnection).where(CalendarConnection.tenant_id == owner.tenant.id) + ) + ).scalar_one_or_none() + if existing: + existing.google_account = email or existing.google_account + existing.token_data_encrypted = encrypt(json.dumps(token_data)) + existing.status = "connected" + else: + session.add( + CalendarConnection( + tenant_id=owner.tenant.id, + google_account=email or "(nepoznat nalog)", + scopes=[FREEBUSY_SCOPE], + token_data_encrypted=encrypt(json.dumps(token_data)), + ) + ) + await session.commit() + return redirect("/dash/scheduling", msg="Google kalendar povezan.") + + +@router.post("/dash/scheduling/google/disconnect") +async def google_disconnect( + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + existing = ( + await session.execute( + select(CalendarConnection).where(CalendarConnection.tenant_id == owner.tenant.id) + ) + ).scalar_one_or_none() + if existing: + await session.delete(existing) + await session.commit() + return redirect("/dash/scheduling", msg="Veza s Google kalendarom prekinuta.") + + +# -- settings: telephony ----------------------------------------------------------- + + +@router.get("/dash/telephony", response_class=HTMLResponse) +async def telephony_page( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + t = owner.tenant + number = ( + await session.execute(select(PhoneNumber).where(PhoneNumber.tenant_id == t.id)) + ).scalar_one_or_none() + workers = ( + ( + await session.execute( + select(Worker) + .where(Worker.tenant_id == t.id, Worker.active.is_(True)) + .order_by(Worker.name) + ) + ) + .scalars() + .all() + ) + forwarding = ( + [forwarding_codes(op, number.msisdn, t.ring_timeout_s) for op in OPERATORS] + if number + else [] + ) + return templates.TemplateResponse( + request, + "dashboard/telephony.html", + ctx(request, owner, number=number, workers=workers, forwarding=forwarding, + active="telephony"), + ) + + +@router.post("/dash/telephony") +async def telephony_save( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + form = await request.form() + t = await session.merge(owner.tenant) + t.ring_first_enabled = bool(form.get("ring_first_enabled")) + t.ring_timeout_s = max(5, min(60, int(form.get("ring_timeout_s") or 12))) + strategy = str(form.get("ring_strategy", "ring_all")) + t.ring_strategy = strategy if strategy in ("ring_all", "sequential") else "ring_all" + await session.commit() + return redirect("/dash/telephony", msg="Sačuvano.") + + +@router.post("/dash/workers") +async def worker_add( + name: str = Form(...), + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + worker = Worker( + tenant_id=owner.tenant.id, + name=name.strip(), + sip_username=sip.make_sip_username(owner.tenant.slug, name), + sip_password=sip.make_sip_password(), + ) + session.add(worker) + await session.commit() + return redirect(f"/dash/workers/{worker.id}/setup", msg=f"{worker.name} dodan/a.") + + +async def _load_worker(session, owner: OwnerContext, worker_id: str) -> Worker | None: + try: + wid = uuid.UUID(worker_id) + except ValueError: + return None + return ( + await session.execute( + select(Worker).where(Worker.id == wid, Worker.tenant_id == owner.tenant.id) + ) + ).scalar_one_or_none() + + +@router.get("/dash/workers/{worker_id}/setup", response_class=HTMLResponse) +async def worker_setup( + worker_id: str, + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + worker = await _load_worker(session, owner, worker_id) + if worker is None: + return redirect("/dash/telephony", err="Radnik nije pronađen.") + return templates.TemplateResponse( + request, + "dashboard/worker_setup.html", + ctx(request, owner, worker=worker, domain=sip.sip_domain(), active="telephony"), + ) + + +@router.get("/dash/workers/{worker_id}/qr.png") +async def worker_qr( + worker_id: str, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + worker = await _load_worker(session, owner, worker_id) + if worker is None: + return Response(status_code=404) + png = sip.qr_png(sip.provisioning_text(worker.sip_username, worker.sip_password)) + return Response(png, media_type="image/png") + + +@router.post("/dash/workers/{worker_id}/delete") +async def worker_delete( + worker_id: str, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + worker = await _load_worker(session, owner, worker_id) + if worker: + worker.active = False + await session.commit() + return redirect("/dash/telephony", msg="Uklonjeno.") + + +# -- settings: agent + notifications ------------------------------------------------- + + +@router.get("/dash/agent", response_class=HTMLResponse) +async def agent_page( + request: Request, + owner: OwnerContext = Depends(current_owner), +): + return templates.TemplateResponse( + request, + "dashboard/agent.html", + ctx( + request, + owner, + voices=TTS_VOICES, + base_url=get_settings().base_url, + greeting=compose_greeting(owner.tenant, "voice"), + active="agent", + ), + ) + + +@router.post("/dash/agent") +async def agent_save( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + form = await request.form() + t = await session.merge(owner.tenant) + voice = str(form.get("tts_voice", "")) + if voice in {v["id"] for v in TTS_VOICES}: + t.tts_voice = voice + mode = str(form.get("price_mode", "exact")) + t.price_mode = mode if mode in ("exact", "range", "on_request") else "exact" + t.agent_notes = str(form.get("agent_notes", "")).strip() + await session.commit() + return redirect("/dash/agent", msg="Sačuvano.") + + +@router.get("/dash/notifications", response_class=HTMLResponse) +async def notifications_page( + request: Request, + owner: OwnerContext = Depends(current_owner), +): + t = owner.tenant + sms_rows = [ + ("confirmation", "Potvrda termina", DEFAULT_TEMPLATES["confirmation"]), + ("rejection", "Odbijanje zahtjeva", DEFAULT_TEMPLATES["rejection"]), + ("request_received", "Zahtjev zaprimljen", DEFAULT_TEMPLATES["request_received"]), + ("missed_call", "Propušten poziv", DEFAULT_TEMPLATES["missed_call"]), + ("expiry", "Istek zahtjeva", DEFAULT_TEMPLATES["expiry"]), + ] + return templates.TemplateResponse( + request, + "dashboard/notifications.html", + ctx( + request, + owner, + emails_text="\n".join(t.notify_emails or []), + overrides=t.sms_templates or {}, + sms_rows=sms_rows, + active="notifications", + ), + ) + + +@router.post("/dash/notifications") +async def notifications_save( + request: Request, + owner: OwnerContext = Depends(current_owner), + session: AsyncSession = Depends(get_session), +): + form = await request.form() + t = await session.merge(owner.tenant) + emails = [ + e.strip() + for e in str(form.get("notify_emails", "")).replace(",", "\n").splitlines() + if e.strip() + ] + t.notify_emails = emails + t.sms_request_received = bool(form.get("sms_request_received")) + overrides = {} + for key in DEFAULT_TEMPLATES: + val = str(form.get(f"sms_{key}", "")).strip() + if val and val != DEFAULT_TEMPLATES[key]: + overrides[key] = val + t.sms_templates = overrides + await session.commit() + return redirect("/dash/notifications", msg="Sačuvano.") + + +# -- widget demo page ------------------------------------------------------------ + + +@router.get("/widget-demo/{key}", response_class=HTMLResponse) +async def widget_demo(key: str, request: Request): + return templates.TemplateResponse(request, "widget_demo.html", {"request": request, "key": key}) diff --git a/gogo/db.py b/gogo/db.py index 5da03ac..c9c02b6 100644 --- a/gogo/db.py +++ b/gogo/db.py @@ -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 diff --git a/gogo/main.py b/gogo/main.py index 53d586c..9623db2 100644 --- a/gogo/main.py +++ b/gogo/main.py @@ -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 diff --git a/gogo/metering.py b/gogo/metering.py new file mode 100644 index 0000000..a349934 --- /dev/null +++ b/gogo/metering.py @@ -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, + } diff --git a/gogo/seed.py b/gogo/seed.py index d616ed6..bb6dc77 100644 --- a/gogo/seed.py +++ b/gogo/seed.py @@ -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"]], diff --git a/gogo/static/widget.js b/gogo/static/widget.js new file mode 100644 index 0000000..e8eb8ca --- /dev/null +++ b/gogo/static/widget.js @@ -0,0 +1,153 @@ +/* Gogo Telefon chat widget — embeddable, dependency-free (§7). + * + * Usage on the salon's site: + * + */ +(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 = + '
Virtuelni asistentGogo Telefon — odgovaramo odmah
' + + '
' + + '
' + + '' + + '' + + "
"; + + 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 = ""; + }); +})(); diff --git a/gogo/telephony/__init__.py b/gogo/telephony/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gogo/telephony/forwarding.py b/gogo/telephony/forwarding.py new file mode 100644 index 0000000..8e63ff1 --- /dev/null +++ b/gogo/telephony/forwarding.py @@ -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#", + ) diff --git a/gogo/telephony/sip.py b/gogo/telephony/sip.py new file mode 100644 index 0000000..df25e77 --- /dev/null +++ b/gogo/telephony/sip.py @@ -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() diff --git a/gogo/templates/admin/_nav.html b/gogo/templates/admin/_nav.html new file mode 100644 index 0000000..15fc561 --- /dev/null +++ b/gogo/templates/admin/_nav.html @@ -0,0 +1,12 @@ +
+ + +
{{ admin.email }} · Odjava
+
diff --git a/gogo/templates/admin/health.html b/gogo/templates/admin/health.html new file mode 100644 index 0000000..5ed1efb --- /dev/null +++ b/gogo/templates/admin/health.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Sistem — Gogo Admin{% endblock %} +{% block nav %}{% include "admin/_nav.html" %}{% endblock %} +{% block content %} +

Zdravlje sistema (§11)

+
+
+

Servisi

+ + + + + + +
Baza podataka{{ 'OK' if db_ok else 'GREŠKA' }}
LLM API ključ{{ 'postavljen' if llm_configured else 'nije postavljen' }}
Email (SMTP)
SMS provajder{{ sms_mode }}
Voice pipeline{{ voice_status }}
+
+
+

Greške u slanju (zadnjih 7 dana)

+ + + +
Email greške{{ email_errors }}
SMS greške{{ sms_errors }}
+
+
+{% if failed_rows %} +
+

Zadnje greške

+ + + {% for f in failed_rows %} + + + {% endfor %} +
VrijemeTipPrimaGreška
{{ f.at.strftime('%d.%m. %H:%M') }}{{ f.kind }}{{ f.to }}{{ f.error[:120] }}
+
+{% endif %} +{% endblock %} diff --git a/gogo/templates/admin/login.html b/gogo/templates/admin/login.html new file mode 100644 index 0000000..d46857e --- /dev/null +++ b/gogo/templates/admin/login.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Admin prijava — Gogo Telefon{% endblock %} +{% block content %} +
+
+

Gogo Admin

+
+ + + + +
+
+
+
+{% endblock %} diff --git a/gogo/templates/admin/numbers.html b/gogo/templates/admin/numbers.html new file mode 100644 index 0000000..6609d4f --- /dev/null +++ b/gogo/templates/admin/numbers.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Brojevi/SIM — Gogo Admin{% endblock %} +{% block nav %}{% include "admin/_nav.html" %}{% endblock %} +{% block content %} +

Brojevi i SIM kartice (GSM gateway)

+
+ + + {% for row in rows %} + + + + + + + + + + {% endfor %} + + + + + + + + + +
BrojGateway portOperaterSIM statusSalon
{{ row.n.msisdn }} + + + + {{ row.tenant_name or '—' }} +
+
+{% endblock %} diff --git a/gogo/templates/admin/playground.html b/gogo/templates/admin/playground.html new file mode 100644 index 0000000..4375e9e --- /dev/null +++ b/gogo/templates/admin/playground.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Playground — Gogo Admin{% endblock %} +{% block nav %}{% include "admin/_nav.html" %}{% endblock %} +{% block content %} +

Test playground (§11)

+

Tekstualni razgovor s asistentom pojedinog salona — koristi njegov sastavljeni +prompt, usluge i provajdera termina. Zahtjevi kreirani ovdje se zaista šalju (koristite +demo salon za igranje).

+ +
+
+ + + {% if history_json != '[]' %}{% endif %} + + + + {% if transcript %} +
+ {% for t in transcript %} +
{{ t.text }}
+ {% if t.tool_calls %}
⚙ {% for tc in t.tool_calls %}{{ tc.name }}({{ tc.input | tojson }}) {% endfor %}
{% endif %} + {% endfor %} +
+ {% endif %} + + + + +
+ + Novi razgovor +
+
+ {% if error %}
{{ error }}
{% endif %} +
+ +{% if system_prompt %} +
+ Sastavljeni system prompt +
{{ system_prompt }}
+
+{% endif %} +{% endblock %} diff --git a/gogo/templates/admin/templates.html b/gogo/templates/admin/templates.html new file mode 100644 index 0000000..7ee61a5 --- /dev/null +++ b/gogo/templates/admin/templates.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}Prompt šablon — Gogo Admin{% endblock %} +{% block nav %}{% include "admin/_nav.html" %}{% endblock %} +{% block content %} +

Globalni prompt šablon (§6.3)

+

Šablon vrijedi za sve salone odjednom. Dostupne oznake: +{salon_profile} {working_hours} {services_table} +{notes} {price_mode} {greeting} +{current_datetime}

+ +
+

Nova verzija

+
+ + + + + +
+
+ +
+

Verzije

+ + + {% for v in versions %} + + + + + + + + {% else %} + + {% endfor %} +
VerzijaDatumBilješkaStatus
v{{ v.version }}{{ v.created_at.strftime('%d.%m.%Y. %H:%M') }}{{ v.notes }}{% if v.published %}aktivna{% endif %} + Učitaj u editor + {% if not v.published %} +
+ +
+ {% endif %} +
Nema sačuvanih verzija — koristi se ugrađeni zadani šablon.
+
+{% endblock %} diff --git a/gogo/templates/admin/tenant_edit.html b/gogo/templates/admin/tenant_edit.html new file mode 100644 index 0000000..2f3eb86 --- /dev/null +++ b/gogo/templates/admin/tenant_edit.html @@ -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 %} +

← Svi saloni

+

{{ t.name if t else 'Novi salon' }}

+ +
+
+
+

Osnovno

+ + + + + + + {% if not t %} + + + + + {% endif %} +
+
+

Plan i naplata (§12)

+ + + + + + + + + + + +
+
+ +
+

Provajder termina (§8)

+ + +
+
+ + + + + + +
+
+ + + + {% if t %} + + {{ base_url }}/webhooks/partner/{{ t.id }} + {% endif %} +
+
+
+ +
+

Broj / SIM

+ + +

Nove brojeve dodajete pod Brojevi/SIM.

+
+ +
+

Prompt override (samo za neobične salone — inače prazno)

+ +
+ + +
+ +{% if t and t.scheduling_provider == 'partner_api' %} +
+

Test veze s partnerom (§8.3)

+

Pokreće živi GET /availability i dry-run + POST /booking-requests, prikazuje sirove odgovore.

+
+ +
+ {% if test_result %}
{{ test_result }}
{% endif %} +
+{% endif %} +{% endblock %} diff --git a/gogo/templates/admin/tenants.html b/gogo/templates/admin/tenants.html new file mode 100644 index 0000000..1d5794b --- /dev/null +++ b/gogo/templates/admin/tenants.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Saloni — Gogo Admin{% endblock %} +{% block nav %}{% include "admin/_nav.html" %}{% endblock %} +{% block content %} +

Saloni + Novi salon

+
+ + + {% for row in rows %} + + + + + + + + + + {% endfor %} +
SalonProvajderBrojPlaćeno doMinute (mj)Status
{{ row.tenant.name }}
{{ row.tenant.city }} · {{ row.tenant.slug }}
{{ row.tenant.scheduling_provider }}{{ row.msisdn or '—' }}{{ row.tenant.paid_until.strftime('%d.%m.%Y.') if row.tenant.paid_until else '—' }}{{ row.used_minutes }}/{{ row.tenant.included_minutes }}{{ row.tenant.status }} + Uredi +
+ +
+
+
+{% endblock %} diff --git a/gogo/templates/admin/usage.html b/gogo/templates/admin/usage.html new file mode 100644 index 0000000..be347b6 --- /dev/null +++ b/gogo/templates/admin/usage.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Potrošnja — Gogo Admin{% endblock %} +{% block nav %}{% include "admin/_nav.html" %}{% endblock %} +{% block content %} +

Potrošnja po salonima — {{ month }}

+
+ + + + {% for r in rows %} + + + + + + + + + + {% endfor %} +
SalonMinute asistentaPoziviSMSChatZahtjevi (mj)Procj. trošak (LLM+TTS)
{{ r.tenant.name }}{{ r.used_minutes }}/{{ r.tenant.included_minutes }} + {% if r.over_100 %}100%+ + {% elif r.over_80 %}80%+{% endif %}{{ r.calls }}{{ r.sms }}{{ r.chats }}{{ r.requests }}~{{ r.est_cost_km }} KM
+

Procjena troška: {{ cost_note }}

+
+{% endblock %} diff --git a/gogo/templates/base.html b/gogo/templates/base.html new file mode 100644 index 0000000..56d2b07 --- /dev/null +++ b/gogo/templates/base.html @@ -0,0 +1,73 @@ + + + + + +{% block title %}Gogo Telefon{% endblock %} + + + +{% block nav %}{% endblock %} +
+{% if request.query_params.get('msg') %}
{{ request.query_params.get('msg') }}
{% endif %} +{% if request.query_params.get('err') %}
{{ request.query_params.get('err') }}
{% endif %} +{% block content %}{% endblock %} +
+ + diff --git a/gogo/templates/dashboard/_nav.html b/gogo/templates/dashboard/_nav.html new file mode 100644 index 0000000..986404b --- /dev/null +++ b/gogo/templates/dashboard/_nav.html @@ -0,0 +1,19 @@ +
+ + +
+ {{ owner.tenant.name }}{% if owner.impersonated_by %} (admin pregled){% endif %} + · Odjava +
+
diff --git a/gogo/templates/dashboard/agent.html b/gogo/templates/dashboard/agent.html new file mode 100644 index 0000000..487227f --- /dev/null +++ b/gogo/templates/dashboard/agent.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Asistent — {{ owner.tenant.name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

Virtuelni asistent

+ +
+
+
+

Glas i cijene

+ + + + +
+
+

Napomene za asistenta

+

Činjenice o salonu koje asistent smije reći klijentima + (npr. „parking iza zgrade", „ne primamo djecu ispod 7 godina").

+ +
+
+ +
+ +
+

Chat widget za vašu web stranicu

+

Zalijepite ovaj red koda pred kraj <body> taga vaše + stranice (ili ga pošaljite onome ko vam održava web):

+
<script src="{{ base_url }}/widget.js" data-gogo-key="{{ t.widget_public_key }}" async></script>
+

Isprobajte razgovor na demo stranici.

+
+ +
+

Pozdravna poruka

+

Pozdrav i ponašanje asistenta održava Gogo tim centralno — uvijek uključuje + obavještenje o snimanju razgovora. Trenutni pozdrav:

+
+ {{ greeting }} +
+
+{% endblock %} diff --git a/gogo/templates/dashboard/call_detail.html b/gogo/templates/dashboard/call_detail.html new file mode 100644 index 0000000..a187064 --- /dev/null +++ b/gogo/templates/dashboard/call_detail.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Poziv — Gogo Telefon{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

← Svi pozivi

+

Poziv {{ call.started_at.strftime('%d.%m.%Y. %H:%M') }} + {{ outcome_labels.get(call.outcome, call.outcome) }}

+
+

Broj: {{ call.caller_msisdn or '—' }} · + Trajanje: {{ call.duration_s }}s · + Naplaćene minute asistenta: {{ (call.agent_seconds_charged / 60) | round(1) }} min

+ {% if call.recording_path %} + + {% else %}

Snimka nije dostupna (obrisana nakon isteka perioda čuvanja ili poziv nije snimljen).

{% endif %} +
+{% if call.transcript %} +
+

Transkript

+
+ {% for t in call.transcript %} +
{{ t.text }}
+ {% if t.tool_calls %}
⚙ {% for tc in t.tool_calls %}{{ tc.name }} {% endfor %}
{% endif %} + {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/gogo/templates/dashboard/calls.html b/gogo/templates/dashboard/calls.html new file mode 100644 index 0000000..838de2e --- /dev/null +++ b/gogo/templates/dashboard/calls.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Pozivi — {{ owner.tenant.name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

Historija poziva

+
+ + + {% for c in calls %} + + + + + + + + {% else %} + + {% endfor %} +
VrijemeBrojTrajanjeIshod
{{ c.started_at.strftime('%d.%m. %H:%M') }}{{ c.caller_msisdn or '—' }}{{ c.duration_s // 60 }}:{{ '%02d' % (c.duration_s % 60) }}{{ outcome_labels.get(c.outcome, c.outcome) }}Detalji
Još nema zabilježenih poziva.
+
+{% endblock %} diff --git a/gogo/templates/dashboard/chat_detail.html b/gogo/templates/dashboard/chat_detail.html new file mode 100644 index 0000000..26114e0 --- /dev/null +++ b/gogo/templates/dashboard/chat_detail.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Chat razgovor — Gogo Telefon{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

← Svi razgovori

+

Chat {{ chat.started_at.strftime('%d.%m.%Y. %H:%M') }}

+
+
+ {% for m in messages %} +
{{ m.content }}
+ {% endfor %} +
+
+{% endblock %} diff --git a/gogo/templates/dashboard/chats.html b/gogo/templates/dashboard/chats.html new file mode 100644 index 0000000..b9b59bb --- /dev/null +++ b/gogo/templates/dashboard/chats.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Chat — {{ owner.tenant.name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

Chat razgovori

+
+ + + {% for row in sessions %} + + + + + + + {% else %} + + {% endfor %} +
VrijemePorukaIshod
{{ row.chat.started_at.strftime('%d.%m. %H:%M') }}{{ row.preview }}{% if row.chat.outcome %}{{ outcome_labels.get(row.chat.outcome, row.chat.outcome) }}{% endif %}Detalji
Još nema chat razgovora. Postavite widget na vašu web + stranicu — kod se nalazi pod Asistent.
+
+{% endblock %} diff --git a/gogo/templates/dashboard/login.html b/gogo/templates/dashboard/login.html new file mode 100644 index 0000000..4b35863 --- /dev/null +++ b/gogo/templates/dashboard/login.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Prijava — Gogo Telefon{% endblock %} +{% block content %} +
+
+

Gogo Telefon

+

Prijava za vlasnike salona

+
+ + + + +
+
+

Nemate nalog? Gogo Telefon nalozi se otvaraju + uz pomoć našeg tima — javite nam se.

+
+
+{% endblock %} diff --git a/gogo/templates/dashboard/notifications.html b/gogo/templates/dashboard/notifications.html new file mode 100644 index 0000000..d391167 --- /dev/null +++ b/gogo/templates/dashboard/notifications.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Obavještenja — {{ owner.tenant.name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

Obavještenja

+
+
+

Email za zahtjeve

+

Na ove adrese stižu novi zahtjevi za termine (jedna po redu):

+ + +
+ +
+

SMS poruke klijentima

+

Možete prilagoditi tekst poruka. Dostupne oznake: + {salon}, {usluga}, {dan}, {datum}, + {vrijeme}, {link}. Ostavite prazno za standardni tekst.

+ {% for key, label, default in sms_rows %} + + + {% endfor %} +
+ +
+{% endblock %} diff --git a/gogo/templates/dashboard/request_detail.html b/gogo/templates/dashboard/request_detail.html new file mode 100644 index 0000000..282b6ce --- /dev/null +++ b/gogo/templates/dashboard/request_detail.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Zahtjev — {{ req.client_name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

← Svi zahtjevi

+

Zahtjev — {{ req.client_name }} + {{ status_labels[req.status] }}

+ +
+
+

Podaci

+ + + + + + {% if req.home_visit %}{% endif %} + {% if req.time_preference_text %}{% endif %} + + {% if req.resolved_at %}{% endif %} +
Klijent{{ req.client_name }}
Telefon{{ req.client_phone }}
Usluga{{ service_name }}
Izvor{{ 'telefonski poziv' if req.source=='voice' else 'web chat' }}
Kućna posjetaDA — {{ req.address or 'adresa nije navedena' }}
Željeno vrijeme{{ req.time_preference_text }}
Zaprimljen{{ req.created_at.strftime('%d.%m.%Y. %H:%M') }}
Zatvoren{{ req.resolved_at.strftime('%d.%m.%Y. %H:%M') }}
+ {% if req.summary %}

Sažetak: {{ req.summary }}

{% endif %} +
+ +
+

Akcije

+ {% if req.status == 'pending' %} +
+ +
+

Klijentu se ne šalje SMS — dogovorili ste se direktno.

+ {% for s in slots %} +
+ +
+ {% endfor %} +
+ + +
+ {% else %} +

Zahtjev je zatvoren. + {% if req.confirmed_slot %}Potvrđen termin: {{ confirmed_label }}.{% endif %}

+ {% endif %} +
+
+ +{% if transcript %} +
+

Razgovor

+
+ {% for t in transcript %} +
{{ t.text }}
+ {% if t.tool_calls %}
⚙ {% for tc in t.tool_calls %}{{ tc.name }} {% endfor %}
{% endif %} + {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/gogo/templates/dashboard/requests.html b/gogo/templates/dashboard/requests.html new file mode 100644 index 0000000..977c093 --- /dev/null +++ b/gogo/templates/dashboard/requests.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Zahtjevi — {{ owner.tenant.name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

Zahtjevi za termine

+{% if usage.over_80 %} + +{% endif %} +
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
DatumKlijentUslugaTerminiStatus
{{ r.req.created_at.strftime('%d.%m. %H:%M') }}
+ {{ 'poziv' if r.req.source=='voice' else 'chat' }}
{{ r.req.client_name }}
{{ r.req.client_phone }}
{{ r.service_name }}{% if r.req.home_visit %}
🏠 {{ r.req.address or 'kućna posjeta' }}{% endif %}
{% for s in r.slots %}{{ s }}
{% endfor %} + {% if r.req.time_preference_text and not r.slots %}{{ r.req.time_preference_text }}{% endif %}
{{ status_labels[r.req.status] }} + Detalji + {% if r.req.status == 'pending' %} +
+ +
+ {% endif %} +
Još nema zahtjeva. Kad asistent primi zahtjev za termin, + pojaviće se ovdje i stići će vam email.
+
+{% endblock %} diff --git a/gogo/templates/dashboard/scheduling.html b/gogo/templates/dashboard/scheduling.html new file mode 100644 index 0000000..e3ee2e4 --- /dev/null +++ b/gogo/templates/dashboard/scheduling.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block title %}Kalendar — {{ owner.tenant.name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

Kalendar i termini

+ +{% if t.scheduling_provider == 'partner_api' %} +
+

Vaš booking softver

+

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.

+ + + + +
Status veze{% if partner_ok is none %}nije testirano + {% elif partner_ok %}povezano + {% else %}greška veze{% endif %}
Zadnja sinhronizacija usluga{{ last_sync or '—' }}
+

Podešavanje veze vrši Gogo tim — javite nam se ako nešto ne radi.

+
+ +{% elif t.scheduling_provider == 'google_calendar' %} +
+

Google kalendar

+ {% if connection %} +

Povezan nalog: {{ connection.google_account }} + aktivno

+

Gogo čita samo zauzetost (slobodno/zauzeto) — nikad sadržaj vaših + događaja i nikad ne upisuje u kalendar.

+
+ +
+ {% else %} +

Povežite Google nalog da bi asistent nudio termine samo kad ste zaista + slobodni. Gogo traži samo pristup zauzetosti (free/busy) — ne vidi sadržaj vaših + događaja i ne može ništa upisivati.

+ {% if google_configured %} + Poveži Google kalendar + {% else %} + + {% endif %} + {% endif %} +
+ +
+

Kalendari po uslugama

+

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 primary.

+
+ + + + + {% for s in services %} + + + {% endfor %} +
UslugaGoogle Calendar ID
Zadani kalendar (sve usluge)
{{ s.name }}
+ +
+
+ +
+

Pravila nuđenja termina

+
+
+
+ + +
+
+ + +
+
+ +
+
+{% else %} +

Demo režim (mock kalendar) — termini se računaju samo iz +radnog vremena.

+{% endif %} +{% endblock %} diff --git a/gogo/templates/dashboard/services.html b/gogo/templates/dashboard/services.html new file mode 100644 index 0000000..9785289 --- /dev/null +++ b/gogo/templates/dashboard/services.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% block title %}Usluge — {{ owner.tenant.name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

Usluge i cjenovnik

+ +
+ + + {% for s in services %} + + + + + + + + + + {% endfor %} + + + + + + + + + + +
UslugaTrajanjeCijena (KM)Kućna posjetaNapomena za asistenta
+ – + + + + +
+ +
+
+ – + +
+
+ +
+

Zalijepi cjenovnik

+

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.

+
+ + +
+
+ +{% if parsed %} +
+

Provjeri i potvrdi

+

Ovako je Gogo pročitao vaš cjenovnik. Ispravite šta treba pa potvrdite — + usluge će biti dodane postojećim.

+
+ + + {% for p in parsed %} + + + + + + + + + + {% endfor %} +
UslugaTrajanje (min)Cijena odCijena doKućnaNapomena
+ + +
+
+{% endif %} +{% endblock %} diff --git a/gogo/templates/dashboard/settings.html b/gogo/templates/dashboard/settings.html new file mode 100644 index 0000000..b94c666 --- /dev/null +++ b/gogo/templates/dashboard/settings.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Salon — {{ owner.tenant.name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

Profil salona

+
+
+
+

Osnovni podaci

+ + + + + + + + + + +
+
+

Radno vrijeme

+

Format: 09:00-13:00, 14:00-18:00 (pauza = dva intervala). + Prazno = neradni dan.

+ {% for key, label in day_labels %} + + + {% endfor %} +
+
+ +
+ +
+

Zalijepi radno vrijeme

+

Zalijepite radno vrijeme kako god ga imate zapisano (sa web stranice, + Facebooka…) — Gogo će ga pročitati, a vi samo provjerite i sačuvate.

+
+ + +
+
+{% endblock %} diff --git a/gogo/templates/dashboard/telephony.html b/gogo/templates/dashboard/telephony.html new file mode 100644 index 0000000..9686345 --- /dev/null +++ b/gogo/templates/dashboard/telephony.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}Telefonija — {{ owner.tenant.name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

Telefonija

+ +
+

Vaš Gogo broj

+ {% if number %} +

{{ number.msisdn }}

+

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.

+

Uključivanje prosljeđivanja

+

Ukucajte ove kodove na telefonu na kojem je SIM vašeg salonskog broja + (poziv se pokreće tipkom za zvanje). Odaberite svog operatera:

+ {% for f in forwarding %} +
+ {{ f.operator_label }} + + + + + + +
Kad se ne javite ({{ t.ring_timeout_s }}s){{ f.activate_no_answer }}
Kad je zauzeto{{ f.activate_busy }}
Kad ste nedostupni{{ f.activate_unreachable }}
Isključi sva prosljeđivanja{{ f.deactivate_all }}
Provjera statusa{{ f.check_status }}
+
+ {% endfor %} + {% else %} + + {% endif %} +
+ +
+

Zvonjenje osoblju prije asistenta

+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Osoblje (aplikacija za javljanje)

+

Svaka radnica instalira besplatnu aplikaciju (preporučujemo + Linphone, može i Zoiper/Groundwire) i skenira svoj QR kod ili prepiše podatke. + Kad zazvoni salon, zazvoni i aplikacija.

+ + + {% for w in workers %} + + + + + + + {% else %} + + {% endfor %} +
ImeSIP korisničko imePodešavanje
{{ w.name }}{{ w.sip_username }}QR + podaci
+
Još nema dodanog osoblja.
+
+ + +
+
+{% endblock %} diff --git a/gogo/templates/dashboard/usage.html b/gogo/templates/dashboard/usage.html new file mode 100644 index 0000000..4cc3e75 --- /dev/null +++ b/gogo/templates/dashboard/usage.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Potrošnja — {{ owner.tenant.name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

Potrošnja — {{ usage.month }}

+
+

Minute virtualnog asistenta

+
+

{{ usage.used_minutes }} od {{ usage.included_minutes }} uključenih minuta + ({{ usage.pct }}%)

+ {% if usage.over_100 %} + + {% endif %} +

Vrijeme kad se javi vaše osoblje ne troši minute; chat se ne naplaćuje po + minutama.

+
+
+

Pozivi asistenta

{{ usage.calls }}

+

Poslani SMS

{{ usage.sms_sent }}

+

Chat razgovori

{{ usage.chat_sessions }}

+
+{% if owner.tenant.paid_until %} +

Pretplata

+

Plaćeno do: {{ owner.tenant.paid_until.strftime('%d.%m.%Y.') }} — +paket „Gogo Start" ({{ owner.tenant.included_minutes }} min/mj).

+{% endif %} +{% endblock %} diff --git a/gogo/templates/dashboard/worker_setup.html b/gogo/templates/dashboard/worker_setup.html new file mode 100644 index 0000000..7d10274 --- /dev/null +++ b/gogo/templates/dashboard/worker_setup.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Podešavanje aplikacije — {{ worker.name }}{% endblock %} +{% block nav %}{% include "dashboard/_nav.html" %}{% endblock %} +{% block content %} +

← Telefonija

+

Aplikacija za: {{ worker.name }}

+
+
+

Skeniraj QR kod

+ QR za podešavanje +
+
+

Ili prepiši ručno

+ + + + + +
Server / domena{{ domain }}
Korisničko ime{{ worker.sip_username }}
Lozinka{{ worker.sip_password }}
TransportUDP, port 5060
+

Koraci u Linphone aplikaciji: Assistant → + „Use SIP account" → unesite podatke → Save. Provjerite da aplikacija ima dozvolu za + obavještenja da bi zvonila.

+
+
+{% endblock %} diff --git a/gogo/templates/widget_demo.html b/gogo/templates/widget_demo.html new file mode 100644 index 0000000..f5b3c5d --- /dev/null +++ b/gogo/templates/widget_demo.html @@ -0,0 +1,15 @@ + + + + + +Gogo widget demo + + + +

Demo stranica salona

+

Ovako izgleda Gogo chat widget na web stranici salona — kliknite na ljubičasti balončić +u donjem desnom uglu.

+ + + diff --git a/gogo/web.py b/gogo/web.py new file mode 100644 index 0000000..9cca709 --- /dev/null +++ b/gogo/web.py @@ -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 diff --git a/tests/test_chat_widget.py b/tests/test_chat_widget.py new file mode 100644 index 0000000..90818e5 --- /dev/null +++ b/tests/test_chat_widget.py @@ -0,0 +1,154 @@ +"""Chat widget e2e (M3): WebSocket booking flow → request in DB + proposal email. + +Uses Starlette's sync TestClient for WebSocket support; the scripted LLM drives +the same agent loop as production. +""" + +import pytest +from starlette.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from gogo.agent.llm import LLMResponse, ScriptedLLM, ToolUse, set_llm +from gogo.main import create_app + + +def make_client() -> TestClient: + return TestClient(create_app()) + + +@pytest.fixture(autouse=True) +def fast_chat(monkeypatch): + """Scripted LLM answers instantly — disable the per-second message gate.""" + from gogo.chat import router as chat_router + + monkeypatch.setattr(chat_router, "MIN_SECONDS_BETWEEN_MESSAGES", 0.0) + + +async def test_widget_js_served(gogo_client, session): + resp = await gogo_client.get("/widget.js") + assert resp.status_code == 200 + assert "gogo-bubble" in resp.text + assert resp.headers["content-type"].startswith("application/javascript") + + +def test_ws_rejects_unknown_key(session, tenant): + client = make_client() + # server closes before accepting → the connect itself raises with our code + with pytest.raises(WebSocketDisconnect) as exc: # noqa: SIM117 + with client.websocket_connect("/widget/ws?key=nepostojeci"): + pass + assert exc.value.code == 4404 + + +def test_ws_booking_flow_creates_request_and_email(session, tenant, emails, sms): + """Full widget booking: greeting → messages → submit_booking_request → + request stored, proposal email sent, chat transcript persisted.""" + import anyio + from sqlalchemy import select + + from gogo.models import BookingRequest, ChatMessage + + set_llm( + ScriptedLLM( + [ + LLMResponse(text="Može! Koji je vaš broj telefona i ime?"), + LLMResponse( + text="", + tool_uses=[ + ToolUse( + id="t1", + name="submit_booking_request", + input={ + "service_name_raw": "manikir", + "client_name": "Lejla", + "client_phone": "+38761555444", + "time_preference_text": "petak poslijepodne", + "summary": "Manikir, petak poslijepodne.", + }, + ) + ], + stop_reason="tool_use", + ), + LLMResponse( + text="Zahtjev sam proslijedila salonu — kontaktiraće vas radi potvrde!" + ), + ] + ) + ) + try: + client = make_client() + with client.websocket_connect(f"/widget/ws?key={tenant.widget_public_key}") as ws: + greeting = ws.receive_json() + assert greeting["type"] == "greeting" + assert "Gogo" in greeting["text"] + resume_token = greeting["session"] + assert resume_token + + ws.send_json({"type": "message", "text": "Može manikir u petak?", "website": ""}) + assert ws.receive_json()["type"] == "typing" + reply = ws.receive_json() + assert reply["type"] == "message" + assert "broj telefona" in reply["text"] + + ws.send_json({"type": "message", "text": "Lejla, 061 555 444"}) + assert ws.receive_json()["type"] == "typing" + reply2 = ws.receive_json() + assert "proslijedila" in reply2["text"] + + # verify persisted state via a fresh event loop session + async def check(): + import gogo.db as db + + async with db.get_sessionmaker()() as s: + req = (await s.execute(select(BookingRequest))).scalar_one() + assert req.client_name == "Lejla" + assert req.source == "chat" + assert req.chat_session_id is not None + msgs = (await s.execute(select(ChatMessage))).scalars().all() + roles = [m.role for m in msgs] + assert roles.count("user") == 2 + assert roles.count("assistant") >= 3 # greeting + 2 replies + + anyio.run(check) + assert len(emails) == 1 # proposal email to the owner + assert "Lejla" in emails[0].subject + finally: + set_llm(None) + + +def test_ws_honeypot_drops_message(session, tenant, emails): + set_llm(ScriptedLLM([LLMResponse(text="NE BI SMIO STIĆI")])) + try: + client = make_client() + with client.websocket_connect(f"/widget/ws?key={tenant.widget_public_key}") as ws: + ws.receive_json() # greeting + # bot fills the hidden field → silently ignored + ws.send_json({"type": "message", "text": "spam", "website": "http://spam.example"}) + # then a real empty check: send another honeypot message; no replies expected + ws.send_json({"type": "message", "text": "spam2", "website": "x"}) + # close cleanly — if the agent had run, ScriptedLLM would have been consumed + assert True + finally: + set_llm(None) + + +def test_ws_resume_restores_history(session, tenant, emails): + set_llm(ScriptedLLM([LLMResponse(text="Prvi odgovor.")])) + try: + client = make_client() + with client.websocket_connect(f"/widget/ws?key={tenant.widget_public_key}") as ws: + token = ws.receive_json()["session"] + ws.send_json({"type": "message", "text": "Zdravo!"}) + ws.receive_json() # typing + ws.receive_json() # reply + + with client.websocket_connect( + f"/widget/ws?key={tenant.widget_public_key}&resume={token}" + ) as ws: + data = ws.receive_json() + assert data["type"] == "resume" + texts = [m["text"] for m in data["messages"]] + assert "Zdravo!" in texts + assert "Prvi odgovor." in texts + finally: + set_llm(None) diff --git a/tests/test_web_dashboard.py b/tests/test_web_dashboard.py new file mode 100644 index 0000000..ae745df --- /dev/null +++ b/tests/test_web_dashboard.py @@ -0,0 +1,344 @@ +"""Owner dashboard + super-admin panel over HTTP (M3).""" + +import pytest +from sqlalchemy import select + +from gogo.auth import hash_password +from gogo.domain import BookingStatus +from gogo.models import Admin, PromptTemplate, Service, Tenant, User +from tests.test_proposal_lifecycle import create as create_request + + +@pytest.fixture +async def owner_user(session, tenant): + user = User( + tenant_id=tenant.id, + email="vlasnica@salon-merima.ba", + password_hash=hash_password("tajna123"), + ) + session.add(user) + await session.commit() + return user + + +@pytest.fixture +async def admin_user(session): + admin = Admin(email="admin@gogotelefon.ba", password_hash=hash_password("admin123")) + session.add(admin) + await session.commit() + return admin + + +async def login_owner(client, email="vlasnica@salon-merima.ba", password="tajna123"): + resp = await client.post("/login", data={"email": email, "password": password}) + assert resp.status_code == 303 + assert resp.headers["location"] == "/dash" + + +async def login_admin(client): + resp = await client.post( + "/admin/login", data={"email": "admin@gogotelefon.ba", "password": "admin123"} + ) + assert resp.status_code == 303 + + +# -- owner auth --------------------------------------------------------------- + + +async def test_dashboard_requires_login(gogo_client, session, tenant): + resp = await gogo_client.get("/dash") + assert resp.status_code == 303 + assert resp.headers["location"] == "/login" + + +async def test_login_wrong_password(gogo_client, session, owner_user): + resp = await gogo_client.post( + "/login", data={"email": owner_user.email, "password": "pogresna"} + ) + assert resp.status_code == 303 + assert "/login" in resp.headers["location"] + + +async def test_login_and_requests_list(gogo_client, session, tenant, owner_user, emails, sms): + await create_request(session, tenant) + await session.commit() + await login_owner(gogo_client) + resp = await gogo_client.get("/dash") + assert resp.status_code == 200 + assert "Amra Hodžić" in resp.text + assert "na čekanju" in resp.text + + +async def test_dashboard_resolve_action(gogo_client, session, tenant, owner_user, emails, sms): + req, _ = await create_request(session, tenant) + await session.commit() + await login_owner(gogo_client) + resp = await gogo_client.post(f"/dash/requests/{req.id}/resolve") + assert resp.status_code == 303 + await session.refresh(req) + assert req.status == BookingStatus.resolved_by_owner.value + assert sms == [] + + +async def test_request_detail_confirm(gogo_client, session, tenant, owner_user, emails, sms): + req, _ = await create_request(session, tenant) + await session.commit() + await login_owner(gogo_client) + resp = await gogo_client.get(f"/dash/requests/{req.id}") + assert "Potvrdi" in resp.text + resp = await gogo_client.post(f"/dash/requests/{req.id}/confirm/0") + assert resp.status_code == 303 + await session.refresh(req) + assert req.status == BookingStatus.confirmed.value + assert len(sms) == 1 + + +# -- settings ------------------------------------------------------------------- + + +async def test_settings_save_hours(gogo_client, session, tenant, owner_user): + await login_owner(gogo_client) + data = { + "name": "Salon Merima", + "address": "Nova adresa 5", + "city": "Banja Luka", + "phone": "+38751000000", + "website": "", + "wh_mon": "09:00-13:00, 14:00-18:00", + "wh_tue": "9-18", + "wh_wed": "09:00-18:00", + "wh_thu": "09:00-18:00", + "wh_fri": "09:00-18:00", + "wh_sat": "09:00-14:00", + "wh_sun": "", + } + resp = await gogo_client.post("/dash/settings", data=data) + assert resp.status_code == 303 + await session.refresh(tenant) + assert tenant.address == "Nova adresa 5" + assert tenant.working_hours["mon"] == [["09:00", "13:00"], ["14:00", "18:00"]] + assert tenant.working_hours["tue"] == [["09:00", "18:00"]] + assert tenant.working_hours["sun"] == [] + + +async def test_settings_bad_hours_rejected(gogo_client, session, tenant, owner_user): + await login_owner(gogo_client) + data = {"name": "Salon Merima", "wh_mon": "od devet do šest"} + resp = await gogo_client.post("/dash/settings", data=data) + assert resp.status_code == 303 + assert "err=" in resp.headers["location"] + + +async def test_services_crud(gogo_client, session, tenant, owner_user): + await login_owner(gogo_client) + resp = await gogo_client.post( + "/dash/services", + data={"name": "Nova usluga", "duration_min": "60", "price_min": "40", + "price_max": "50", "agent_note": "napomena"}, + ) + assert resp.status_code == 303 + svc = ( + await session.execute(select(Service).where(Service.name == "Nova usluga")) + ).scalar_one() + assert svc.duration_min == 60 + assert svc.price_min == 40 + + resp = await gogo_client.post(f"/dash/services/{svc.id}/delete") + assert resp.status_code == 303 + await session.refresh(svc) + assert svc.active is False + + +async def test_paste_price_list_with_scripted_llm( + gogo_client, session, tenant, owner_user, monkeypatch +): + from gogo.agent.llm import LLMResponse, ScriptedLLM, ToolUse, set_llm + + set_llm( + ScriptedLLM( + [ + LLMResponse( + text="", + tool_uses=[ + ToolUse( + id="t1", + name="save_services", + input={ + "services": [ + {"name": "Trajna", "duration_min": 90, + "price_min": 40, "price_max": 60}, + ] + }, + ) + ], + stop_reason="tool_use", + ) + ] + ) + ) + try: + await login_owner(gogo_client) + resp = await gogo_client.post( + "/dash/services/parse", data={"text": "Trajna 40-60KM"} + ) + assert resp.status_code == 200 + assert "Provjeri i potvrdi" in resp.text + assert "Trajna" in resp.text + + # confirm the parsed row → imported + resp = await gogo_client.post( + "/dash/services/import", + data={"count": "1", "row_0": "on", "name_0": "Trajna", + "duration_0": "90", "pmin_0": "40", "pmax_0": "60", "note_0": ""}, + ) + assert resp.status_code == 303 + svc = ( + await session.execute(select(Service).where(Service.name == "Trajna")) + ).scalar_one() + assert svc.duration_min == 90 + finally: + set_llm(None) + + +async def test_workers_add_and_qr(gogo_client, session, tenant, owner_user): + await login_owner(gogo_client) + resp = await gogo_client.post("/dash/workers", data={"name": "Merima"}) + assert resp.status_code == 303 + from gogo.models import Worker + + worker = (await session.execute(select(Worker))).scalar_one() + assert worker.name == "Merima" + assert worker.sip_username.startswith("salon-merima-") + + resp = await gogo_client.get(f"/dash/workers/{worker.id}/qr.png") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "image/png" + assert resp.content[:8] == b"\x89PNG\r\n\x1a\n" + + +async def test_notifications_save(gogo_client, session, tenant, owner_user): + await login_owner(gogo_client) + resp = await gogo_client.post( + "/dash/notifications", + data={ + "notify_emails": "a@b.ba\nc@d.ba", + "sms_request_received": "on", + "sms_confirmation": "Vaš termin: {usluga} {dan} u {vrijeme}h. {salon}", + }, + ) + assert resp.status_code == 303 + await session.refresh(tenant) + assert tenant.notify_emails == ["a@b.ba", "c@d.ba"] + assert tenant.sms_request_received is True + assert "confirmation" in tenant.sms_templates + + +# -- super-admin ------------------------------------------------------------------ + + +async def test_admin_create_tenant_and_owner(gogo_client, session, admin_user): + await login_admin(gogo_client) + resp = await gogo_client.post( + "/admin/tenants/new", + data={ + "name": "Salon Aida", "slug": "salon-aida", "city": "Sarajevo", + "status": "active", "plan": "gogo_start", "included_minutes": "300", + "paid_until": "2026-12-31", "proposal_ttl_hours": "24", + "scheduling_provider": "partner_api", + "partner_base_url": "http://partner.test", + "partner_api_key": "test-partner-key", + "partner_webhook_secret": "test-webhook-secret", + "owner_email": "aida@salon.ba", "owner_password": "lozinka1", + "phone_number_id": "", "llm_model": "", "prompt_override": "", + }, + ) + assert resp.status_code == 303, resp.text + t = ( + await session.execute(select(Tenant).where(Tenant.slug == "salon-aida")) + ).scalar_one() + assert t.scheduling_provider == "partner_api" + assert t.paid_until is not None + user = ( + await session.execute(select(User).where(User.tenant_id == t.id)) + ).scalar_one() + assert user.email == "aida@salon.ba" + + from gogo.models import ProviderConfig + + cfg = ( + await session.execute(select(ProviderConfig).where(ProviderConfig.tenant_id == t.id)) + ).scalar_one() + assert cfg.config["base_url"] == "http://partner.test" + assert cfg.config["api_key_encrypted"] # stored encrypted + + +async def test_admin_connectivity_test_against_fake_partner( + gogo_client, session, admin_user, partner_tenant, wire_fake_partner +): + wire_fake_partner.slots["SRV-12"] = [ + {"start": "2026-07-15T14:30:00+02:00", "end": "2026-07-15T15:15:00+02:00"} + ] + await login_admin(gogo_client) + resp = await gogo_client.post(f"/admin/tenants/{partner_tenant.id}/test-connection") + assert resp.status_code == 200 + assert "GET /availability" in resp.text + assert "dry_run" in resp.text or "dry-run" in resp.text + assert "ok=True" in resp.text + # dry-run must not create anything in the partner system + assert wire_fake_partner.requests == {} + + +async def test_admin_prompt_template_versioning(gogo_client, session, admin_user, tenant): + await login_admin(gogo_client) + resp = await gogo_client.post( + "/admin/templates", + data={"body": "V1 {salon_profile} {greeting}", "notes": "prva", "publish": "on"}, + ) + assert resp.status_code == 303 + resp = await gogo_client.post( + "/admin/templates", + data={"body": "V2 {salon_profile} {greeting}", "notes": "druga", "publish": "on"}, + ) + assert resp.status_code == 303 + + versions = (await session.execute(select(PromptTemplate))).scalars().all() + published = [v for v in versions if v.published] + assert len(versions) == 2 + assert len(published) == 1 and published[0].version == 2 + + # composed prompt uses the published template + from gogo.agent.prompt import compose_system_prompt + + prompt = await compose_system_prompt(session, tenant, "voice") + assert prompt.startswith("V2") + + # rollback to v1 + resp = await gogo_client.post("/admin/templates/1/publish") + assert resp.status_code == 303 + session.expire_all() + await session.refresh(tenant) # async-safe reload (expired attrs can't lazy-load) + prompt = await compose_system_prompt(session, tenant, "voice") + assert prompt.startswith("V1") + + +async def test_admin_impersonation(gogo_client, session, admin_user, tenant): + await login_admin(gogo_client) + resp = await gogo_client.post(f"/admin/impersonate/{tenant.id}") + assert resp.status_code == 303 + resp = await gogo_client.get("/dash") + assert resp.status_code == 200 + assert "admin pregled" in resp.text + assert tenant.name in resp.text + + +async def test_admin_requires_login(gogo_client, session): + resp = await gogo_client.get("/admin") + assert resp.status_code == 303 + assert resp.headers["location"] == "/admin/login" + + +async def test_admin_health_page(gogo_client, session, admin_user): + await login_admin(gogo_client) + resp = await gogo_client.get("/admin/health") + assert resp.status_code == 200 + assert "Baza podataka" in resp.text