- 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>
177 lines
5.8 KiB
Python
177 lines
5.8 KiB
Python
"""Fake partner booking-software API — reference implementation of Appendix B.
|
|
|
|
Used by the test suite to exercise the `partner_api` provider (auth, idempotency
|
|
replay, webhook signatures, polling fallback) AND intended to be handed to
|
|
partner IT teams as a working example of the contract they must implement.
|
|
|
|
Run standalone: uvicorn tests.fake_partner:app --port 9100
|
|
(API key: "test-partner-key"; see PartnerState to preload slots.)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, Header, Query, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
API_KEY = "test-partner-key"
|
|
WEBHOOK_SECRET = "test-webhook-secret"
|
|
|
|
|
|
@dataclass
|
|
class PartnerState:
|
|
"""In-memory 'database' of the partner's booking software."""
|
|
|
|
# service_id -> list of slot dicts {"start", "end", optionally staff}
|
|
slots: dict[str, list[dict]] = field(default_factory=dict)
|
|
# idempotency key -> stored response
|
|
idempotency: dict[str, dict] = field(default_factory=dict)
|
|
# partner_request_id -> request record (incl. "status")
|
|
requests: dict[str, dict] = field(default_factory=dict)
|
|
services: list[dict] = field(default_factory=list)
|
|
seq: int = 0
|
|
# availability latency injection for tests (seconds)
|
|
availability_delay: float = 0.0
|
|
|
|
def reset(self) -> None:
|
|
self.slots.clear()
|
|
self.idempotency.clear()
|
|
self.requests.clear()
|
|
self.services.clear()
|
|
self.seq = 0
|
|
self.availability_delay = 0.0
|
|
|
|
|
|
state = PartnerState()
|
|
app = FastAPI(title="Fake Partner Booking API (Appendix B reference)")
|
|
|
|
|
|
def _err(status: int, code: str, message: str) -> JSONResponse:
|
|
# §B.0 error shape
|
|
return JSONResponse({"error": {"code": code, "message": message}}, status_code=status)
|
|
|
|
|
|
def _check_auth(authorization: str | None) -> JSONResponse | None:
|
|
if authorization != f"Bearer {API_KEY}":
|
|
return _err(401, "unauthorized", "Missing or invalid API key")
|
|
return None
|
|
|
|
|
|
@app.get("/availability")
|
|
async def availability(
|
|
service_id: str = Query(...),
|
|
home_visit: str = Query("false"),
|
|
from_: str = Query(..., alias="from"),
|
|
to: str = Query(...),
|
|
authorization: str | None = Header(default=None),
|
|
):
|
|
"""§B.1 — ready-to-offer free slots; partner does all the slot math."""
|
|
if err := _check_auth(authorization):
|
|
return err
|
|
if state.availability_delay:
|
|
import asyncio
|
|
|
|
await asyncio.sleep(state.availability_delay)
|
|
date_from = datetime.fromisoformat(from_).date() if len(from_) > 10 else from_
|
|
date_to = datetime.fromisoformat(to).date() if len(to) > 10 else to
|
|
slots = [
|
|
s
|
|
for s in state.slots.get(service_id, [])
|
|
if str(date_from) <= s["start"][:10] <= str(date_to)
|
|
]
|
|
return {"service_id": service_id, "slots": slots[:20]}
|
|
|
|
|
|
@app.post("/booking-requests", status_code=201)
|
|
async def create_booking_request(
|
|
request: Request,
|
|
authorization: str | None = Header(default=None),
|
|
idempotency_key: str | None = Header(default=None),
|
|
):
|
|
"""§B.2 — push a booking request; idempotent per Idempotency-Key."""
|
|
if err := _check_auth(authorization):
|
|
return err
|
|
payload = await request.json()
|
|
|
|
key = idempotency_key or payload.get("gogo_request_id", "")
|
|
if key in state.idempotency:
|
|
return state.idempotency[key] # replay MUST return the original result
|
|
|
|
if payload.get("dry_run"):
|
|
return {"partner_request_id": None, "status": "dry_run_ok"}
|
|
|
|
state.seq += 1
|
|
partner_id = f"REQ-{state.seq:04d}"
|
|
state.requests[partner_id] = {**payload, "status": "pending"}
|
|
response = {"partner_request_id": partner_id, "status": "received"}
|
|
state.idempotency[key] = response
|
|
return response
|
|
|
|
|
|
@app.get("/booking-requests/{partner_request_id}")
|
|
async def booking_request_status(
|
|
partner_request_id: str, authorization: str | None = Header(default=None)
|
|
):
|
|
"""§B.3 polling fallback."""
|
|
if err := _check_auth(authorization):
|
|
return err
|
|
rec = state.requests.get(partner_request_id)
|
|
if rec is None:
|
|
return _err(404, "not_found", "Unknown partner_request_id")
|
|
return {"status": rec["status"], "confirmed_slot": rec.get("confirmed_slot")}
|
|
|
|
|
|
@app.get("/services")
|
|
async def services(authorization: str | None = Header(default=None)):
|
|
"""§B.4 optional catalog endpoint."""
|
|
if err := _check_auth(authorization):
|
|
return err
|
|
return {"services": state.services}
|
|
|
|
|
|
# -- helpers for driving the partner side in tests / demos -------------------
|
|
|
|
|
|
def sign_webhook(body: bytes, secret: str = WEBHOOK_SECRET) -> str:
|
|
return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
|
|
|
|
async def staff_resolve(
|
|
gogo_base: httpx.AsyncClient,
|
|
tenant_id: str,
|
|
partner_request_id: str,
|
|
outcome: str,
|
|
confirmed_slot: dict | None = None,
|
|
secret: str = WEBHOOK_SECRET,
|
|
) -> httpx.Response:
|
|
"""Simulate salon staff resolving a request in the partner software:
|
|
updates local state and calls Gogo's outcome webhook (§B.3)."""
|
|
import json as jsonlib
|
|
|
|
rec = state.requests.get(partner_request_id)
|
|
if rec is not None:
|
|
rec["status"] = "resolved" if outcome == "resolved" else outcome
|
|
if confirmed_slot:
|
|
rec["confirmed_slot"] = confirmed_slot
|
|
payload = {
|
|
"gogo_request_id": rec["gogo_request_id"] if rec else None,
|
|
"partner_request_id": partner_request_id,
|
|
"outcome": outcome,
|
|
}
|
|
if confirmed_slot:
|
|
payload["confirmed_slot"] = confirmed_slot
|
|
body = jsonlib.dumps(payload).encode()
|
|
return await gogo_base.post(
|
|
f"/webhooks/partner/{tenant_id}",
|
|
content=body,
|
|
headers={
|
|
"X-Gogo-Signature": sign_webhook(body, secret),
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|