Files
gogo-telefon/gogo/scheduling/google_calendar.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

212 lines
8.1 KiB
Python

"""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"]