فهرست منبع

busy: generate non-bookable dates statically; publish to CalDAV or ICS

Removes BusyCal(StoreBacked) in favour of pure functions in room_cal/busy:
- get_dates(): derives busy dates from Closed + Reservation events
- to_ics(): serializes to iCalendar bytes
- fingerprint(): stable hash for cache invalidation
- push_caldav(): syncs to the CalDAV busy calendar directly

push_busy action and /busy API route both thin to call-throughs into the
busy module; ICS and CalDAV outputs now share a single source of truth for
event generation.
ZiB 4 ماه پیش
والد
کامیت
a08d5f3ea5
3فایلهای تغییر یافته به همراه107 افزوده شده و 103 حذف شده
  1. 7 45
      room_cal/actions/push_busy.py
  2. 6 34
      room_cal/api/_routes/busy.py
  3. 94 24
      room_cal/busy/__init__.py

+ 7 - 45
room_cal/actions/push_busy.py

@@ -1,54 +1,16 @@
-import asyncio
-from datetime import date, timedelta
+from datetime import date
 
 
-from room_cal.busy import BusyCal
-from room_cal.closed import Closed
-from room_cal.reservations import Reservation
-from room_cal._utils import FetchResult, iter_dates
+from room_cal.busy import get_dates, push_caldav
+from room_cal._utils import FetchResult
 
 
 ACTION_SPEC = ('push-busy', [])
 ACTION_SPEC = ('push-busy', [])
 
 
 
 
 async def push_busy(start_date: date, end_date: date) -> FetchResult[dict]:
 async def push_busy(start_date: date, end_date: date) -> FetchResult[dict]:
-  """Sync 'Busy' events to the export calendar from local Room calendar data.
+  """Sync busy dates to the CalDAV busy calendar.
 
 
   A day is busy when it has a Closed event or falls within a guest reservation.
   A day is busy when it has a Closed event or falls within a guest reservation.
-  Creates 'Busy' all-day events for busy dates; removes them for days no longer busy.
-  Returns FetchResult with {'busy': [date, ...]} and a changed flag.
   """
   """
-  closed_cal = Closed()
-  res_cal = Reservation()
-  busy_cal = BusyCal()
-
-  closed_events, res_events = await asyncio.gather(
-    closed_cal.list(start_date, end_date),
-    res_cal.list(start_date, end_date),
-  )
-
-  busy_dates: set[date] = set()
-
-  for e in closed_events:
-    busy_dates.add(e.start.date())
-
-  for e in res_events:
-    checkin = e.start.date()
-    last_night = e.end.date() - timedelta(days=1)
-    for d in iter_dates(checkin, last_night):
-      if start_date <= d <= end_date:
-        busy_dates.add(d)
-
-  existing = await busy_cal.list(start_date, end_date)
-  existing_by_date = {e.start.date(): e for e in existing}
-  existing_dates = set(existing_by_date)
-
-  to_add = sorted(busy_dates - existing_dates)
-  to_remove = sorted(existing_dates - busy_dates)
-
-  for d in to_remove:
-    await busy_cal.delete(existing_by_date[d])
-
-  for d in to_add:
-    await busy_cal.set(d, existing=None)
-
-  changed = bool(to_add or to_remove)
-  return FetchResult(data={'busy': sorted(busy_dates)}, changed=changed)
+  busy_dates = await get_dates(start_date, end_date)
+  added, removed = await push_caldav(busy_dates, start_date, end_date)
+  return FetchResult(data={'busy': busy_dates}, changed=bool(added or removed))

+ 6 - 34
room_cal/api/_routes/busy.py

@@ -1,10 +1,10 @@
 import hashlib
 import hashlib
-from datetime import date, datetime, timedelta, timezone
+from datetime import date, timedelta
 
 
 from fastapi import APIRouter, Request
 from fastapi import APIRouter, Request
 from fastapi.responses import Response
 from fastapi.responses import Response
 
 
-from room_cal.busy import BusyCal
+from room_cal.busy import get_dates, to_ics, fingerprint
 
 
 router = APIRouter()
 router = APIRouter()
 
 
@@ -16,32 +16,6 @@ _cached_etag: str | None = None
 _cached_fingerprint: 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)
 @router.get('/busy', include_in_schema=False)
 async def busy_ics(request: Request):
 async def busy_ics(request: Request):
   global _cached_ics, _cached_etag, _cached_fingerprint
   global _cached_ics, _cached_etag, _cached_fingerprint
@@ -50,15 +24,13 @@ async def busy_ics(request: Request):
   start = today - timedelta(days=_WINDOW_PAST_DAYS)
   start = today - timedelta(days=_WINDOW_PAST_DAYS)
   end = today + timedelta(days=_WINDOW_FUTURE_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)
+  busy_dates = await get_dates(start, end)
+  fp = fingerprint(busy_dates)
 
 
   if fp != _cached_fingerprint:
   if fp != _cached_fingerprint:
