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>
This commit is contained in:
1
gogo/scheduling/__init__.py
Normal file
1
gogo/scheduling/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from gogo.scheduling.base import SchedulingProvider, get_provider # noqa: F401
|
||||
72
gogo/scheduling/base.py
Normal file
72
gogo/scheduling/base.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""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 {}
|
||||
211
gogo/scheduling/google_calendar.py
Normal file
211
gogo/scheduling/google_calendar.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Google Calendar provider (§8.1) — free/busy READ ONLY.
|
||||
|
||||
Gogo never requests calendar write access and never creates/modifies events.
|
||||
Availability = tenant working hours minus busy blocks from the mapped calendar,
|
||||
quantized to service duration. Delivery = owner email with action links + .ics.
|
||||
|
||||
Uses raw HTTP (httpx) against the Calendar v3 freeBusy endpoint + OAuth token
|
||||
refresh — no heavy Google SDK, easy to point at a fake server in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.crypto import decrypt, encrypt
|
||||
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
|
||||
from gogo.hours import DEFAULT_WORKING_HOURS
|
||||
from gogo.models import (
|
||||
BookingRequest,
|
||||
CalendarConnection,
|
||||
CalendarMapping,
|
||||
Service,
|
||||
Tenant,
|
||||
utcnow,
|
||||
)
|
||||
from gogo.scheduling.base import register_provider
|
||||
from gogo.scheduling.slots import compute_slots
|
||||
|
||||
log = logging.getLogger("gogo.gcal")
|
||||
|
||||
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
GOOGLE_FREEBUSY_URL = "https://www.googleapis.com/calendar/v3/freeBusy"
|
||||
|
||||
|
||||
@register_provider("google_calendar")
|
||||
class GoogleCalendarProvider:
|
||||
# test hooks: override endpoints / freeze time
|
||||
token_url: str = GOOGLE_TOKEN_URL
|
||||
freebusy_url: str = GOOGLE_FREEBUSY_URL
|
||||
now_override: datetime | None = None
|
||||
|
||||
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
|
||||
self.session = session
|
||||
self.tenant = tenant
|
||||
self.config = config
|
||||
|
||||
async def get_services(self) -> list[ServiceInfo] | None:
|
||||
return None # services are maintained in the Gogo dashboard
|
||||
|
||||
async def get_availability(
|
||||
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
|
||||
) -> list[Slot]:
|
||||
service = (
|
||||
await self.session.execute(
|
||||
select(Service).where(Service.id == uuid.UUID(service_id))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
duration = service.duration_min if service else 30
|
||||
buffer_min = service.buffer_min if service else 0
|
||||
tz = ZoneInfo(self.tenant.timezone)
|
||||
|
||||
calendar_id = await self._calendar_for_service(service)
|
||||
busy = await self.fetch_busy(calendar_id, date_from, date_to, tz)
|
||||
|
||||
return compute_slots(
|
||||
working_hours=self.tenant.working_hours or DEFAULT_WORKING_HOURS,
|
||||
busy=busy,
|
||||
duration_min=duration,
|
||||
buffer_min=buffer_min,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
tz=tz,
|
||||
now=self.now_override or utcnow(),
|
||||
min_notice_hours=self.tenant.min_notice_hours,
|
||||
max_days_ahead=self.tenant.max_days_ahead,
|
||||
)
|
||||
|
||||
async def is_slot_free(self, service_id: str | None, slot: Slot) -> bool:
|
||||
"""Re-check free/busy before 'confirm + SMS' (§9.2)."""
|
||||
service = None
|
||||
if service_id:
|
||||
service = (
|
||||
await self.session.execute(
|
||||
select(Service).where(Service.id == uuid.UUID(str(service_id)))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
calendar_id = await self._calendar_for_service(service)
|
||||
tz = ZoneInfo(self.tenant.timezone)
|
||||
busy = await self.fetch_busy(
|
||||
calendar_id, slot.start.date(), slot.end.date(), tz
|
||||
)
|
||||
return not any(b_start < slot.end and b_end > slot.start for b_start, b_end in busy)
|
||||
|
||||
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
|
||||
if booking_request.dry_run:
|
||||
return DeliveryResult(ok=True, detail="dry-run ok (google_calendar)")
|
||||
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()
|
||||
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, detail="email sent")
|
||||
|
||||
# -- internals ---------------------------------------------------------
|
||||
|
||||
async def _calendar_for_service(self, service: Service | None) -> str:
|
||||
"""Mapped calendar for the service, tenant default mapping, or 'primary'."""
|
||||
if service is not None:
|
||||
row = (
|
||||
await self.session.execute(
|
||||
select(CalendarMapping).where(
|
||||
CalendarMapping.tenant_id == self.tenant.id,
|
||||
CalendarMapping.service_id == service.id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
return row.google_calendar_id
|
||||
default = (
|
||||
await self.session.execute(
|
||||
select(CalendarMapping).where(
|
||||
CalendarMapping.tenant_id == self.tenant.id,
|
||||
CalendarMapping.service_id.is_(None),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return default.google_calendar_id if default else "primary"
|
||||
|
||||
async def fetch_busy(
|
||||
self, calendar_id: str, date_from: date, date_to: date, tz: ZoneInfo
|
||||
) -> list[tuple[datetime, datetime]]:
|
||||
conn = (
|
||||
await self.session.execute(
|
||||
select(CalendarConnection).where(CalendarConnection.tenant_id == self.tenant.id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if conn is None:
|
||||
log.warning("tenant %s: google_calendar provider without connection", self.tenant.id)
|
||||
return []
|
||||
|
||||
access_token = await self._fresh_access_token(conn)
|
||||
time_min = datetime.combine(date_from, datetime.min.time(), tzinfo=tz)
|
||||
time_max = datetime.combine(date_to + timedelta(days=1), datetime.min.time(), tzinfo=tz)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
self.freebusy_url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
json={
|
||||
"timeMin": time_min.isoformat(),
|
||||
"timeMax": time_max.isoformat(),
|
||||
"items": [{"id": calendar_id}],
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
busy = []
|
||||
for cal in data.get("calendars", {}).values():
|
||||
for block in cal.get("busy", []):
|
||||
busy.append(
|
||||
(
|
||||
datetime.fromisoformat(block["start"]),
|
||||
datetime.fromisoformat(block["end"]),
|
||||
)
|
||||
)
|
||||
return busy
|
||||
|
||||
async def _fresh_access_token(self, conn: CalendarConnection) -> str:
|
||||
token_data = json.loads(decrypt(conn.token_data_encrypted))
|
||||
expiry = token_data.get("expiry")
|
||||
if expiry and datetime.fromisoformat(expiry) > datetime.now(UTC) + timedelta(minutes=2):
|
||||
return token_data["access_token"]
|
||||
# refresh
|
||||
from gogo.config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
self.token_url,
|
||||
data={
|
||||
"client_id": s.google_client_id,
|
||||
"client_secret": s.google_client_secret,
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"grant_type": "refresh_token",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
fresh = resp.json()
|
||||
token_data["access_token"] = fresh["access_token"]
|
||||
token_data["expiry"] = (
|
||||
datetime.now(UTC) + timedelta(seconds=fresh.get("expires_in", 3600))
|
||||
).isoformat()
|
||||
conn.token_data_encrypted = encrypt(json.dumps(token_data))
|
||||
return token_data["access_token"]
|
||||
81
gogo/scheduling/mock.py
Normal file
81
gogo/scheduling/mock.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Mock scheduling provider — in-memory, for tests and demos (§16 M1).
|
||||
|
||||
Availability: generated from tenant working hours with configurable busy blocks
|
||||
(class-level, settable by tests/demo seeder). Delivery: owner email, same as
|
||||
google_calendar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gogo.domain import BookingRequestData, DeliveryResult, ServiceInfo, Slot
|
||||
from gogo.hours import DEFAULT_WORKING_HOURS
|
||||
from gogo.models import BookingRequest, Service, Tenant, utcnow
|
||||
from gogo.scheduling.base import register_provider
|
||||
from gogo.scheduling.slots import compute_slots
|
||||
|
||||
|
||||
@register_provider("mock")
|
||||
class MockProvider:
|
||||
# tenant_id(str) -> list[(start, end)] busy blocks; tests populate this
|
||||
busy_blocks: dict[str, list[tuple[datetime, datetime]]] = {}
|
||||
# test hook: freeze "now"
|
||||
now_override: datetime | None = None
|
||||
|
||||
def __init__(self, session: AsyncSession, tenant: Tenant, config: dict):
|
||||
self.session = session
|
||||
self.tenant = tenant
|
||||
self.config = config
|
||||
|
||||
async def get_services(self) -> list[ServiceInfo] | None:
|
||||
return None # no external catalog
|
||||
|
||||
async def get_availability(
|
||||
self, service_id: str, date_from: date, date_to: date, home_visit: bool = False
|
||||
) -> list[Slot]:
|
||||
service = (
|
||||
await self.session.execute(
|
||||
select(Service).where(Service.id == uuid.UUID(service_id))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
duration = service.duration_min if service else 30
|
||||
buffer_min = service.buffer_min if service else 0
|
||||
tz = ZoneInfo(self.tenant.timezone)
|
||||
return compute_slots(
|
||||
working_hours=self.tenant.working_hours or DEFAULT_WORKING_HOURS,
|
||||
busy=self.busy_blocks.get(str(self.tenant.id), []),
|
||||
duration_min=duration,
|
||||
buffer_min=buffer_min,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
tz=tz,
|
||||
now=self.now_override or utcnow(),
|
||||
min_notice_hours=self.tenant.min_notice_hours,
|
||||
max_days_ahead=self.tenant.max_days_ahead,
|
||||
)
|
||||
|
||||
async def deliver_request(self, booking_request: BookingRequestData) -> DeliveryResult:
|
||||
if booking_request.dry_run:
|
||||
return DeliveryResult(ok=True, detail="dry-run ok (mock)")
|
||||
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()
|
||||
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, detail="email sent (mock)")
|
||||
226
gogo/scheduling/partner_api.py
Normal file
226
gogo/scheduling/partner_api.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""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")
|
||||
77
gogo/scheduling/slots.py
Normal file
77
gogo/scheduling/slots.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Slot generation for Google Calendar tenants (§8.1).
|
||||
|
||||
working hours − busy blocks, quantized to service duration (+ optional buffer),
|
||||
respecting min_notice_hours and max_days_ahead. Partner tenants never hit this —
|
||||
their software returns ready slots.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from gogo.domain import Slot
|
||||
from gogo.hours import day_intervals, iter_days
|
||||
|
||||
QUANTIZE_MIN = 15 # slot starts snap to :00/:15/:30/:45
|
||||
|
||||
|
||||
def subtract_busy(
|
||||
interval: tuple[datetime, datetime], busy: list[tuple[datetime, datetime]]
|
||||
) -> list[tuple[datetime, datetime]]:
|
||||
"""Subtract busy blocks from one open interval → list of free intervals."""
|
||||
free = [interval]
|
||||
for b_start, b_end in sorted(busy):
|
||||
next_free = []
|
||||
for f_start, f_end in free:
|
||||
if b_end <= f_start or b_start >= f_end:
|
||||
next_free.append((f_start, f_end))
|
||||
continue
|
||||
if b_start > f_start:
|
||||
next_free.append((f_start, b_start))
|
||||
if b_end < f_end:
|
||||
next_free.append((b_end, f_end))
|
||||
free = next_free
|
||||
return free
|
||||
|
||||
|
||||
def compute_slots(
|
||||
*,
|
||||
working_hours: dict,
|
||||
busy: list[tuple[datetime, datetime]],
|
||||
duration_min: int,
|
||||
buffer_min: int = 0,
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
tz: ZoneInfo,
|
||||
now: datetime,
|
||||
min_notice_hours: int = 2,
|
||||
max_days_ahead: int = 14,
|
||||
max_slots: int = 20,
|
||||
) -> list[Slot]:
|
||||
"""Generate offerable free slots, earliest first."""
|
||||
earliest_start = now + timedelta(hours=min_notice_hours)
|
||||
horizon = (now + timedelta(days=max_days_ahead)).date()
|
||||
date_to = min(date_to, horizon)
|
||||
total_min = duration_min + buffer_min
|
||||
step = timedelta(minutes=total_min) # spec §8.1: quantized to service duration (+ buffer)
|
||||
|
||||
slots: list[Slot] = []
|
||||
for day in iter_days(date_from, date_to):
|
||||
for open_start, open_end in day_intervals(working_hours, day, tz):
|
||||
for f_start, f_end in subtract_busy((open_start, open_end), busy):
|
||||
cursor = _quantize_up(max(f_start, earliest_start.astimezone(tz)))
|
||||
while cursor + timedelta(minutes=total_min) <= f_end:
|
||||
slots.append(Slot(start=cursor, end=cursor + timedelta(minutes=duration_min)))
|
||||
if len(slots) >= max_slots:
|
||||
return slots
|
||||
cursor += step
|
||||
return slots
|
||||
|
||||
|
||||
def _quantize_up(dt: datetime) -> datetime:
|
||||
dt = dt.replace(second=0, microsecond=0)
|
||||
rem = dt.minute % QUANTIZE_MIN
|
||||
if rem:
|
||||
dt += timedelta(minutes=QUANTIZE_MIN - rem)
|
||||
return dt
|
||||
Reference in New Issue
Block a user