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