Files
gogo-telefon/gogo/scheduling/partner_api.py
Senad Uka e855650f09 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>
2026-07-11 09:45:06 +02:00

227 lines
8.8 KiB
Python

"""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")