227 lines
8.8 KiB
Python
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")
|