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