M1: backend core + proposal engine
- FastAPI skeleton, SQLAlchemy models (§13), Alembic initial migration - SchedulingProvider interface with google_calendar (free/busy read-only), partner_api (Appendix B client) and mock implementations - Proposal engine: create → provider-routed delivery → owner actions (resolve/confirm+SMS/reject) → expiry + reminders (§9) - Signed single-use action links, .ics METHOD:REQUEST attachment - Partner outcome webhook with HMAC verification + polling fallback - SmsProvider (console) with Bosnian templates (§5.5), EmailProvider (console/SMTP) - Fake partner API server in tests/ — Appendix B reference implementation - 43 tests: slot math, proposal lifecycle, action links, partner contract Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
3
gogo/__init__.py
Normal file
3
gogo/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Gogo Telefon — voice-first AI receptionist for small service businesses."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
0
gogo/api/__init__.py
Normal file
0
gogo/api/__init__.py
Normal file
190
gogo/api/actions.py
Normal file
190
gogo/api/actions.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Owner action links from proposal emails (§9.1-9.2): /a/{token}.
|
||||
|
||||
Signed, single-use, no login required, idempotent (§15). All owner-facing
|
||||
copy is Bosnian. Reject shows a minimal form to edit the client SMS before
|
||||
sending; confirm re-checks free/busy and warns if the slot has been taken.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.db import get_session
|
||||
from gogo.domain import BookingStatus, Slot
|
||||
from gogo.i18n import fmt_slot
|
||||
from gogo.models import ActionToken, BookingRequest, Tenant, utcnow
|
||||
from gogo.proposals import engine
|
||||
from gogo.proposals.tokens import unsign
|
||||
from gogo.sms.templates import render_sms
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_PAGE = """<!doctype html><html lang="bs"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Gogo Telefon</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#f4f4f5;margin:0;padding:24px;
|
||||
display:flex;justify-content:center}}
|
||||
.card{{background:#fff;border-radius:12px;padding:32px;max-width:480px;width:100%;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.08)}}
|
||||
h1{{font-size:20px;margin-top:0}} p{{line-height:1.5}}
|
||||
.ok{{color:#16a34a}} .warn{{color:#d97706}} .err{{color:#dc2626}}
|
||||
textarea{{width:100%;min-height:90px;font:inherit;padding:8px;box-sizing:border-box}}
|
||||
button,a.btn{{background:#2563eb;color:#fff;border:0;border-radius:8px;padding:12px 20px;
|
||||
font-size:15px;cursor:pointer;text-decoration:none;display:inline-block;margin-top:8px}}
|
||||
button.green{{background:#16a34a}} button.red{{background:#dc2626}}
|
||||
.muted{{color:#71717a;font-size:13px;margin-top:24px}}
|
||||
</style></head><body><div class="card">{body}
|
||||
<p class="muted">Gogo Telefon — virtuelni asistent</p></div></body></html>"""
|
||||
|
||||
|
||||
def page(body: str, status_code: int = 200) -> HTMLResponse:
|
||||
return HTMLResponse(_PAGE.format(body=body), status_code=status_code)
|
||||
|
||||
|
||||
STATUS_LABEL = {
|
||||
BookingStatus.pending.value: "na čekanju",
|
||||
BookingStatus.resolved_by_owner.value: "riješen — klijent kontaktiran",
|
||||
BookingStatus.confirmed.value: "potvrđen",
|
||||
BookingStatus.rejected.value: "odbijen",
|
||||
BookingStatus.expired.value: "istekao",
|
||||
}
|
||||
|
||||
|
||||
async def _load(token: str, session: AsyncSession):
|
||||
raw = unsign(token)
|
||||
if raw is None:
|
||||
return None, None, None, page(
|
||||
"<h1 class='err'>Nevažeći link</h1><p>Link je oštećen ili nije ispravan.</p>", 400
|
||||
)
|
||||
at = (
|
||||
await session.execute(select(ActionToken).where(ActionToken.token == raw))
|
||||
).scalar_one_or_none()
|
||||
if at is None:
|
||||
return None, None, None, page(
|
||||
"<h1 class='err'>Nepoznat link</h1><p>Ovaj link više ne postoji.</p>", 404
|
||||
)
|
||||
req = (
|
||||
await session.execute(select(BookingRequest).where(BookingRequest.id == at.request_id))
|
||||
).scalar_one()
|
||||
tenant = (
|
||||
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
|
||||
).scalar_one()
|
||||
return at, req, tenant, None
|
||||
|
||||
|
||||
def _already_done(req: BookingRequest) -> HTMLResponse:
|
||||
label = STATUS_LABEL.get(req.status, req.status)
|
||||
return page(
|
||||
f"<h1 class='ok'>Zahtjev je već obrađen</h1>"
|
||||
f"<p>Status zahtjeva klijenta <b>{req.client_name}</b>: <b>{label}</b>.</p>"
|
||||
"<p>Nije potrebna dodatna akcija.</p>"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/a/{token}", response_class=HTMLResponse)
|
||||
async def action_get(token: str, session: AsyncSession = Depends(get_session)):
|
||||
at, req, tenant, err = await _load(token, session)
|
||||
if err:
|
||||
return err
|
||||
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return _already_done(req)
|
||||
|
||||
if at.action == "resolve":
|
||||
await engine.resolve_request(session, tenant, req)
|
||||
at.used_at = utcnow()
|
||||
await session.commit()
|
||||
return page(
|
||||
"<h1 class='ok'>✓ Označeno kao riješeno</h1>"
|
||||
f"<p>Zahtjev klijenta <b>{req.client_name}</b> je zatvoren. "
|
||||
"Klijentu <b>nije</b> poslan SMS — dogovorili ste se direktno.</p>"
|
||||
"<p>Ne zaboravite upisati termin u svoj kalendar (možete iskoristiti "
|
||||
"priloženi .ics iz emaila).</p>"
|
||||
)
|
||||
|
||||
if at.action == "confirm":
|
||||
slots = [Slot.model_validate(s) for s in (req.slots or [])]
|
||||
idx = at.slot_index or 0
|
||||
if idx >= len(slots):
|
||||
return page("<h1 class='err'>Greška</h1><p>Traženi termin ne postoji.</p>", 400)
|
||||
slot = slots[idx]
|
||||
ok = await engine.confirm_request(session, tenant, req, slot, recheck=True)
|
||||
if not ok:
|
||||
# slot taken since — warn, offer force (§9.2)
|
||||
return page(
|
||||
"<h1 class='warn'>⚠ Termin je u međuvremenu zauzet</h1>"
|
||||
f"<p>U kalendaru više nije slobodno: <b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
|
||||
"<p>Možete svejedno potvrditi (npr. ako ste sami upisali ovaj termin "
|
||||
"u kalendar), ili se javiti klijentu direktno.</p>"
|
||||
f"<form method='post' action='/a/{token}/force-confirm'>"
|
||||
"<button class='green'>Svejedno potvrdi i pošalji SMS</button></form>"
|
||||
)
|
||||
at.used_at = utcnow()
|
||||
await session.commit()
|
||||
return page(
|
||||
"<h1 class='ok'>✓ Termin potvrđen</h1>"
|
||||
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s potvrdom za "
|
||||
f"<b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
|
||||
"<p>Ne zaboravite upisati termin u svoj kalendar.</p>"
|
||||
)
|
||||
|
||||
if at.action == "reject":
|
||||
default_sms = render_sms(tenant, "rejection")
|
||||
return page(
|
||||
"<h1>Odbij zahtjev</h1>"
|
||||
f"<p>Klijent <b>{req.client_name}</b> ({req.client_phone}) će dobiti ovu poruku — "
|
||||
"možete je izmijeniti prije slanja:</p>"
|
||||
f"<form method='post' action='/a/{token}/reject'>"
|
||||
f"<textarea name='sms_body'>{default_sms}</textarea>"
|
||||
"<button class='red'>Pošalji i odbij zahtjev</button></form>"
|
||||
)
|
||||
|
||||
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
|
||||
|
||||
|
||||
@router.post("/a/{token}/reject", response_class=HTMLResponse)
|
||||
async def action_reject(
|
||||
token: str, sms_body: str = Form(""), session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
at, req, tenant, err = await _load(token, session)
|
||||
if err:
|
||||
return err
|
||||
if at.action != "reject":
|
||||
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return _already_done(req)
|
||||
await engine.reject_request(session, tenant, req, custom_sms=sms_body.strip() or None)
|
||||
at.used_at = utcnow()
|
||||
await session.commit()
|
||||
return page(
|
||||
"<h1 class='ok'>Zahtjev odbijen</h1>"
|
||||
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s obavještenjem.</p>"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/a/{token}/force-confirm", response_class=HTMLResponse)
|
||||
async def action_force_confirm(token: str, session: AsyncSession = Depends(get_session)):
|
||||
at, req, tenant, err = await _load(token, session)
|
||||
if err:
|
||||
return err
|
||||
if at.action != "confirm":
|
||||
return page("<h1 class='err'>Nepoznata akcija</h1>", 400)
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return _already_done(req)
|
||||
slots = [Slot.model_validate(s) for s in (req.slots or [])]
|
||||
idx = at.slot_index or 0
|
||||
if idx >= len(slots):
|
||||
return page("<h1 class='err'>Greška</h1><p>Traženi termin ne postoji.</p>", 400)
|
||||
slot = slots[idx]
|
||||
await engine.confirm_request(session, tenant, req, slot, recheck=False)
|
||||
at.used_at = utcnow()
|
||||
await session.commit()
|
||||
return page(
|
||||
"<h1 class='ok'>✓ Termin potvrđen</h1>"
|
||||
f"<p>Klijentu <b>{req.client_name}</b> je poslan SMS s potvrdom za "
|
||||
f"<b>{fmt_slot(slot.start, tenant.timezone)}</b>.</p>"
|
||||
)
|
||||
23
gogo/api/health.py
Normal file
23
gogo/api/health.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Health/observability endpoints (§15)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import gogo
|
||||
from gogo.db import get_session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": gogo.__version__}
|
||||
|
||||
|
||||
@router.get("/health/db")
|
||||
async def health_db(session: AsyncSession = Depends(get_session)):
|
||||
await session.execute(text("SELECT 1"))
|
||||
return {"status": "ok"}
|
||||
120
gogo/api/webhooks.py
Normal file
120
gogo/api/webhooks.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Partner outcome webhook (§B.3): POST /webhooks/partner/{tenant_id}.
|
||||
|
||||
HMAC-SHA256 of the raw body with the per-tenant shared secret, sent as
|
||||
X-Gogo-Signature: sha256=<hex>. Idempotent per (gogo_request_id, outcome).
|
||||
Drives the same transitions (and client SMS) as the email actions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.crypto import decrypt
|
||||
from gogo.db import get_session
|
||||
from gogo.domain import BookingStatus, Slot
|
||||
from gogo.models import BookingRequest, ProviderConfig, Tenant
|
||||
from gogo.proposals import engine
|
||||
|
||||
log = logging.getLogger("gogo.webhooks")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _err(status: int, code: str, message: str) -> JSONResponse:
|
||||
return JSONResponse({"error": {"code": code, "message": message}}, status_code=status)
|
||||
|
||||
|
||||
def verify_signature(secret: str, body: bytes, header_value: str | None) -> bool:
|
||||
if not header_value or not header_value.startswith("sha256="):
|
||||
return False
|
||||
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(header_value.removeprefix("sha256="), expected)
|
||||
|
||||
|
||||
@router.post("/webhooks/partner/{tenant_id}")
|
||||
async def partner_webhook(
|
||||
tenant_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
x_gogo_signature: str | None = Header(default=None),
|
||||
):
|
||||
try:
|
||||
tid = uuid.UUID(tenant_id)
|
||||
except ValueError:
|
||||
return _err(404, "unknown_tenant", "Unknown tenant id")
|
||||
tenant = (
|
||||
await session.execute(select(Tenant).where(Tenant.id == tid))
|
||||
).scalar_one_or_none()
|
||||
if tenant is None:
|
||||
return _err(404, "unknown_tenant", "Unknown tenant id")
|
||||
|
||||
config_row = (
|
||||
await session.execute(select(ProviderConfig).where(ProviderConfig.tenant_id == tid))
|
||||
).scalar_one_or_none()
|
||||
secret_enc = (config_row.config if config_row else {}).get("webhook_secret_encrypted")
|
||||
if not secret_enc:
|
||||
return _err(409, "not_configured", "Webhook secret not configured for tenant")
|
||||
|
||||
body = await request.body()
|
||||
if not verify_signature(decrypt(secret_enc), body, x_gogo_signature):
|
||||
return _err(401, "bad_signature", "Invalid or missing X-Gogo-Signature")
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
gogo_request_id = uuid.UUID(payload["gogo_request_id"])
|
||||
outcome = payload["outcome"]
|
||||
except Exception: # noqa: BLE001
|
||||
return _err(400, "bad_payload", "Malformed JSON payload")
|
||||
if outcome not in ("confirmed", "resolved", "rejected"):
|
||||
return _err(400, "bad_outcome", f"Unknown outcome: {outcome}")
|
||||
|
||||
req = (
|
||||
await session.execute(
|
||||
select(BookingRequest).where(
|
||||
BookingRequest.id == gogo_request_id, BookingRequest.tenant_id == tid
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if req is None:
|
||||
return _err(404, "unknown_request", "Unknown gogo_request_id")
|
||||
|
||||
if payload.get("partner_request_id") and not req.partner_request_id:
|
||||
req.partner_request_id = str(payload["partner_request_id"])
|
||||
|
||||
# Idempotency per (gogo_request_id, outcome) (§B.3)
|
||||
target = {
|
||||
"confirmed": BookingStatus.confirmed.value,
|
||||
"resolved": BookingStatus.resolved_by_owner.value,
|
||||
"rejected": BookingStatus.rejected.value,
|
||||
}[outcome]
|
||||
if req.status == target:
|
||||
return {"ok": True}
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return _err(409, "conflict", f"Request already {req.status}")
|
||||
|
||||
if outcome == "confirmed":
|
||||
slot_data = payload.get("confirmed_slot")
|
||||
if not slot_data:
|
||||
return _err(400, "missing_slot", "confirmed_slot is required for outcome=confirmed")
|
||||
try:
|
||||
slot = Slot.model_validate(slot_data)
|
||||
except Exception: # noqa: BLE001
|
||||
return _err(400, "bad_slot", "Malformed confirmed_slot")
|
||||
# Partner software is the source of truth — no free/busy recheck (§8.2)
|
||||
await engine.confirm_request(session, tenant, req, slot, recheck=False)
|
||||
elif outcome == "resolved":
|
||||
await engine.resolve_request(session, tenant, req, note=payload.get("note", ""))
|
||||
else:
|
||||
await engine.reject_request(session, tenant, req)
|
||||
|
||||
await session.commit()
|
||||
log.info("webhook: request %s → %s (tenant %s)", req.id, outcome, tenant.slug)
|
||||
return {"ok": True}
|
||||
59
gogo/config.py
Normal file
59
gogo/config.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Application configuration via environment variables / .env file."""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_prefix="GOGO_", extra="ignore")
|
||||
|
||||
# Core
|
||||
env: str = "dev" # dev | test | prod
|
||||
secret_key: str = "dev-secret-change-me"
|
||||
base_url: str = "http://localhost:8000" # public URL for action links / transcripts
|
||||
database_url: str = "postgresql+psycopg://gogo:gogo@localhost:5432/gogo"
|
||||
|
||||
# Email (SMTP). provider=console logs instead of sending.
|
||||
email_provider: str = "console" # console | smtp
|
||||
smtp_host: str = "localhost"
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_starttls: bool = True
|
||||
email_from: str = "Gogo Telefon <noreply@gogotelefon.ba>"
|
||||
|
||||
# SMS. provider=console logs instead of sending (GSM gateway impl in M5).
|
||||
sms_provider: str = "console" # console | gsm_gateway
|
||||
gsm_gateway_url: str = ""
|
||||
gsm_gateway_user: str = ""
|
||||
gsm_gateway_password: str = ""
|
||||
|
||||
# LLM
|
||||
anthropic_api_key: str = ""
|
||||
llm_model: str = "claude-haiku-4-5-20251001" # default; per-tenant override possible
|
||||
|
||||
# TTS (decided by Phase-0 PoC; both supported behind TTSProvider)
|
||||
tts_provider: str = "azure" # azure | elevenlabs
|
||||
azure_speech_key: str = ""
|
||||
azure_speech_region: str = "westeurope"
|
||||
elevenlabs_api_key: str = ""
|
||||
|
||||
# Google OAuth (Calendar free/busy)
|
||||
google_client_id: str = ""
|
||||
google_client_secret: str = ""
|
||||
|
||||
# Proposal engine defaults (overridable per tenant)
|
||||
proposal_ttl_hours: int = 24
|
||||
|
||||
# Retention
|
||||
audio_retention_days: int = 90
|
||||
|
||||
# Session cookies
|
||||
session_cookie: str = "gogo_session"
|
||||
session_max_age: int = 60 * 60 * 24 * 14 # 14 days
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
24
gogo/crypto.py
Normal file
24
gogo/crypto.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Symmetric encryption for secrets at rest (OAuth tokens, partner API keys).
|
||||
|
||||
Key is derived from GOGO_SECRET_KEY; rotate by re-encrypting after a key change.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from gogo.config import get_settings
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = hashlib.sha256(get_settings().secret_key.encode()).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
return _fernet().encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt(ciphertext: str) -> str:
|
||||
return _fernet().decrypt(ciphertext.encode()).decode()
|
||||
43
gogo/db.py
Normal file
43
gogo/db.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Async SQLAlchemy engine/session setup."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from gogo.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
_engine = None
|
||||
_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)
|
||||
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||
get_engine()
|
||||
assert _sessionmaker is not None
|
||||
return _sessionmaker
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
"""FastAPI dependency."""
|
||||
async with get_sessionmaker()() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def reset_engine() -> None:
|
||||
"""Test helper: force re-creation of the engine (e.g. after settings change)."""
|
||||
global _engine, _sessionmaker
|
||||
_engine = None
|
||||
_sessionmaker = None
|
||||
87
gogo/domain.py
Normal file
87
gogo/domain.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Shared domain value objects used across providers, agent tools and the proposal engine.
|
||||
|
||||
These are deliberately plain (pydantic) models decoupled from the ORM so that
|
||||
SchedulingProvider implementations and the agent can be tested without a database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BookingStatus(enum.StrEnum):
|
||||
pending = "pending"
|
||||
resolved_by_owner = "resolved_by_owner" # owner contacted client directly (primary flow)
|
||||
confirmed = "confirmed" # owner confirmed a slot → Gogo sends confirmation SMS
|
||||
rejected = "rejected"
|
||||
expired = "expired"
|
||||
|
||||
|
||||
class CallOutcome(enum.StrEnum):
|
||||
human_answered = "human_answered"
|
||||
request_created = "request_created"
|
||||
info_only = "info_only"
|
||||
message_taken = "message_taken"
|
||||
abandoned = "abandoned"
|
||||
|
||||
|
||||
class ProviderType(enum.StrEnum):
|
||||
google_calendar = "google_calendar"
|
||||
partner_api = "partner_api"
|
||||
mock = "mock" # in-memory, for tests and demos
|
||||
|
||||
|
||||
class Slot(BaseModel):
|
||||
"""One concrete offerable time slot (timezone-aware datetimes)."""
|
||||
|
||||
start: datetime
|
||||
end: datetime
|
||||
staff_id: str | None = None
|
||||
staff_name: str | None = None
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
if self.start.tzinfo is None or self.end.tzinfo is None:
|
||||
raise ValueError("Slot datetimes must be timezone-aware")
|
||||
|
||||
|
||||
class ServiceInfo(BaseModel):
|
||||
"""Service as seen by providers / the agent (decoupled from ORM row)."""
|
||||
|
||||
id: str # our service UUID as string, or partner service id for catalog sync
|
||||
name: str
|
||||
duration_min: int
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
currency: str = "BAM"
|
||||
home_visit: bool = False
|
||||
agent_note: str | None = None
|
||||
active: bool = True
|
||||
|
||||
|
||||
class BookingRequestData(BaseModel):
|
||||
"""Payload the proposal engine hands to a provider's deliver_request()."""
|
||||
|
||||
gogo_request_id: str
|
||||
tenant_id: str
|
||||
created_at: datetime
|
||||
source: str # "voice" | "chat"
|
||||
client_name: str
|
||||
client_phone: str
|
||||
service_id: str | None = None
|
||||
service_name_raw: str = ""
|
||||
requested_slots: list[Slot] = Field(default_factory=list) # 0-3, ordered by preference
|
||||
time_preference_text: str = ""
|
||||
home_visit: bool = False
|
||||
address: str | None = None
|
||||
summary: str = ""
|
||||
transcript_url: str = ""
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class DeliveryResult(BaseModel):
|
||||
ok: bool
|
||||
partner_request_id: str | None = None
|
||||
detail: str = ""
|
||||
1
gogo/email/__init__.py
Normal file
1
gogo/email/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from gogo.email.sender import EmailMessage, get_email_provider, send_email # noqa: F401
|
||||
92
gogo/email/sender.py
Normal file
92
gogo/email/sender.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""EmailProvider interface: SMTP for production, console for dev/tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from email.message import EmailMessage as MimeMessage
|
||||
from typing import Protocol
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
from gogo.config import get_settings
|
||||
|
||||
log = logging.getLogger("gogo.email")
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailMessage:
|
||||
to: list[str]
|
||||
subject: str
|
||||
text: str
|
||||
html: str | None = None
|
||||
# attachments: (filename, mimetype, bytes)
|
||||
attachments: list[tuple[str, str, bytes]] = field(default_factory=list)
|
||||
|
||||
|
||||
class EmailProvider(Protocol):
|
||||
async def send(self, msg: EmailMessage) -> None: ...
|
||||
|
||||
|
||||
class ConsoleEmailProvider:
|
||||
"""Logs emails instead of sending; keeps them in memory for tests."""
|
||||
|
||||
sent: list[EmailMessage]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent = []
|
||||
|
||||
async def send(self, msg: EmailMessage) -> None:
|
||||
self.sent.append(msg)
|
||||
log.info(
|
||||
"EMAIL to=%s subject=%r attachments=%d\n%s",
|
||||
msg.to,
|
||||
msg.subject,
|
||||
len(msg.attachments),
|
||||
msg.text,
|
||||
)
|
||||
|
||||
|
||||
class SmtpEmailProvider:
|
||||
async def send(self, msg: EmailMessage) -> None:
|
||||
s = get_settings()
|
||||
mime = MimeMessage()
|
||||
mime["From"] = s.email_from
|
||||
mime["To"] = ", ".join(msg.to)
|
||||
mime["Subject"] = msg.subject
|
||||
mime.set_content(msg.text)
|
||||
if msg.html:
|
||||
mime.add_alternative(msg.html, subtype="html")
|
||||
for filename, mimetype, data in msg.attachments:
|
||||
maintype, subtype = mimetype.split("/", 1)
|
||||
mime.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||
await aiosmtplib.send(
|
||||
mime,
|
||||
hostname=s.smtp_host,
|
||||
port=s.smtp_port,
|
||||
username=s.smtp_user or None,
|
||||
password=s.smtp_password or None,
|
||||
start_tls=s.smtp_starttls,
|
||||
)
|
||||
|
||||
|
||||
_provider: EmailProvider | None = None
|
||||
|
||||
|
||||
def get_email_provider() -> EmailProvider:
|
||||
global _provider
|
||||
if _provider is None:
|
||||
_provider = (
|
||||
SmtpEmailProvider() if get_settings().email_provider == "smtp" else ConsoleEmailProvider()
|
||||
)
|
||||
return _provider
|
||||
|
||||
|
||||
def set_email_provider(p: EmailProvider | None) -> None:
|
||||
"""Test hook."""
|
||||
global _provider
|
||||
_provider = p
|
||||
|
||||
|
||||
async def send_email(msg: EmailMessage) -> None:
|
||||
await get_email_provider().send(msg)
|
||||
79
gogo/hours.py
Normal file
79
gogo/hours.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Working-hours helpers.
|
||||
|
||||
Working hours are stored per tenant as:
|
||||
{"mon": [["09:00", "13:00"], ["14:00", "18:00"]], ..., "sun": []}
|
||||
Multiple intervals per day model lunch breaks. Missing/empty day = closed.
|
||||
All computations happen in the tenant's timezone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
WEEKDAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
|
||||
# Bosnian day names (Latin) for UI / agent context
|
||||
DAY_NAMES_BS = {
|
||||
"mon": "ponedjeljak",
|
||||
"tue": "utorak",
|
||||
"wed": "srijeda",
|
||||
"thu": "četvrtak",
|
||||
"fri": "petak",
|
||||
"sat": "subota",
|
||||
"sun": "nedjelja",
|
||||
}
|
||||
|
||||
DEFAULT_WORKING_HOURS: dict[str, list[list[str]]] = {
|
||||
"mon": [["09:00", "18:00"]],
|
||||
"tue": [["09:00", "18:00"]],
|
||||
"wed": [["09:00", "18:00"]],
|
||||
"thu": [["09:00", "18:00"]],
|
||||
"fri": [["09:00", "18:00"]],
|
||||
"sat": [["09:00", "14:00"]],
|
||||
"sun": [],
|
||||
}
|
||||
|
||||
|
||||
def _parse_hhmm(s: str) -> time:
|
||||
h, m = s.split(":")
|
||||
return time(int(h), int(m))
|
||||
|
||||
|
||||
def day_intervals(
|
||||
working_hours: dict, day: date, tz: ZoneInfo
|
||||
) -> list[tuple[datetime, datetime]]:
|
||||
"""Open intervals for a calendar day as tz-aware datetimes."""
|
||||
key = WEEKDAY_KEYS[day.weekday()]
|
||||
out = []
|
||||
for start_s, end_s in working_hours.get(key, []):
|
||||
start = datetime.combine(day, _parse_hhmm(start_s), tzinfo=tz)
|
||||
end = datetime.combine(day, _parse_hhmm(end_s), tzinfo=tz)
|
||||
if end > start:
|
||||
out.append((start, end))
|
||||
return out
|
||||
|
||||
|
||||
def is_open_at(working_hours: dict, at: datetime, tz: ZoneInfo) -> bool:
|
||||
at = at.astimezone(tz)
|
||||
return any(s <= at < e for s, e in day_intervals(working_hours, at.date(), tz))
|
||||
|
||||
|
||||
def hours_summary_bs(working_hours: dict) -> str:
|
||||
"""Human-readable Bosnian working-hours summary for prompts/emails."""
|
||||
parts = []
|
||||
for key in WEEKDAY_KEYS:
|
||||
intervals = working_hours.get(key, [])
|
||||
if not intervals:
|
||||
parts.append(f"{DAY_NAMES_BS[key]}: zatvoreno")
|
||||
else:
|
||||
spans = ", ".join(f"{a}–{b}" for a, b in intervals)
|
||||
parts.append(f"{DAY_NAMES_BS[key]}: {spans}")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def iter_days(start: date, end: date):
|
||||
d = start
|
||||
while d <= end:
|
||||
yield d
|
||||
d += timedelta(days=1)
|
||||
65
gogo/i18n.py
Normal file
65
gogo/i18n.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Bosnian (Latin) formatting helpers for client- and owner-facing text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
DAY_NAMES = [
|
||||
"ponedjeljak",
|
||||
"utorak",
|
||||
"srijeda",
|
||||
"četvrtak",
|
||||
"petak",
|
||||
"subota",
|
||||
"nedjelja",
|
||||
]
|
||||
|
||||
MONTH_NAMES = [
|
||||
"januar",
|
||||
"februar",
|
||||
"mart",
|
||||
"april",
|
||||
"maj",
|
||||
"juni",
|
||||
"juli",
|
||||
"august",
|
||||
"septembar",
|
||||
"oktobar",
|
||||
"novembar",
|
||||
"decembar",
|
||||
]
|
||||
|
||||
|
||||
def fmt_slot(dt: datetime, tz: str = "Europe/Sarajevo") -> str:
|
||||
"""'srijeda, 15.07. u 17:00'"""
|
||||
local = dt.astimezone(ZoneInfo(tz))
|
||||
day = DAY_NAMES[local.weekday()]
|
||||
return f"{day}, {local.day:02d}.{local.month:02d}. u {local:%H:%M}"
|
||||
|
||||
|
||||
def fmt_date(dt: datetime, tz: str = "Europe/Sarajevo") -> str:
|
||||
local = dt.astimezone(ZoneInfo(tz))
|
||||
return f"{local.day:02d}.{local.month:02d}.{local.year}."
|
||||
|
||||
|
||||
def fmt_time(dt: datetime, tz: str = "Europe/Sarajevo") -> str:
|
||||
local = dt.astimezone(ZoneInfo(tz))
|
||||
return f"{local:%H:%M}"
|
||||
|
||||
|
||||
def fmt_price(price_min: float | None, price_max: float | None, currency: str = "KM") -> str:
|
||||
cur = "KM" if currency == "BAM" else currency
|
||||
if price_min is None and price_max is None:
|
||||
return "cijena na upit"
|
||||
if price_max is None or price_min == price_max:
|
||||
return f"{_num(price_min)} {cur}"
|
||||
if price_min is None:
|
||||
return f"do {_num(price_max)} {cur}"
|
||||
return f"{_num(price_min)}–{_num(price_max)} {cur}"
|
||||
|
||||
|
||||
def _num(x: float | None) -> str:
|
||||
if x is None:
|
||||
return "?"
|
||||
return str(int(x)) if float(x).is_integer() else f"{x:.2f}".replace(".", ",")
|
||||
110
gogo/jobs.py
Normal file
110
gogo/jobs.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Background jobs: proposal expiry/reminders, partner polling fallback, retention.
|
||||
|
||||
APScheduler (in-process) — no extra infra needed for MVP scale (§4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from sqlalchemy import select
|
||||
|
||||
from gogo.db import get_sessionmaker
|
||||
from gogo.domain import BookingStatus, Slot
|
||||
from gogo.models import BookingRequest, Tenant
|
||||
from gogo.proposals import engine as proposal_engine
|
||||
from gogo.scheduling.base import get_provider
|
||||
|
||||
log = logging.getLogger("gogo.jobs")
|
||||
|
||||
|
||||
async def run_expiry_job() -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
expired = await proposal_engine.process_expirations(session)
|
||||
await session.commit()
|
||||
if expired:
|
||||
log.info("expired %d proposals", expired)
|
||||
|
||||
|
||||
async def run_partner_polling_job() -> None:
|
||||
"""Polling fallback (§B.3) for partner tenants that cannot call webhooks."""
|
||||
async with get_sessionmaker()() as session:
|
||||
pending = (
|
||||
(
|
||||
await session.execute(
|
||||
select(BookingRequest).where(
|
||||
BookingRequest.status == BookingStatus.pending.value,
|
||||
BookingRequest.partner_request_id.is_not(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for req in pending:
|
||||
tenant = (
|
||||
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
|
||||
).scalar_one()
|
||||
if tenant.scheduling_provider != "partner_api":
|
||||
continue
|
||||
provider = await get_provider(session, tenant)
|
||||
if not provider.config.get("polling_fallback"):
|
||||
continue
|
||||
try:
|
||||
data = await provider.poll_status(req.partner_request_id)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("polling failed for request %s", req.id)
|
||||
continue
|
||||
status = data.get("status")
|
||||
if status == "confirmed" and data.get("confirmed_slot"):
|
||||
slot = Slot.model_validate(data["confirmed_slot"])
|
||||
await proposal_engine.confirm_request(session, tenant, req, slot, recheck=False)
|
||||
elif status == "resolved":
|
||||
await proposal_engine.resolve_request(session, tenant, req)
|
||||
elif status == "rejected":
|
||||
await proposal_engine.reject_request(session, tenant, req)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def run_retention_job() -> None:
|
||||
"""Delete call audio older than retention window; keep transcripts (§5.4)."""
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.models import Call
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(days=get_settings().audio_retention_days)
|
||||
async with get_sessionmaker()() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(Call).where(
|
||||
Call.recording_path.is_not(None), Call.started_at < cutoff
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for call in rows:
|
||||
path = call.recording_path
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
log.exception("failed to delete recording %s", path)
|
||||
continue
|
||||
call.recording_path = None
|
||||
await session.commit()
|
||||
if rows:
|
||||
log.info("retention: cleared %d recordings", len(rows))
|
||||
|
||||
|
||||
def build_scheduler() -> AsyncIOScheduler:
|
||||
scheduler = AsyncIOScheduler()
|
||||
scheduler.add_job(run_expiry_job, "interval", minutes=5, id="proposal_expiry")
|
||||
scheduler.add_job(run_partner_polling_job, "interval", minutes=5, id="partner_polling")
|
||||
scheduler.add_job(run_retention_job, "cron", hour=4, minute=0, id="retention")
|
||||
return scheduler
|
||||
39
gogo/main.py
Normal file
39
gogo/main.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""FastAPI application assembly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from gogo.api import actions, health, webhooks
|
||||
from gogo.config import get_settings
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
scheduler = None
|
||||
if get_settings().env != "test":
|
||||
from gogo.jobs import build_scheduler
|
||||
|
||||
scheduler = build_scheduler()
|
||||
scheduler.start()
|
||||
yield
|
||||
if scheduler:
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
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)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
359
gogo/models.py
Normal file
359
gogo/models.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""ORM models — core tables per spec §13."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
Uuid,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from gogo.db import Base
|
||||
|
||||
JSONVariant = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
|
||||
class Tenant(Base, TimestampMixin):
|
||||
"""A salon."""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
slug: Mapped[str] = mapped_column(String(80), unique=True) # short id used in URLs
|
||||
address: Mapped[str] = mapped_column(String(300), default="")
|
||||
city: Mapped[str] = mapped_column(String(100), default="")
|
||||
phone: Mapped[str] = mapped_column(String(40), default="") # salon's own public number
|
||||
website: Mapped[str] = mapped_column(String(200), default="")
|
||||
|
||||
locale: Mapped[str] = mapped_column(String(20), default="bs-Latn-BA")
|
||||
timezone: Mapped[str] = mapped_column(String(50), default="Europe/Sarajevo")
|
||||
currency: Mapped[str] = mapped_column(String(10), default="BAM")
|
||||
|
||||
# {"mon": [["09:00","13:00"],["14:00","18:00"]], ..., "sun": []}
|
||||
working_hours: Mapped[dict] = mapped_column(JSONVariant, default=dict)
|
||||
|
||||
scheduling_provider: Mapped[str] = mapped_column(String(30), default="google_calendar")
|
||||
|
||||
# Plan / metering (§12)
|
||||
plan: Mapped[str] = mapped_column(String(40), default="gogo_start")
|
||||
included_minutes: Mapped[int] = mapped_column(Integer, default=300)
|
||||
hard_cutoff: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
paid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active") # active | disabled
|
||||
|
||||
# Agent config (§10.5)
|
||||
tts_voice: Mapped[str] = mapped_column(String(80), default="")
|
||||
price_mode: Mapped[str] = mapped_column(String(20), default="exact") # exact|range|on_request
|
||||
agent_notes: Mapped[str] = mapped_column(Text, default="") # plain informational notes
|
||||
llm_model: Mapped[str] = mapped_column(String(80), default="") # empty = global default
|
||||
|
||||
# Proposal engine
|
||||
proposal_ttl_hours: Mapped[int] = mapped_column(Integer, default=24)
|
||||
notify_emails: Mapped[list] = mapped_column(JSONVariant, default=list) # owner emails
|
||||
sms_request_received: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
sms_templates: Mapped[dict] = mapped_column(JSONVariant, default=dict) # overrides only
|
||||
|
||||
# Google Calendar tenants
|
||||
min_notice_hours: Mapped[int] = mapped_column(Integer, default=2)
|
||||
max_days_ahead: Mapped[int] = mapped_column(Integer, default=14)
|
||||
|
||||
# Telephony
|
||||
ring_first_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
ring_timeout_s: Mapped[int] = mapped_column(Integer, default=12)
|
||||
ring_strategy: Mapped[str] = mapped_column(String(20), default="ring_all") # ring_all|sequential
|
||||
|
||||
# Chat widget
|
||||
widget_public_key: Mapped[str] = mapped_column(
|
||||
String(64), default=lambda: uuid.uuid4().hex, unique=True
|
||||
)
|
||||
|
||||
services: Mapped[list[Service]] = relationship(back_populates="tenant")
|
||||
|
||||
|
||||
class ProviderConfig(Base, TimestampMixin):
|
||||
__tablename__ = "provider_configs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
|
||||
provider_type: Mapped[str] = mapped_column(String(30))
|
||||
# partner_api: {base_url, api_key_encrypted, webhook_secret_encrypted,
|
||||
# catalog_sync, email_to_owner, polling_fallback}
|
||||
# google_calendar: {} (connection lives in calendar_connections)
|
||||
config: Mapped[dict] = mapped_column(JSONVariant, default=dict)
|
||||
|
||||
|
||||
class Service(Base, TimestampMixin):
|
||||
__tablename__ = "services"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
duration_min: Mapped[int] = mapped_column(Integer, default=30)
|
||||
price_min: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
price_max: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
home_visit: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
agent_note: Mapped[str] = mapped_column(Text, default="")
|
||||
buffer_min: Mapped[int] = mapped_column(Integer, default=0) # per-service buffer (GCal)
|
||||
partner_service_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
tenant: Mapped[Tenant] = relationship(back_populates="services")
|
||||
|
||||
|
||||
class CalendarConnection(Base, TimestampMixin):
|
||||
__tablename__ = "calendar_connections"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
|
||||
google_account: Mapped[str] = mapped_column(String(200))
|
||||
scopes: Mapped[list] = mapped_column(JSONVariant, default=list)
|
||||
token_data_encrypted: Mapped[str] = mapped_column(Text) # Fernet-encrypted OAuth tokens
|
||||
status: Mapped[str] = mapped_column(String(20), default="connected")
|
||||
|
||||
|
||||
class CalendarMapping(Base):
|
||||
"""service → google calendar id. A row with service_id NULL is the tenant default."""
|
||||
|
||||
__tablename__ = "calendar_mappings"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "service_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
service_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("services.id"), nullable=True
|
||||
)
|
||||
google_calendar_id: Mapped[str] = mapped_column(String(300))
|
||||
|
||||
|
||||
class Worker(Base, TimestampMixin):
|
||||
"""Ring-group member — exists only as a SIP endpoint, no user account (§5.3)."""
|
||||
|
||||
__tablename__ = "workers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
sip_username: Mapped[str] = mapped_column(String(80), unique=True)
|
||||
sip_password: Mapped[str] = mapped_column(String(80)) # provisioned to softphone via QR
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class PhoneNumber(Base, TimestampMixin):
|
||||
"""Gogo SIM number assigned to a salon; maps to a GSM gateway port (§5.1)."""
|
||||
|
||||
__tablename__ = "phone_numbers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("tenants.id"), nullable=True, unique=True
|
||||
)
|
||||
msisdn: Mapped[str] = mapped_column(String(30), unique=True)
|
||||
gateway_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
operator: Mapped[str] = mapped_column(String(30), default="") # mtel|bhtelecom|eronet
|
||||
sim_status: Mapped[str] = mapped_column(String(20), default="unassigned")
|
||||
|
||||
|
||||
class Call(Base):
|
||||
__tablename__ = "calls"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
caller_msisdn: Mapped[str] = mapped_column(String(30), default="")
|
||||
duration_s: Mapped[int] = mapped_column(Integer, default=0)
|
||||
outcome: Mapped[str] = mapped_column(String(30), default="abandoned")
|
||||
recording_path: Mapped[str | None] = mapped_column(String(400), nullable=True)
|
||||
transcript: Mapped[list] = mapped_column(JSONVariant, default=list) # [{role, text, t}]
|
||||
agent_seconds_charged: Mapped[int] = mapped_column(Integer, default=0)
|
||||
trace: Mapped[dict] = mapped_column(JSONVariant, default=dict) # latency breakdown etc.
|
||||
|
||||
|
||||
class ChatSession(Base):
|
||||
__tablename__ = "chat_sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
resume_token: Mapped[str] = mapped_column(String(64), default=lambda: uuid.uuid4().hex)
|
||||
client_ip: Mapped[str] = mapped_column(String(60), default="")
|
||||
outcome: Mapped[str] = mapped_column(String(30), default="")
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("chat_sessions.id"), index=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
role: Mapped[str] = mapped_column(String(20)) # user | assistant | tool
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class BookingRequest(Base, TimestampMixin):
|
||||
__tablename__ = "booking_requests"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
source: Mapped[str] = mapped_column(String(10)) # voice | chat
|
||||
client_name: Mapped[str] = mapped_column(String(200))
|
||||
client_phone: Mapped[str] = mapped_column(String(40))
|
||||
service_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("services.id"), nullable=True)
|
||||
service_name_raw: Mapped[str] = mapped_column(String(300), default="")
|
||||
slots: Mapped[list] = mapped_column(JSONVariant, default=list) # ordered Slot dicts
|
||||
time_preference_text: Mapped[str] = mapped_column(String(300), default="")
|
||||
home_visit: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
address: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
status: Mapped[str] = mapped_column(String(30), default="pending", index=True)
|
||||
confirmed_slot: Mapped[dict | None] = mapped_column(JSONVariant, nullable=True)
|
||||
partner_request_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
call_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("calls.id"), nullable=True)
|
||||
chat_session_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("chat_sessions.id"), nullable=True
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
reminder_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
resolution_note: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class ActionToken(Base):
|
||||
"""Signed single-use action link tokens for proposal emails (§9.1)."""
|
||||
|
||||
__tablename__ = "action_tokens"
|
||||
|
||||
token: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
request_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("booking_requests.id"), index=True)
|
||||
action: Mapped[str] = mapped_column(String(30)) # resolve | confirm | reject
|
||||
slot_index: Mapped[int | None] = mapped_column(Integer, nullable=True) # for confirm
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class MessageForOwner(Base):
|
||||
"""Non-booking messages taken by the agent (take_message tool)."""
|
||||
|
||||
__tablename__ = "messages_for_owner"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
client_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
client_phone: Mapped[str] = mapped_column(String(40), default="")
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
source: Mapped[str] = mapped_column(String(10), default="voice")
|
||||
read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class SmsLog(Base):
|
||||
__tablename__ = "sms_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
to_msisdn: Mapped[str] = mapped_column(String(30))
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
kind: Mapped[str] = mapped_column(String(30)) # missed_call|received|confirmation|rejection|expiry
|
||||
status: Mapped[str] = mapped_column(String(20), default="queued") # queued|sent|failed
|
||||
error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class EmailLog(Base):
|
||||
__tablename__ = "email_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tenants.id"), nullable=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
to_addr: Mapped[str] = mapped_column(String(300))
|
||||
subject: Mapped[str] = mapped_column(String(400))
|
||||
kind: Mapped[str] = mapped_column(String(30))
|
||||
status: Mapped[str] = mapped_column(String(20), default="queued")
|
||||
error: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class UsageCounter(Base):
|
||||
"""Per tenant per month usage (§12). month format: 'YYYY-MM'."""
|
||||
|
||||
__tablename__ = "usage_counters"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "month"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
month: Mapped[str] = mapped_column(String(7))
|
||||
agent_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
calls: Mapped[int] = mapped_column(Integer, default=0)
|
||||
sms_sent: Mapped[int] = mapped_column(Integer, default=0)
|
||||
chat_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
warned_80: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
warned_100: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
"""Owner account — single account per salon (§10)."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
|
||||
email: Mapped[str] = mapped_column(String(200), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(200))
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class Admin(Base, TimestampMixin):
|
||||
__tablename__ = "admins"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(200), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(200))
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class PromptTemplate(Base):
|
||||
"""Versioned global agent prompt template, super-admin owned (§6.3)."""
|
||||
|
||||
__tablename__ = "prompt_templates"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
version: Mapped[int] = mapped_column(Integer, unique=True)
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class TenantPromptOverride(Base):
|
||||
"""Per-tenant prompt override — super-admin-only escape hatch (§6.3)."""
|
||||
|
||||
__tablename__ = "tenant_prompt_overrides"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tenants.id"), unique=True)
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
)
|
||||
0
gogo/proposals/__init__.py
Normal file
0
gogo/proposals/__init__.py
Normal file
217
gogo/proposals/email.py
Normal file
217
gogo/proposals/email.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Proposal email to the owner: subject, body, action links, .ics (§9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.domain import Slot
|
||||
from gogo.email import EmailMessage, send_email
|
||||
from gogo.i18n import fmt_slot
|
||||
from gogo.models import BookingRequest, EmailLog, Service, Tenant
|
||||
from gogo.proposals.ics import build_ics
|
||||
from gogo.proposals.tokens import action_url, create_action_token
|
||||
|
||||
|
||||
def _slots_from_request(req: BookingRequest) -> list[Slot]:
|
||||
return [Slot.model_validate(s) for s in (req.slots or [])]
|
||||
|
||||
|
||||
async def send_proposal_email(
|
||||
session: AsyncSession, tenant: Tenant, req: BookingRequest, service: Service | None
|
||||
) -> None:
|
||||
recipients = list(tenant.notify_emails or [])
|
||||
if not recipients:
|
||||
return
|
||||
|
||||
slots = _slots_from_request(req)
|
||||
service_name = service.name if service else (req.service_name_raw or "termin")
|
||||
days = ", ".join(sorted({fmt_slot(s.start, tenant.timezone).split(",")[0] for s in slots}))
|
||||
subject = f"Novi zahtjev za termin — {service_name} — {req.client_name}"
|
||||
if days:
|
||||
subject += f" ({days})"
|
||||
|
||||
resolve_url = action_url(await create_action_token(session, req.id, "resolve"))
|
||||
reject_url = action_url(await create_action_token(session, req.id, "reject"))
|
||||
confirm_urls = [
|
||||
(
|
||||
fmt_slot(s.start, tenant.timezone),
|
||||
action_url(await create_action_token(session, req.id, "confirm", slot_index=i)),
|
||||
)
|
||||
for i, s in enumerate(slots)
|
||||
]
|
||||
|
||||
transcript_url = f"{get_settings().base_url}/t/{req.id}"
|
||||
|
||||
lines = [
|
||||
f"Novi zahtjev za termin — {tenant.name}",
|
||||
"",
|
||||
f"Klijent: {req.client_name}",
|
||||
f"Telefon: {req.client_phone}",
|
||||
f"Usluga: {service_name}",
|
||||
]
|
||||
if req.home_visit:
|
||||
lines.append(f"Dolazak na adresu: DA — {req.address or 'adresa nije navedena'}")
|
||||
if slots:
|
||||
lines.append("Traženi termini (po redoslijedu želje):")
|
||||
lines += [f" {i + 1}. {fmt_slot(s.start, tenant.timezone)}" for i, s in enumerate(slots)]
|
||||
if req.time_preference_text and not slots:
|
||||
lines.append(f"Željeno vrijeme: {req.time_preference_text}")
|
||||
if req.summary:
|
||||
lines += ["", f"Sažetak razgovora: {req.summary}"]
|
||||
lines += [
|
||||
"",
|
||||
f"Cijeli razgovor: {transcript_url}",
|
||||
"",
|
||||
"─" * 40,
|
||||
f"RIJEŠENO — kontaktirao/la sam klijenta:\n {resolve_url}",
|
||||
]
|
||||
for label, url in confirm_urls:
|
||||
lines.append(f"POTVRDI {label} + pošalji SMS klijentu:\n {url}")
|
||||
lines.append(f"ODBIJ zahtjev:\n {reject_url}")
|
||||
|
||||
html = _render_html(
|
||||
tenant, req, service_name, slots, resolve_url, confirm_urls, reject_url, transcript_url
|
||||
)
|
||||
|
||||
attachments = []
|
||||
if slots:
|
||||
first = slots[0]
|
||||
ics = build_ics(
|
||||
uid=str(req.id),
|
||||
start=first.start,
|
||||
end=first.end,
|
||||
summary=f"{service_name} — {req.client_name} (zahtjev)",
|
||||
description=(
|
||||
f"Zahtjev putem Gogo Telefona.\nKlijent: {req.client_name}, {req.client_phone}\n"
|
||||
f"{req.summary}"
|
||||
),
|
||||
)
|
||||
attachments.append(("termin.ics", "text/calendar", ics))
|
||||
|
||||
await send_email(EmailMessage(recipients, subject, "\n".join(lines), html, attachments))
|
||||
session.add(
|
||||
EmailLog(
|
||||
tenant_id=tenant.id,
|
||||
to_addr=", ".join(recipients),
|
||||
subject=subject,
|
||||
kind="proposal",
|
||||
status="sent",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _render_html(
|
||||
tenant: Tenant,
|
||||
req: BookingRequest,
|
||||
service_name: str,
|
||||
slots: list[Slot],
|
||||
resolve_url: str,
|
||||
confirm_urls: list[tuple[str, str]],
|
||||
reject_url: str,
|
||||
transcript_url: str,
|
||||
) -> str:
|
||||
def esc(s: str) -> str:
|
||||
return (
|
||||
str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
)
|
||||
|
||||
slot_rows = "".join(
|
||||
f"<li>{esc(fmt_slot(s.start, tenant.timezone))}</li>" for s in slots
|
||||
)
|
||||
confirm_buttons = "".join(
|
||||
f'<p><a href="{url}" style="background:#2563eb;color:#fff;padding:10px 16px;'
|
||||
f'border-radius:6px;text-decoration:none;display:inline-block">'
|
||||
f"Potvrdi {esc(label)} + SMS</a></p>"
|
||||
for label, url in confirm_urls
|
||||
)
|
||||
home = (
|
||||
f"<p><b>Dolazak na adresu:</b> DA — {esc(req.address or 'adresa nije navedena')}</p>"
|
||||
if req.home_visit
|
||||
else ""
|
||||
)
|
||||
pref = (
|
||||
f"<p><b>Željeno vrijeme:</b> {esc(req.time_preference_text)}</p>"
|
||||
if req.time_preference_text and not slots
|
||||
else ""
|
||||
)
|
||||
summary = f"<p><b>Sažetak:</b> {esc(req.summary)}</p>" if req.summary else ""
|
||||
return f"""
|
||||
<div style="font-family:sans-serif;max-width:560px">
|
||||
<h2 style="margin-bottom:4px">Novi zahtjev za termin</h2>
|
||||
<p style="color:#666;margin-top:0">{esc(tenant.name)}</p>
|
||||
<p><b>Klijent:</b> {esc(req.client_name)}<br>
|
||||
<b>Telefon:</b> {esc(req.client_phone)}<br>
|
||||
<b>Usluga:</b> {esc(service_name)}</p>
|
||||
{home}
|
||||
{"<p><b>Traženi termini:</b></p><ol>" + slot_rows + "</ol>" if slots else ""}
|
||||
{pref}
|
||||
{summary}
|
||||
<p><a href="{transcript_url}">Cijeli razgovor →</a></p>
|
||||
<hr>
|
||||
<p><a href="{resolve_url}" style="background:#16a34a;color:#fff;padding:12px 20px;
|
||||
border-radius:6px;text-decoration:none;display:inline-block;font-weight:bold">
|
||||
✓ Riješeno — kontaktirao/la sam klijenta</a></p>
|
||||
{confirm_buttons}
|
||||
<p><a href="{reject_url}" style="color:#dc2626">Odbij zahtjev</a></p>
|
||||
<p style="color:#999;font-size:12px">Gogo Telefon — virtuelni asistent salona.
|
||||
U prilogu je .ics za prvi traženi termin (možete ga pomjeriti prije spremanja u kalendar).</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
async def send_reminder_email(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> None:
|
||||
"""Reminder at 50% of proposal TTL (§9.2)."""
|
||||
recipients = list(tenant.notify_emails or [])
|
||||
if not recipients:
|
||||
return
|
||||
subject = f"Podsjetnik: neodgovoren zahtjev — {req.client_name}"
|
||||
ttl = tenant.proposal_ttl_hours or 24
|
||||
body = (
|
||||
f"Zahtjev klijenta {req.client_name} ({req.client_phone}) čeka odgovor.\n"
|
||||
f"Ako ne odgovorite u roku od {ttl // 2}h, zahtjev ističe i klijent dobija "
|
||||
f"SMS s molbom da nazove ponovo.\n\n"
|
||||
f"Pregled: {get_settings().base_url}/t/{req.id}"
|
||||
)
|
||||
await send_email(EmailMessage(recipients, subject, body))
|
||||
session.add(
|
||||
EmailLog(
|
||||
tenant_id=tenant.id,
|
||||
to_addr=", ".join(recipients),
|
||||
subject=subject,
|
||||
kind="proposal_reminder",
|
||||
status="sent",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def send_usage_warning_email(
|
||||
session: AsyncSession, tenant: Tenant, used_minutes: int, pct: int
|
||||
) -> None:
|
||||
recipients = list(tenant.notify_emails or [])
|
||||
if not recipients:
|
||||
return
|
||||
subject = f"Gogo Telefon: iskorišteno {pct}% minuta ovaj mjesec"
|
||||
body = (
|
||||
f"Salon {tenant.name} je iskoristio {used_minutes} od {tenant.included_minutes} "
|
||||
f"uključenih minuta virtualnog asistenta ovaj mjesec.\n"
|
||||
"Asistent nastavlja odgovarati na pozive. Za veći paket javite se Gogo podršci."
|
||||
)
|
||||
await send_email(EmailMessage(recipients, subject, body))
|
||||
session.add(
|
||||
EmailLog(
|
||||
tenant_id=tenant.id,
|
||||
to_addr=", ".join(recipients),
|
||||
subject=subject,
|
||||
kind="usage_warning",
|
||||
status="sent",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
from datetime import UTC
|
||||
|
||||
return datetime.now(UTC)
|
||||
251
gogo/proposals/engine.py
Normal file
251
gogo/proposals/engine.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""Proposal engine (§9): create booking requests, route delivery through the
|
||||
tenant's scheduling provider, drive state transitions and client SMS.
|
||||
|
||||
States: pending → resolved_by_owner | confirmed | rejected | expired
|
||||
The same transition functions are used by email action links, the dashboard,
|
||||
and the partner webhook — semantics are identical everywhere (§B.3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.domain import BookingRequestData, BookingStatus, DeliveryResult, Slot
|
||||
from gogo.i18n import fmt_date, fmt_slot, fmt_time
|
||||
from gogo.models import BookingRequest, Service, Tenant, utcnow
|
||||
from gogo.scheduling.base import get_provider
|
||||
from gogo.sms import send_sms
|
||||
from gogo.sms.templates import render_sms
|
||||
|
||||
log = logging.getLogger("gogo.proposals")
|
||||
|
||||
|
||||
class TransitionError(Exception):
|
||||
"""Invalid state transition (e.g. confirming an already-rejected request)."""
|
||||
|
||||
|
||||
async def create_booking_request(
|
||||
session: AsyncSession,
|
||||
tenant: Tenant,
|
||||
*,
|
||||
source: str,
|
||||
client_name: str,
|
||||
client_phone: str,
|
||||
service_id: str | None = None,
|
||||
service_name_raw: str = "",
|
||||
slots: list[Slot] | None = None,
|
||||
time_preference_text: str = "",
|
||||
home_visit: bool = False,
|
||||
address: str | None = None,
|
||||
summary: str = "",
|
||||
call_id: uuid.UUID | None = None,
|
||||
chat_session_id: uuid.UUID | None = None,
|
||||
) -> tuple[BookingRequest, DeliveryResult]:
|
||||
"""Create a pending request and deliver it via the tenant's provider."""
|
||||
slots = (slots or [])[:3] # hard cap: at most 3 offered slots (§6.2)
|
||||
now = utcnow()
|
||||
req = BookingRequest(
|
||||
tenant_id=tenant.id,
|
||||
source=source,
|
||||
client_name=client_name.strip(),
|
||||
client_phone=client_phone.strip(),
|
||||
service_id=uuid.UUID(service_id) if service_id else None,
|
||||
service_name_raw=service_name_raw,
|
||||
slots=[s.model_dump(mode="json") for s in slots],
|
||||
time_preference_text=time_preference_text,
|
||||
home_visit=home_visit,
|
||||
address=address,
|
||||
summary=summary,
|
||||
call_id=call_id,
|
||||
chat_session_id=chat_session_id,
|
||||
status=BookingStatus.pending.value,
|
||||
expires_at=now + timedelta(hours=tenant.proposal_ttl_hours or 24),
|
||||
)
|
||||
session.add(req)
|
||||
await session.flush() # assign req.id
|
||||
|
||||
data = BookingRequestData(
|
||||
gogo_request_id=str(req.id),
|
||||
tenant_id=str(tenant.id),
|
||||
created_at=now,
|
||||
source=source,
|
||||
client_name=req.client_name,
|
||||
client_phone=req.client_phone,
|
||||
service_id=service_id,
|
||||
service_name_raw=service_name_raw,
|
||||
requested_slots=slots,
|
||||
time_preference_text=time_preference_text,
|
||||
home_visit=home_visit,
|
||||
address=address,
|
||||
summary=summary,
|
||||
transcript_url=f"{get_settings().base_url}/t/{req.id}",
|
||||
)
|
||||
provider = await get_provider(session, tenant)
|
||||
result = await provider.deliver_request(data)
|
||||
if not result.ok:
|
||||
log.error("delivery failed for request %s: %s", req.id, result.detail)
|
||||
|
||||
# Optional "request received" SMS to the client (§9.1)
|
||||
if tenant.sms_request_received and req.client_phone:
|
||||
await send_sms(
|
||||
session,
|
||||
tenant.id,
|
||||
req.client_phone,
|
||||
render_sms(tenant, "request_received"),
|
||||
kind="received",
|
||||
)
|
||||
return req, result
|
||||
|
||||
|
||||
# -- transitions -------------------------------------------------------------
|
||||
|
||||
|
||||
async def resolve_request(
|
||||
session: AsyncSession, tenant: Tenant, req: BookingRequest, note: str = ""
|
||||
) -> None:
|
||||
"""Owner contacted the client directly — primary flow. Gogo sends NO SMS (§9.2)."""
|
||||
_require_pending(req, allow_same=BookingStatus.resolved_by_owner)
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return # idempotent replay
|
||||
req.status = BookingStatus.resolved_by_owner.value
|
||||
req.resolved_at = utcnow()
|
||||
req.resolution_note = note
|
||||
|
||||
|
||||
async def confirm_request(
|
||||
session: AsyncSession,
|
||||
tenant: Tenant,
|
||||
req: BookingRequest,
|
||||
slot: Slot,
|
||||
*,
|
||||
recheck: bool = True,
|
||||
) -> bool:
|
||||
"""Owner confirms a slot → confirmation SMS to client.
|
||||
|
||||
Returns False (no transition) if recheck finds the slot busy — the caller
|
||||
should show a warning and let the owner decide (force with recheck=False).
|
||||
"""
|
||||
_require_pending(req, allow_same=BookingStatus.confirmed)
|
||||
if req.status == BookingStatus.confirmed.value:
|
||||
return True # idempotent replay
|
||||
|
||||
if recheck:
|
||||
provider = await get_provider(session, tenant)
|
||||
checker = getattr(provider, "is_slot_free", None)
|
||||
if checker is not None:
|
||||
try:
|
||||
free = await checker(
|
||||
str(req.service_id) if req.service_id else None, slot
|
||||
)
|
||||
except Exception: # noqa: BLE001 — recheck is best-effort
|
||||
log.exception("free/busy recheck failed for %s", req.id)
|
||||
free = True
|
||||
if not free:
|
||||
return False
|
||||
|
||||
req.status = BookingStatus.confirmed.value
|
||||
req.confirmed_slot = slot.model_dump(mode="json")
|
||||
req.resolved_at = utcnow()
|
||||
|
||||
service_name = await _service_name(session, req)
|
||||
day_name = fmt_slot(slot.start, tenant.timezone).split(",")[0]
|
||||
body = render_sms(
|
||||
tenant,
|
||||
"confirmation",
|
||||
usluga=service_name,
|
||||
dan=day_name,
|
||||
datum=fmt_date(slot.start, tenant.timezone),
|
||||
vrijeme=fmt_time(slot.start, tenant.timezone),
|
||||
)
|
||||
if req.client_phone:
|
||||
await send_sms(session, tenant.id, req.client_phone, body, kind="confirmation")
|
||||
return True
|
||||
|
||||
|
||||
async def reject_request(
|
||||
session: AsyncSession, tenant: Tenant, req: BookingRequest, custom_sms: str | None = None
|
||||
) -> None:
|
||||
"""Reject → rejection SMS to client (template, editable before send §9.2)."""
|
||||
_require_pending(req, allow_same=BookingStatus.rejected)
|
||||
if req.status == BookingStatus.rejected.value:
|
||||
return # idempotent replay
|
||||
req.status = BookingStatus.rejected.value
|
||||
req.resolved_at = utcnow()
|
||||
body = custom_sms or render_sms(tenant, "rejection")
|
||||
if req.client_phone:
|
||||
await send_sms(session, tenant.id, req.client_phone, body, kind="rejection")
|
||||
|
||||
|
||||
async def expire_request(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> None:
|
||||
"""TTL passed with no owner action → apology SMS to client (§9.2)."""
|
||||
if req.status != BookingStatus.pending.value:
|
||||
return
|
||||
req.status = BookingStatus.expired.value
|
||||
req.resolved_at = utcnow()
|
||||
if req.client_phone:
|
||||
await send_sms(
|
||||
session, tenant.id, req.client_phone, render_sms(tenant, "expiry"), kind="expiry"
|
||||
)
|
||||
|
||||
|
||||
def _require_pending(req: BookingRequest, allow_same: BookingStatus) -> None:
|
||||
if req.status not in (BookingStatus.pending.value, allow_same.value):
|
||||
raise TransitionError(
|
||||
f"request {req.id} is {req.status}, cannot transition to {allow_same.value}"
|
||||
)
|
||||
|
||||
|
||||
async def _service_name(session: AsyncSession, req: BookingRequest) -> str:
|
||||
if req.service_id:
|
||||
service = (
|
||||
await session.execute(select(Service).where(Service.id == req.service_id))
|
||||
).scalar_one_or_none()
|
||||
if service:
|
||||
return service.name
|
||||
return req.service_name_raw or "termin"
|
||||
|
||||
|
||||
# -- background jobs ---------------------------------------------------------
|
||||
|
||||
|
||||
async def process_expirations(session: AsyncSession) -> int:
|
||||
"""Expire overdue pending requests; send owner reminders at 50% TTL. Returns count expired."""
|
||||
from gogo.proposals.email import send_reminder_email
|
||||
|
||||
now = datetime.now(UTC)
|
||||
pending = (
|
||||
(
|
||||
await session.execute(
|
||||
select(BookingRequest).where(
|
||||
BookingRequest.status == BookingStatus.pending.value
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
expired = 0
|
||||
for req in pending:
|
||||
tenant = (
|
||||
await session.execute(select(Tenant).where(Tenant.id == req.tenant_id))
|
||||
).scalar_one()
|
||||
expires_at = req.expires_at
|
||||
if expires_at is None:
|
||||
continue
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
if expires_at <= now:
|
||||
await expire_request(session, tenant, req)
|
||||
expired += 1
|
||||
else:
|
||||
ttl = timedelta(hours=tenant.proposal_ttl_hours or 24)
|
||||
if not req.reminder_sent and expires_at - now <= ttl / 2:
|
||||
await send_reminder_email(session, tenant, req)
|
||||
req.reminder_sent = True
|
||||
return expired
|
||||
42
gogo/proposals/ics.py
Normal file
42
gogo/proposals/ics.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Generate the .ics attachment (METHOD:REQUEST) for the first-choice slot (§9.1).
|
||||
|
||||
The .ics is a convenience so Gmail offers "Add to calendar" — the owner can move
|
||||
and rearrange the event before saving. Gogo never writes to the owner's calendar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from icalendar import Calendar, Event, vCalAddress, vText
|
||||
|
||||
from gogo.config import get_settings
|
||||
|
||||
|
||||
def build_ics(
|
||||
*,
|
||||
uid: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
summary: str,
|
||||
description: str,
|
||||
organizer_email: str = "noreply@gogotelefon.ba",
|
||||
) -> bytes:
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//Gogo Telefon//gogotelefon.ba//BS")
|
||||
cal.add("version", "2.0")
|
||||
cal.add("method", "REQUEST")
|
||||
|
||||
ev = Event()
|
||||
ev.add("uid", f"{uid}@gogotelefon.ba")
|
||||
ev.add("dtstart", start)
|
||||
ev.add("dtend", end)
|
||||
ev.add("summary", summary)
|
||||
ev.add("description", description)
|
||||
ev.add("status", "TENTATIVE")
|
||||
organizer = vCalAddress(f"MAILTO:{organizer_email}")
|
||||
organizer.params["cn"] = vText("Gogo Telefon")
|
||||
ev["organizer"] = organizer
|
||||
ev.add("url", get_settings().base_url)
|
||||
cal.add_component(ev)
|
||||
return cal.to_ical()
|
||||
44
gogo/proposals/tokens.py
Normal file
44
gogo/proposals/tokens.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Single-use signed action tokens for proposal email links (§9.1).
|
||||
|
||||
Tokens are random, stored in DB (single-use enforced there) and the URL carries
|
||||
an itsdangerous signature so guessing/forging is infeasible even if the DB row
|
||||
leaked. Action links must be idempotent (§15): a used 'resolve' link re-visited
|
||||
shows "already resolved", it never errors or double-fires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from itsdangerous import BadSignature, URLSafeSerializer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.models import ActionToken
|
||||
|
||||
|
||||
def _serializer() -> URLSafeSerializer:
|
||||
return URLSafeSerializer(get_settings().secret_key, salt="proposal-action")
|
||||
|
||||
|
||||
async def create_action_token(
|
||||
session: AsyncSession, request_id: uuid.UUID, action: str, slot_index: int | None = None
|
||||
) -> str:
|
||||
"""Create a DB-backed token and return the signed URL-safe token string."""
|
||||
raw = secrets.token_urlsafe(24)[:40]
|
||||
session.add(
|
||||
ActionToken(token=raw, request_id=request_id, action=action, slot_index=slot_index)
|
||||
)
|
||||
return _serializer().dumps(raw)
|
||||
|
||||
|
||||
def action_url(signed: str) -> str:
|
||||
return f"{get_settings().base_url}/a/{signed}"
|
||||
|
||||
|
||||
def unsign(signed: str) -> str | None:
|
||||
try:
|
||||
return _serializer().loads(signed)
|
||||
except BadSignature:
|
||||
return None
|
||||
1
gogo/scheduling/__init__.py
Normal file
1
gogo/scheduling/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from gogo.scheduling.base import SchedulingProvider, get_provider # noqa: F401
|
||||
72
gogo/scheduling/base.py
Normal file
72
gogo/scheduling/base.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""SchedulingProvider interface (§8) and per-tenant provider resolution.
|
||||
|
||||
Adding a third provider = one new module registering itself here. No changes
|
||||
to the agent, tools, or proposal engine are needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
|
||||
from gogo.models import ProviderConfig, Tenant
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SchedulingProvider(Protocol):
|
||||
"""Availability lookup + proposal delivery for one tenant."""
|
||||
|
||||
async def get_services(self) -> list[ServiceInfo] | None:
|
||||
"""Optional catalog sync; None = provider has no catalog."""
|
||||
...
|
||||
|
||||
async def get_availability(
|
||||
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
|
||||
) -> list[Slot]:
|
||||
"""Ready-to-offer free slots. The agent picks up to 3."""
|
||||
...
|
||||
|
||||
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
|
||||
"""Deliver the proposal to the owner (email / partner push).
|
||||
|
||||
Outcome is signaled back via webhook/polling (partner) or the owner's
|
||||
email/dashboard actions (google_calendar, mock).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
_REGISTRY: dict[str, type] = {}
|
||||
|
||||
|
||||
def register_provider(name: str):
|
||||
def deco(cls):
|
||||
_REGISTRY[name] = cls
|
||||
return cls
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
async def get_provider(session: AsyncSession, tenant: Tenant) -> SchedulingProvider:
|
||||
"""Instantiate the tenant's configured provider."""
|
||||
# Imports here to avoid circulars; modules self-register on import.
|
||||
from gogo.scheduling import google_calendar, mock, partner_api # noqa: F401
|
||||
|
||||
cls = _REGISTRY.get(tenant.scheduling_provider)
|
||||
if cls is None:
|
||||
raise ValueError(f"Unknown scheduling provider: {tenant.scheduling_provider}")
|
||||
config = await _load_config(session, tenant)
|
||||
return cls(session=session, tenant=tenant, config=config)
|
||||
|
||||
|
||||
async def _load_config(session: AsyncSession, tenant: Tenant) -> dict:
|
||||
from sqlalchemy import select
|
||||
|
||||
row = (
|
||||
await session.execute(
|
||||
select(ProviderConfig).where(ProviderConfig.tenant_id == tenant.id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return row.config if row else {}
|
||||
211
gogo/scheduling/google_calendar.py
Normal file
211
gogo/scheduling/google_calendar.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Google Calendar provider (§8.1) — free/busy READ ONLY.
|
||||
|
||||
Gogo never requests calendar write access and never creates/modifies events.
|
||||
Availability = tenant working hours minus busy blocks from the mapped calendar,
|
||||
quantized to service duration. Delivery = owner email with action links + .ics.
|
||||
|
||||
Uses raw HTTP (httpx) against the Calendar v3 freeBusy endpoint + OAuth token
|
||||
refresh — no heavy Google SDK, easy to point at a fake server in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.crypto import decrypt, encrypt
|
||||
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
|
||||
from gogo.hours import DEFAULT_WORKING_HOURS
|
||||
from gogo.models import (
|
||||
BookingRequest,
|
||||
CalendarConnection,
|
||||
CalendarMapping,
|
||||
Service,
|
||||
Tenant,
|
||||
utcnow,
|
||||
)
|
||||
from gogo.scheduling.base import register_provider
|
||||
from gogo.scheduling.slots import compute_slots
|
||||
|
||||
log = logging.getLogger("gogo.gcal")
|
||||
|
||||
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
GOOGLE_FREEBUSY_URL = "https://www.googleapis.com/calendar/v3/freeBusy"
|
||||
|
||||
|
||||
@register_provider("google_calendar")
|
||||
class GoogleCalendarProvider:
|
||||
# test hooks: override endpoints / freeze time
|
||||
token_url: str = GOOGLE_TOKEN_URL
|
||||
freebusy_url: str = GOOGLE_FREEBUSY_URL
|
||||
now_override: datetime | None = None
|
||||
|
||||
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
|
||||
self.session = session
|
||||
self.tenant = tenant
|
||||
self.config = config
|
||||
|
||||
async def get_services(self) -> list[ServiceInfo] | None:
|
||||
return None # services are maintained in the Gogo dashboard
|
||||
|
||||
async def get_availability(
|
||||
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
|
||||
) -> list[Slot]:
|
||||
service = (
|
||||
await self.session.execute(
|
||||
select(Service).where(Service.id == uuid.UUID(service_id))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
duration = service.duration_min if service else 30
|
||||
buffer_min = service.buffer_min if service else 0
|
||||
tz = ZoneInfo(self.tenant.timezone)
|
||||
|
||||
calendar_id = await self._calendar_for_service(service)
|
||||
busy = await self.fetch_busy(calendar_id, date_from, date_to, tz)
|
||||
|
||||
return compute_slots(
|
||||
working_hours=self.tenant.working_hours or DEFAULT_WORKING_HOURS,
|
||||
busy=busy,
|
||||
duration_min=duration,
|
||||
buffer_min=buffer_min,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
tz=tz,
|
||||
now=self.now_override or utcnow(),
|
||||
min_notice_hours=self.tenant.min_notice_hours,
|
||||
max_days_ahead=self.tenant.max_days_ahead,
|
||||
)
|
||||
|
||||
async def is_slot_free(self, service_id: str | None, slot: Slot) -> bool:
|
||||
"""Re-check free/busy before 'confirm + SMS' (§9.2)."""
|
||||
service = None
|
||||
if service_id:
|
||||
service = (
|
||||
await self.session.execute(
|
||||
select(Service).where(Service.id == uuid.UUID(str(service_id)))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
calendar_id = await self._calendar_for_service(service)
|
||||
tz = ZoneInfo(self.tenant.timezone)
|
||||
busy = await self.fetch_busy(
|
||||
calendar_id, slot.start.date(), slot.end.date(), tz
|
||||
)
|
||||
return not any(b_start < slot.end and b_end > slot.start for b_start, b_end in busy)
|
||||
|
||||
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
|
||||
if booking_request.dry_run:
|
||||
return DeliveryResult(ok=True, detail="dry-run ok (google_calendar)")
|
||||
from gogo.proposals.email import send_proposal_email
|
||||
|
||||
req = (
|
||||
await self.session.execute(
|
||||
select(BookingRequest).where(
|
||||
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
service = None
|
||||
if req.service_id:
|
||||
service = (
|
||||
await self.session.execute(select(Service).where(Service.id == req.service_id))
|
||||
).scalar_one_or_none()
|
||||
await send_proposal_email(self.session, self.tenant, req, service)
|
||||
return DeliveryResult(ok=True, detail="email sent")
|
||||
|
||||
# -- internals ---------------------------------------------------------
|
||||
|
||||
async def _calendar_for_service(self, service: Service | None) -> str:
|
||||
"""Mapped calendar for the service, tenant default mapping, or 'primary'."""
|
||||
if service is not None:
|
||||
row = (
|
||||
await self.session.execute(
|
||||
select(CalendarMapping).where(
|
||||
CalendarMapping.tenant_id == self.tenant.id,
|
||||
CalendarMapping.service_id == service.id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
return row.google_calendar_id
|
||||
default = (
|
||||
await self.session.execute(
|
||||
select(CalendarMapping).where(
|
||||
CalendarMapping.tenant_id == self.tenant.id,
|
||||
CalendarMapping.service_id.is_(None),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return default.google_calendar_id if default else "primary"
|
||||
|
||||
async def fetch_busy(
|
||||
self, calendar_id: str, date_from: date, date_to: date, tz: ZoneInfo
|
||||
) -> list[tuple[datetime, datetime]]:
|
||||
conn = (
|
||||
await self.session.execute(
|
||||
select(CalendarConnection).where(CalendarConnection.tenant_id == self.tenant.id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if conn is None:
|
||||
log.warning("tenant %s: google_calendar provider without connection", self.tenant.id)
|
||||
return []
|
||||
|
||||
access_token = await self._fresh_access_token(conn)
|
||||
time_min = datetime.combine(date_from, datetime.min.time(), tzinfo=tz)
|
||||
time_max = datetime.combine(date_to + timedelta(days=1), datetime.min.time(), tzinfo=tz)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
self.freebusy_url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
json={
|
||||
"timeMin": time_min.isoformat(),
|
||||
"timeMax": time_max.isoformat(),
|
||||
"items": [{"id": calendar_id}],
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
busy = []
|
||||
for cal in data.get("calendars", {}).values():
|
||||
for block in cal.get("busy", []):
|
||||
busy.append(
|
||||
(
|
||||
datetime.fromisoformat(block["start"]),
|
||||
datetime.fromisoformat(block["end"]),
|
||||
)
|
||||
)
|
||||
return busy
|
||||
|
||||
async def _fresh_access_token(self, conn: CalendarConnection) -> str:
|
||||
token_data = json.loads(decrypt(conn.token_data_encrypted))
|
||||
expiry = token_data.get("expiry")
|
||||
if expiry and datetime.fromisoformat(expiry) > datetime.now(UTC) + timedelta(minutes=2):
|
||||
return token_data["access_token"]
|
||||
# refresh
|
||||
from gogo.config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
self.token_url,
|
||||
data={
|
||||
"client_id": s.google_client_id,
|
||||
"client_secret": s.google_client_secret,
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"grant_type": "refresh_token",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
fresh = resp.json()
|
||||
token_data["access_token"] = fresh["access_token"]
|
||||
token_data["expiry"] = (
|
||||
datetime.now(UTC) + timedelta(seconds=fresh.get("expires_in", 3600))
|
||||
).isoformat()
|
||||
conn.token_data_encrypted = encrypt(json.dumps(token_data))
|
||||
return token_data["access_token"]
|
||||
81
gogo/scheduling/mock.py
Normal file
81
gogo/scheduling/mock.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Mock scheduling provider — in-memory, for tests and demos (§16 M1).
|
||||
|
||||
Availability: generated from tenant working hours with configurable busy blocks
|
||||
(class-level, settable by tests/demo seeder). Delivery: owner email, same as
|
||||
google_calendar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
|
||||
from gogo.hours import DEFAULT_WORKING_HOURS
|
||||
from gogo.models import BookingRequest, Service, Tenant, utcnow
|
||||
from gogo.scheduling.base import register_provider
|
||||
from gogo.scheduling.slots import compute_slots
|
||||
|
||||
|
||||
@register_provider("mock")
|
||||
class MockProvider:
|
||||
# tenant_id(str) -> list[(start, end)] busy blocks; tests populate this
|
||||
busy_blocks: dict[str, list[tuple[datetime, datetime]]] = {}
|
||||
# test hook: freeze "now"
|
||||
now_override: datetime | None = None
|
||||
|
||||
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
|
||||
self.session = session
|
||||
self.tenant = tenant
|
||||
self.config = config
|
||||
|
||||
async def get_services(self) -> list[ServiceInfo] | None:
|
||||
return None # no external catalog
|
||||
|
||||
async def get_availability(
|
||||
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
|
||||
) -> list[Slot]:
|
||||
service = (
|
||||
await self.session.execute(
|
||||
select(Service).where(Service.id == uuid.UUID(service_id))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
duration = service.duration_min if service else 30
|
||||
buffer_min = service.buffer_min if service else 0
|
||||
tz = ZoneInfo(self.tenant.timezone)
|
||||
return compute_slots(
|
||||
working_hours=self.tenant.working_hours or DEFAULT_WORKING_HOURS,
|
||||
busy=self.busy_blocks.get(str(self.tenant.id), []),
|
||||
duration_min=duration,
|
||||
buffer_min=buffer_min,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
tz=tz,
|
||||
now=self.now_override or utcnow(),
|
||||
min_notice_hours=self.tenant.min_notice_hours,
|
||||
max_days_ahead=self.tenant.max_days_ahead,
|
||||
)
|
||||
|
||||
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
|
||||
if booking_request.dry_run:
|
||||
return DeliveryResult(ok=True, detail="dry-run ok (mock)")
|
||||
from gogo.proposals.email import send_proposal_email
|
||||
|
||||
req = (
|
||||
await self.session.execute(
|
||||
select(BookingRequest).where(
|
||||
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
service = None
|
||||
if req.service_id:
|
||||
service = (
|
||||
await self.session.execute(select(Service).where(Service.id == req.service_id))
|
||||
).scalar_one_or_none()
|
||||
await send_proposal_email(self.session, self.tenant, req, service)
|
||||
return DeliveryResult(ok=True, detail="email sent (mock)")
|
||||
226
gogo/scheduling/partner_api.py
Normal file
226
gogo/scheduling/partner_api.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Partner API provider (§8.2, Appendix B).
|
||||
|
||||
The salon's own booking software is the source of truth: availability is pulled
|
||||
from `GET /availability` (ready slots, no slot math on our side) and booking
|
||||
requests are pushed via `POST /booking-requests` with an Idempotency-Key.
|
||||
Outcomes arrive on our webhook (gogo/api/webhooks.py) or via polling fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.crypto import decrypt
|
||||
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
|
||||
from gogo.models import BookingRequest, Service, Tenant
|
||||
from gogo.scheduling.base import register_provider
|
||||
|
||||
log = logging.getLogger("gogo.partner")
|
||||
|
||||
RETRIES = 3 # 5xx retries with exponential backoff (§B.0)
|
||||
RETRY_BASE_DELAY = 0.5
|
||||
|
||||
|
||||
@register_provider("partner_api")
|
||||
class PartnerApiProvider:
|
||||
# test hook: factory for the HTTP client (e.g. httpx.ASGITransport against a fake app)
|
||||
client_factory = staticmethod(lambda timeout: httpx.AsyncClient(timeout=timeout))
|
||||
|
||||
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
|
||||
self.session = session
|
||||
self.tenant = tenant
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return (self.config.get("base_url") or "").rstrip("/")
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
enc = self.config.get("api_key_encrypted")
|
||||
return decrypt(enc) if enc else ""
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
async def get_services(self) -> list[ServiceInfo] | None:
|
||||
"""Catalog sync from GET /services (optional endpoint, §B.4)."""
|
||||
if not self.config.get("catalog_sync"):
|
||||
return None
|
||||
data = await self._get("/services")
|
||||
return [
|
||||
ServiceInfo(
|
||||
id=str(s["id"]),
|
||||
name=s["name"],
|
||||
duration_min=int(s.get("duration_min", 30)),
|
||||
price_min=s.get("price_min"),
|
||||
price_max=s.get("price_max"),
|
||||
currency=s.get("currency", "BAM"),
|
||||
home_visit=bool(s.get("home_visit", False)),
|
||||
active=bool(s.get("active", True)),
|
||||
)
|
||||
for s in data.get("services", [])
|
||||
]
|
||||
|
||||
async def get_availability(
|
||||
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
|
||||
) -> list[Slot]:
|
||||
partner_service_id = await self._partner_service_id(service_id)
|
||||
data = await self._get(
|
||||
"/availability",
|
||||
params={
|
||||
"service_id": partner_service_id,
|
||||
"from": date_from.isoformat(),
|
||||
"to": date_to.isoformat(),
|
||||
"home_visit": "true" if home_visit else "false",
|
||||
},
|
||||
# §B.1: the voice agent is waiting mid-conversation
|
||||
timeout=2.0,
|
||||
)
|
||||
return [
|
||||
Slot(
|
||||
start=datetime.fromisoformat(s["start"]),
|
||||
end=datetime.fromisoformat(s["end"]),
|
||||
staff_id=s.get("staff_id"),
|
||||
staff_name=s.get("staff_name"),
|
||||
)
|
||||
for s in data.get("slots", [])
|
||||
]
|
||||
|
||||
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
|
||||
payload = {
|
||||
"gogo_request_id": booking_request.gogo_request_id,
|
||||
"created_at": booking_request.created_at.isoformat(),
|
||||
"source": booking_request.source,
|
||||
"client": {
|
||||
"name": booking_request.client_name,
|
||||
"phone": booking_request.client_phone,
|
||||
},
|
||||
"service_id": (
|
||||
await self._partner_service_id(booking_request.service_id)
|
||||
if booking_request.service_id
|
||||
else None
|
||||
),
|
||||
"service_name_raw": booking_request.service_name_raw,
|
||||
"requested_slots": [
|
||||
{"start": s.start.isoformat(), "end": s.end.isoformat()}
|
||||
for s in booking_request.requested_slots
|
||||
],
|
||||
"time_preference_text": booking_request.time_preference_text,
|
||||
"home_visit": booking_request.home_visit,
|
||||
"address": booking_request.address,
|
||||
"summary": booking_request.summary,
|
||||
"transcript_url": booking_request.transcript_url,
|
||||
"dry_run": booking_request.dry_run,
|
||||
}
|
||||
try:
|
||||
data = await self._post(
|
||||
"/booking-requests",
|
||||
json=payload,
|
||||
headers={"Idempotency-Key": booking_request.gogo_request_id},
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("partner push failed tenant=%s", self.tenant.id)
|
||||
return DeliveryResult(ok=False, detail=f"partner push failed: {e}")
|
||||
|
||||
partner_id = data.get("partner_request_id")
|
||||
if not booking_request.dry_run and partner_id:
|
||||
req = (
|
||||
await self.session.execute(
|
||||
select(BookingRequest).where(
|
||||
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if req:
|
||||
req.partner_request_id = str(partner_id)
|
||||
|
||||
# Email to the owner is optional for partner tenants (default off, §8.2)
|
||||
if not booking_request.dry_run and self.config.get("email_to_owner"):
|
||||
from gogo.proposals.email import send_proposal_email
|
||||
|
||||
req = (
|
||||
await self.session.execute(
|
||||
select(BookingRequest).where(
|
||||
BookingRequest.id == uuid.UUID(booking_request.gogo_request_id)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if req:
|
||||
service = None
|
||||
if req.service_id:
|
||||
service = (
|
||||
await self.session.execute(
|
||||
select(Service).where(Service.id == req.service_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
await send_proposal_email(self.session, self.tenant, req, service)
|
||||
|
||||
return DeliveryResult(ok=True, partner_request_id=partner_id, detail="pushed to partner")
|
||||
|
||||
async def poll_status(self, partner_request_id: str) -> dict:
|
||||
"""Polling fallback (§B.3): GET /booking-requests/{id}."""
|
||||
return await self._get(f"/booking-requests/{partner_request_id}")
|
||||
|
||||
# -- internals ---------------------------------------------------------
|
||||
|
||||
async def _partner_service_id(self, service_id: str | uuid.UUID | None) -> str | None:
|
||||
"""Our service UUID → partner's service id (services.partner_service_id)."""
|
||||
if service_id is None:
|
||||
return None
|
||||
service = (
|
||||
await self.session.execute(
|
||||
select(Service).where(Service.id == uuid.UUID(str(service_id)))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if service and service.partner_service_id:
|
||||
return service.partner_service_id
|
||||
return str(service_id)
|
||||
|
||||
async def _get(self, path: str, params: dict | None = None, timeout: float = 10.0) -> dict:
|
||||
return await self._request("GET", path, params=params, timeout=timeout)
|
||||
|
||||
async def _post(
|
||||
self, path: str, json: dict, headers: dict | None = None, timeout: float = 10.0
|
||||
) -> dict:
|
||||
return await self._request("POST", path, json=json, headers=headers, timeout=timeout)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict | None = None,
|
||||
json: dict | None = None,
|
||||
headers: dict | None = None,
|
||||
timeout: float = 10.0,
|
||||
) -> dict:
|
||||
url = f"{self.base_url}{path}"
|
||||
hdrs = {**self._headers(), **(headers or {})}
|
||||
last_exc: Exception | None = None
|
||||
async with self.client_factory(timeout=timeout) as client:
|
||||
for attempt in range(RETRIES):
|
||||
try:
|
||||
resp = await client.request(
|
||||
method, url, params=params, json=json, headers=hdrs
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
last_exc = e
|
||||
await asyncio.sleep(RETRY_BASE_DELAY * 2**attempt)
|
||||
continue
|
||||
if resp.status_code >= 500:
|
||||
last_exc = httpx.HTTPStatusError(
|
||||
f"{resp.status_code} from partner", request=resp.request, response=resp
|
||||
)
|
||||
await asyncio.sleep(RETRY_BASE_DELAY * 2**attempt)
|
||||
continue
|
||||
resp.raise_for_status() # 4xx: not retried, surfaced (§B.0)
|
||||
return resp.json()
|
||||
raise last_exc if last_exc else RuntimeError("partner request failed")
|
||||
77
gogo/scheduling/slots.py
Normal file
77
gogo/scheduling/slots.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Slot generation for Google Calendar tenants (§8.1).
|
||||
|
||||
working hours − busy blocks, quantized to service duration (+ optional buffer),
|
||||
respecting min_notice_hours and max_days_ahead. Partner tenants never hit this —
|
||||
their software returns ready slots.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from gogo.domain import Slot
|
||||
from gogo.hours import day_intervals, iter_days
|
||||
|
||||
QUANTIZE_MIN = 15 # slot starts snap to :00/:15/:30/:45
|
||||
|
||||
|
||||
def subtract_busy(
|
||||
interval: tuple[datetime, datetime], busy: list[tuple[datetime, datetime]]
|
||||
) -> list[tuple[datetime, datetime]]:
|
||||
"""Subtract busy blocks from one open interval → list of free intervals."""
|
||||
free = [interval]
|
||||
for b_start, b_end in sorted(busy):
|
||||
next_free = []
|
||||
for f_start, f_end in free:
|
||||
if b_end <= f_start or b_start >= f_end:
|
||||
next_free.append((f_start, f_end))
|
||||
continue
|
||||
if b_start > f_start:
|
||||
next_free.append((f_start, b_start))
|
||||
if b_end < f_end:
|
||||
next_free.append((b_end, f_end))
|
||||
free = next_free
|
||||
return free
|
||||
|
||||
|
||||
def compute_slots(
|
||||
*,
|
||||
working_hours: dict,
|
||||
busy: list[tuple[datetime, datetime]],
|
||||
duration_min: int,
|
||||
buffer_min: int = 0,
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
tz: ZoneInfo,
|
||||
now: datetime,
|
||||
min_notice_hours: int = 2,
|
||||
max_days_ahead: int = 14,
|
||||
max_slots: int = 20,
|
||||
) -> list[Slot]:
|
||||
"""Generate offerable free slots, earliest first."""
|
||||
earliest_start = now + timedelta(hours=min_notice_hours)
|
||||
horizon = (now + timedelta(days=max_days_ahead)).date()
|
||||
date_to = min(date_to, horizon)
|
||||
total_min = duration_min + buffer_min
|
||||
step = timedelta(minutes=total_min) # spec §8.1: quantized to service duration (+ buffer)
|
||||
|
||||
slots: list[Slot] = []
|
||||
for day in iter_days(date_from, date_to):
|
||||
for open_start, open_end in day_intervals(working_hours, day, tz):
|
||||
for f_start, f_end in subtract_busy((open_start, open_end), busy):
|
||||
cursor = _quantize_up(max(f_start, earliest_start.astimezone(tz)))
|
||||
while cursor + timedelta(minutes=total_min) <= f_end:
|
||||
slots.append(Slot(start=cursor, end=cursor + timedelta(minutes=duration_min)))
|
||||
if len(slots) >= max_slots:
|
||||
return slots
|
||||
cursor += step
|
||||
return slots
|
||||
|
||||
|
||||
def _quantize_up(dt: datetime) -> datetime:
|
||||
dt = dt.replace(second=0, microsecond=0)
|
||||
rem = dt.minute % QUANTIZE_MIN
|
||||
if rem:
|
||||
dt += timedelta(minutes=QUANTIZE_MIN - rem)
|
||||
return dt
|
||||
1
gogo/sms/__init__.py
Normal file
1
gogo/sms/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from gogo.sms.base import SmsProvider, get_sms_provider, send_sms # noqa: F401
|
||||
72
gogo/sms/base.py
Normal file
72
gogo/sms/base.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""SmsProvider interface. Console impl for dev/tests; GSM gateway impl in gateway.py (M5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Protocol
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.models import SmsLog
|
||||
|
||||
log = logging.getLogger("gogo.sms")
|
||||
|
||||
|
||||
class SmsProvider(Protocol):
|
||||
async def send(self, tenant_id: uuid.UUID, to_msisdn: str, body: str) -> None:
|
||||
"""Send one SMS from the tenant's assigned SIM. Raises on failure."""
|
||||
...
|
||||
|
||||
|
||||
class ConsoleSmsProvider:
|
||||
"""Logs SMS instead of sending; keeps them in memory for tests."""
|
||||
|
||||
sent: list[tuple[str, str]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent = []
|
||||
|
||||
async def send(self, tenant_id: uuid.UUID, to_msisdn: str, body: str) -> None:
|
||||
self.sent.append((to_msisdn, body))
|
||||
log.info("SMS tenant=%s to=%s: %s", tenant_id, to_msisdn, body)
|
||||
|
||||
|
||||
_provider: SmsProvider | None = None
|
||||
|
||||
|
||||
def get_sms_provider() -> SmsProvider:
|
||||
global _provider
|
||||
if _provider is None:
|
||||
if get_settings().sms_provider == "gsm_gateway":
|
||||
from gogo.sms.gateway import GsmGatewaySmsProvider
|
||||
|
||||
_provider = GsmGatewaySmsProvider()
|
||||
else:
|
||||
_provider = ConsoleSmsProvider()
|
||||
return _provider
|
||||
|
||||
|
||||
def set_sms_provider(p: SmsProvider | None) -> None:
|
||||
"""Test hook."""
|
||||
global _provider
|
||||
_provider = p
|
||||
|
||||
|
||||
async def send_sms(
|
||||
session: AsyncSession, tenant_id: uuid.UUID, to_msisdn: str, body: str, kind: str
|
||||
) -> bool:
|
||||
"""Send + log. Returns success. Never raises (SMS failure must not break the flow)."""
|
||||
row = SmsLog(tenant_id=tenant_id, to_msisdn=to_msisdn, body=body, kind=kind)
|
||||
try:
|
||||
await get_sms_provider().send(tenant_id, to_msisdn, body)
|
||||
row.status = "sent"
|
||||
ok = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("SMS send failed tenant=%s to=%s", tenant_id, to_msisdn)
|
||||
row.status = "failed"
|
||||
row.error = str(e)
|
||||
ok = False
|
||||
session.add(row)
|
||||
return ok
|
||||
37
gogo/sms/templates.py
Normal file
37
gogo/sms/templates.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Per-tenant SMS templates with safe Bosnian defaults (§5.5).
|
||||
|
||||
Tenant overrides live in tenants.sms_templates (only overridden keys stored).
|
||||
Placeholders are .format()-style; unknown placeholders are left intact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from gogo.models import Tenant
|
||||
|
||||
DEFAULT_TEMPLATES: dict[str, str] = {
|
||||
"missed_call": (
|
||||
"Poštovani, dobili ste {salon}. Možete zakazati i putem poruke ili chata: {link}. "
|
||||
"Nazvaćemo vas ili nas pozovite ponovo."
|
||||
),
|
||||
"request_received": (
|
||||
"Primili smo vaš zahtjev za termin. Javićemo vam potvrdu u najkraćem roku. — {salon}"
|
||||
),
|
||||
"confirmation": "Potvrđen termin: {usluga}, {dan} {datum} u {vrijeme}h — {salon}.",
|
||||
"rejection": "{salon}: nažalost traženi termin nije moguć. Molimo pozovite nas da dogovorimo drugi.",
|
||||
"expiry": (
|
||||
"{salon}: nismo uspjeli potvrditi vaš zahtjev za termin. "
|
||||
"Molimo pozovite nas ponovo da dogovorimo termin."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class _SafeDict(dict):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
|
||||
|
||||
def render_sms(tenant: Tenant, kind: str, **params: str) -> str:
|
||||
templates = {**DEFAULT_TEMPLATES, **(tenant.sms_templates or {})}
|
||||
template = templates[kind]
|
||||
params.setdefault("salon", tenant.name)
|
||||
return template.format_map(_SafeDict(params))
|
||||
Reference in New Issue
Block a user