Files
gogo-telefon/gogo/scheduling/base.py
Senad Uka e855650f09 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>
2026-07-11 09:45:06 +02:00

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 {}