- Embeddable vanilla-JS chat widget (one script tag + tenant public key):
WebSocket, resumable sessions (24h localStorage token), honeypot + per-IP
and per-message rate limits, Bosnian UI (§7)
- Session auth (bcrypt + signed cookies), owner accounts + super-admins,
admin impersonation ('otvori kao salon', §11)
- Owner dashboard (§10, Bosnian): requests with same actions as email,
call/chat history with transcripts, usage bar; settings for profile,
working hours (manual + LLM paste-to-parse), services CRUD + 'Zalijepi
cjenovnik' LLM import with review step, scheduling (Google OAuth free/busy
read-only + calendar mappings + buffers, partner status), telephony
(forwarding MMI codes per operator, ring group, worker SIP creds + QR),
agent voice/price-mode/notes + widget snippet, notifications/SMS templates
- Super-admin panel (§11): tenant CRUD incl. plan/minutes/paid-until,
partner API config (encrypted secrets), number/SIM registry + assignment,
connectivity test (live availability + dry-run push), versioned prompt
template editor with publish/rollback, playground, usage overview, health
- Fixes: WAL + busy_timeout for sqlite tests, no awaits in WS disconnect
path (deadlock), template publish autoflush bug
- 76 tests green (incl. widget WS e2e booking flow); dashboard, widget and
admin verified live in a browser against Postgres
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1106 lines
35 KiB
Python
1106 lines
35 KiB
Python
"""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})
|