|
@@ -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
|