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:
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"]
|
||||
Reference in New Issue
Block a user