Files
gogo-telefon/gogo/scheduling/google_calendar.py

212 lines
8.1 KiB
Python
Raw Normal View History

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