-    ics = _build_ics(busy_dates)
-    etag = f'"{hashlib.sha256(ics).hexdigest()[:16]}"'
+    ics = to_ics(busy_dates)
     _cached_ics = ics
     _cached_ics = ics
-    _cached_etag = etag
+    _cached_etag = f'"{hashlib.sha256(ics).hexdigest()[:16]}"'
     _cached_fingerprint = fp
     _cached_fingerprint = fp
 
 
   if request.headers.get('if-none-match') == _cached_etag:
   if request.headers.get('if-none-match') == _cached_etag:

+ 94 - 24
room_cal/busy/__init__.py

@@ -1,36 +1,106 @@
-from datetime import date
-from room_cal.store import StoreBacked
-from room_cal._utils import get_config
+import asyncio
+import hashlib
+from datetime import date, datetime, timedelta
+
+from room_cal.closed import Closed
+from room_cal.reservations import Reservation
+from room_cal._utils import get_config, iter_dates
 
 
-DEFAULT_CALENDAR_NAME = 'Busy'
 _BUSY_TITLE = 'Busy'
 _BUSY_TITLE = 'Busy'
-_UNSET = object()
+DEFAULT_CALENDAR_NAME = 'Busy'
 
 
 
 
 def _get_calendar_name() -> str:
 def _get_calendar_name() -> str:
   return get_config().global_.busy_calendar_name or DEFAULT_CALENDAR_NAME
   return get_config().global_.busy_calendar_name or DEFAULT_CALENDAR_NAME
 
 
 
 
-class BusyCal(StoreBacked):
-  def __init__(self, calendar_name: str | None = None, *, backend=None):
-    name = calendar_name or _get_calendar_name()
-    super().__init__(name, backend=backend)
+async def get_dates(start_date: date, end_date: date) -> list[date]:
+  """Compute non-bookable dates from Closed and Reservation events."""
+  closed_cal = Closed()
+  res_cal = Reservation()
+
+  closed_events, res_events = await asyncio.gather(
+    closed_cal.list(start_date, end_date),
+    res_cal.list(start_date, end_date),
+  )
+
+  busy: set[date] = set()
+
+  for e in closed_events:
+    busy.add(e.start.date())
+
+  for e in res_events:
+    checkin = e.start.date()
+    last_night = e.end.date() - timedelta(days=1)
+    for d in iter_dates(checkin, last_night):
+      if start_date <= d <= end_date:
+        busy.add(d)
+
+  return sorted(busy)
+
+
+def to_ics(busy_dates: list[date]) -> bytes:
+  """Serialize busy dates to iCalendar 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_TITLE)
+    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()
+
+
+def fingerprint(busy_dates: list[date]) -> str:
+  """Stable hash of a sorted busy date list, for cache invalidation."""
+  key = ','.join(d.isoformat() for d in busy_dates)
+  return hashlib.sha256(key.encode()).hexdigest()
+
+
+async def push_caldav(busy_dates: list[date], start_date: date, end_date: date) -> tuple[list[date], list[date]]:
+  """Sync busy_dates to the CalDAV busy calendar over [start_date, end_date].
+
+  Returns (added, removed) date lists.
+  """
+  from room_cal.store._caldav import Calendar
+
+  cfg = get_config()
+  cal_cfg = cfg.store.caldav
+  cal = Calendar(
+    _get_calendar_name(),
+    url=cal_cfg.url,
+    username=cal_cfg.username,
+    password=cal_cfg.password,
+  )
+
+  start = datetime(start_date.year, start_date.month, start_date.day)
+  end = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
+  existing = await cal.fetch_events(start, end)
 
 
-  async def list(self, start_date: date, end_date: date) -> list:
-    events = await self._fetch_date_range(start_date, end_date)
-    return [e for e in events if e.all_day and e.title == _BUSY_TITLE]
+  existing_by_date = {
+    e['start'].date(): e
+    for e in existing
+    if e['all_day'] and e['title'] == _BUSY_TITLE
+  }
+  target = set(busy_dates)
+  extant = set(existing_by_date)
 
 
-  async def set(self, target_date: date, *, existing=_UNSET) -> None:
-    if existing is _UNSET:
-      existing = await self._find(target_date)
-    if existing:
-      return
-    await self._create_all_day(target_date, _BUSY_TITLE)
+  to_add = sorted(target - extant)
+  to_remove = sorted(extant - target)
 
 
-  async def delete(self, event) -> None:
-    await self._backend.delete(event.uid)
+  for d in to_remove:
+    await cal.delete_event(existing_by_date[d])
+  for d in to_add:
+    await cal.create_event(d, _BUSY_TITLE)
 
 
-  async def _find(self, target_date: date):
-    events = await self._fetch_date_range(target_date, target_date)
-    matches = [e for e in events if e.all_day and e.title == _BUSY_TITLE]
-    return matches[0] if matches else None
+  return to_add, to_remove