212 lines
8.1 KiB
Python
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"]
|