- 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>
73 lines
2.3 KiB
Python
73 lines
2.3 KiB
Python
"""SchedulingProvider interface (§8) and per-tenant provider resolution.
|
|
|
|
Adding a third provider = one new module registering itself here. No changes
|
|
to the agent, tools, or proposal engine are needed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from typing import Protocol, runtime_checkable
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
|
|
from gogo.models import ProviderConfig, Tenant
|
|
|
|
|
|
@runtime_checkable
|
|
class SchedulingProvider(Protocol):
|
|
"""Availability lookup + proposal delivery for one tenant."""
|
|
|
|
async def get_services(self) -> list[ServiceInfo] | None:
|
|
"""Optional catalog sync; None = provider has no catalog."""
|
|
...
|
|
|
|
async def get_availability(
|
|
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
|
|
) -> list[Slot]:
|
|
"""Ready-to-offer free slots. The agent picks up to 3."""
|
|
...
|
|
|
|
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
|
|
"""Deliver the proposal to the owner (email / partner push).
|
|
|
|
Outcome is signaled back via webhook/polling (partner) or the owner's
|
|
email/dashboard actions (google_calendar, mock).
|
|
"""
|
|
...
|
|
|
|
|
|
_REGISTRY: dict[str, type] = {}
|
|
|
|
|
|
def register_provider(name: str):
|
|
def deco(cls):
|
|
_REGISTRY[name] = cls
|
|
return cls
|
|
|
|
return deco
|
|
|
|
|
|
async def get_provider(session: AsyncSession, tenant: Tenant) -> SchedulingProvider:
|
|
"""Instantiate the tenant's configured provider."""
|
|
# Imports here to avoid circulars; modules self-register on import.
|
|
from gogo.scheduling import google_calendar, mock, partner_api # noqa: F401
|
|
|
|
cls = _REGISTRY.get(tenant.scheduling_provider)
|
|
if cls is None:
|
|
raise ValueError(f"Unknown scheduling provider: {tenant.scheduling_provider}")
|
|
config = await _load_config(session, tenant)
|
|
return cls(session=session, tenant=tenant, config=config)
|
|
|
|
|
|
async def _load_config(session: AsyncSession, tenant: Tenant) -> dict:
|
|
from sqlalchemy import select
|
|
|
|
row = (
|
|
await session.execute(
|
|
select(ProviderConfig).where(ProviderConfig.tenant_id == tenant.id)
|
|
)
|
|
).scalar_one_or_none()
|
|
return row.config if row else {}
|