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