Procházet zdrojové kódy

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 před 4 měsíci
rodič
revize
e845b22714
2 změnil soubory, kde provedl 82 přidání a 3 odebrání
  1. 3 3
      room_cal/api/__init__.py
  2. 79 0
      room_cal/api/_routes/busy.py

+ 3 - 3
room_cal/api/__init__.py

@@ -6,7 +6,7 @@ from fastapi import FastAPI, Request
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
 from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
 from starlette.middleware.base import BaseHTTPMiddleware
 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
 import os
 from room_cal.api._auth import router as auth_router, get_user, AUTH_DISABLED
 from room_cal.api._auth import router as auth_router, get_user, AUTH_DISABLED
 from room_cal._health import run_health_loop
 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)
   return path.read_text().replace("const API_BASE = '';", f"const API_BASE = '{_API_BASE}';", 1)
 
 
 _HTML_PATHS = {'/dev', '/calendar', '/calc'}
 _HTML_PATHS = {'/dev', '/calendar', '/calc'}
-_PUBLIC_PREFIXES = ('/auth/', '/calc', '/health', '/sms/')
+_PUBLIC_PREFIXES = ('/auth/', '/calc', '/health', '/sms/', '/busy')
 _OPEN_PATHS = {'/docs', '/openapi.json', '/redoc'}
 _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(CORSMiddleware, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])
   app.add_middleware(_AuthMiddleware)
   app.add_middleware(_AuthMiddleware)
   app.include_router(auth_router)
   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.include_router(mod.router)
 
 
   @app.get('/dev', response_class=HTMLResponse, include_in_schema=False)
   @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',
+    },
+  )