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:
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")
|
||||
Reference in New Issue
Block a user