Эх сурвалжийг харах

api: add unauthenticated /busy ICS feed endpoint

Serves busy calendar events as an iCalendar file with in-memory caching
keyed by a fingerprint of current busy dates, plus ETag/304 support for
downstream HTTP caches.
ZiB 4 сар өмнө
parent
commit
e845b22714

+ 3 - 3
room_cal/api/__init__.py

@@ -6,7 +6,7 @@ from fastapi import FastAPI, Request
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
 from starlette.middleware.base import BaseHTTPMiddleware
-from room_cal.api._routes import services, calc, chat, credentials, prices, reservations, closed, discounts, actions, workflows, health, reload, sms
+from room_cal.api._routes import services, calc, chat, credentials, prices, reservations, closed, discounts, actions, workflows, health, reload, sms, busy
 import os
 from room_cal.api._auth import router as auth_router, get_user, AUTH_DISABLED
 from room_cal._health import run_health_loop
@@ -21,7 +21,7 @@ def _render(path: Path) -> str:
   return path.read_text().replace("const API_BASE = '';", f"const API_BASE = '{_API_BASE}';", 1)
 
 _HTML_PATHS = {'/dev', '/calendar', '/calc'}
-_PUBLIC_PREFIXES = ('/auth/', '/calc', '/health', '/sms/')
+_PUBLIC_PREFIXES = ('/auth/', '/calc', '/health', '/sms/', '/busy')
 _OPEN_PATHS = {'/docs', '/openapi.json', '/redoc'}
 
 
@@ -70,7 +70,7 @@ def create_app() -> FastAPI:
   app.add_middleware(CORSMiddleware, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])
   app.add_middleware(_AuthMiddleware)
   app.include_router(auth_router)
-  for mod in (services, calc, chat, credentials, prices, reservations, closed, discounts, actions, workflows, health, reload, sms):
+  for mod in (services, calc, chat, credentials, prices, reservations, closed, discounts, actions, workflows, health, reload, sms, busy):
     app.include_router(mod.router)
 
   @app.get('/dev', response_class=HTMLResponse, include_in_schema=False)

+ 79 - 0
room_cal/api/_routes/busy.py

@@ -0,0 +1,79 @@
+import hashlib
+from datetime import date, datetime, timedelta, timezone
+
+from fastapi import APIRouter, Request
+from fastapi.responses import Response
+
+from room_cal.busy import BusyCal
+
+router = APIRouter()
+
+_WINDOW_PAST_DAYS = 7
+_WINDOW_FUTURE_DAYS = 365
+
+_cached_ics: bytes | None = None
+_cached_etag: str | None = None
+_cached_fingerprint: str | None = None
+
+
+def _fingerprint(dates: list[date]) -> str:
+  key = ','.join(d.isoformat() for d in sorted(dates))
+  return hashlib.sha256(key.encode()).hexdigest()
+
+
+def _build_ics(busy_dates: list[date]) -> bytes:
+  from icalendar import Calendar, Event, vDate
+
+  cal = Calendar()
+  cal.add('prodid', '-//room_cal//busy//EN')
+  cal.add('version', '2.0')
+  cal.add('calscale', 'GREGORIAN')
+  cal.add('x-wr-calname', 'Busy')
+
+  for d in busy_dates:
+    event = Event()
+    event.add('uid', f'busy-{d.isoformat()}@room_cal')
+    event.add('summary', 'Busy')
+    event.add('dtstart', vDate(d))
+    event.add('dtend', vDate(d + timedelta(days=1)))
+    event.add('transp', 'OPAQUE')
+    cal.add_component(event)
+
+  return cal.to_ical()
+
+
+@router.get('/busy', include_in_schema=False)
+async def busy_ics(request: Request):
+  global _cached_ics, _cached_etag, _cached_fingerprint
+
+  today = date.today()
+  start = today - timedelta(days=_WINDOW_PAST_DAYS)
+  end = today + timedelta(days=_WINDOW_FUTURE_DAYS)
+
+  events = await BusyCal().list(start, end)
+  busy_dates = sorted({e.start.date() for e in events})
+  fp = _fingerprint(busy_dates)
+
+  if fp != _cached_fingerprint:
+    ics = _build_ics(busy_dates)
+    etag = f'"{hashlib.sha256(ics).hexdigest()[:16]}"'
+    _cached_ics = ics
+    _cached_etag = etag
+    _cached_fingerprint = fp
+
+  if request.headers.get('if-none-match') == _cached_etag:
+    return Response(status_code=304, headers={
+      'etag': _cached_etag,
+      'cache-control': 'no-cache',
+    })
+
+  return Response(
+    content=_cached_ics,
+    media_type='text/calendar; charset=utf-8',
+    headers={
+      'content-disposition': 'inline; filename="busy.ics"',
+      'etag': _cached_etag,
+      'cache-control': 'no-cache',
+      'vary': 'Accept-Encoding',
+    },
+  )