Explorar o código

Extract Calendar to room_cal/caldav.py; decouple UserCal from StoreBacked

Calendar and its CalDAV primitives move to room_cal/caldav.py so any module
can use the caching/dedup layer with its own credentials without touching Store.
store/_caldav.py is now just CalDAVBackend + the store-side calendar registry.
UserCal drops the StoreBacked inheritance — it holds a CalDAVBackend directly
and owns its own fetch/create helpers. Existing imports from room_cal.store
are unaffected via re-exports.
ZiB hai 4 meses
pai
achega
498b52705d
Modificáronse 3 ficheiros con 246 adicións e 215 borrados
  1. 199 0
      room_cal/caldav.py
  2. 20 210
      room_cal/store/_caldav.py
  3. 27 5
      room_cal/user_cal/__init__.py

+ 199 - 0
room_cal/caldav.py

@@ -0,0 +1,199 @@
+import asyncio
+import logging
+import uuid
+from caldav import aio
+from datetime import date, datetime, timedelta, timezone
+from icalendar import Calendar as _iCal, Event as _iEvent
+
+_log = logging.getLogger(__name__)
+
+DEFAULT_CALENDAR_NAME = 'Room'
+
+_X_DATA_PROP = 'X-ROOMCAL-DATA'
+
+
+def _naive(dt) -> datetime:
+  if isinstance(dt, datetime):
+    return dt.replace(tzinfo=None)
+  return datetime(dt.year, dt.month, dt.day)
+
+
+def _parse_event(caldav_event) -> dict:
+  cal = caldav_event.icalendar_instance
+  vevent = next(c for c in cal.walk() if c.name == 'VEVENT')
+
+  dtstart = vevent.get('DTSTART').dt
+  dtend_prop = vevent.get('DTEND') or vevent.get('DUE')
+  dtend = dtend_prop.dt if dtend_prop else dtstart
+
+  all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime)
+
+  recurrence_id = vevent.get('RECURRENCE-ID')
+
+  x_data_prop = vevent.get(_X_DATA_PROP)
+  x_data = str(x_data_prop) if x_data_prop is not None else None
+
+  return {
+    'start':        _naive(dtstart),
+    'end':          _naive(dtend),
+    'all_day':      all_day,
+    'title':        str(vevent.get('SUMMARY', '')),
+    'description':  str(vevent.get('DESCRIPTION', '')),
+    'x_data':       x_data,
+    'uid':          str(vevent.get('UID', '')),
+    'url':          str(caldav_event.url),
+    'etag':         getattr(caldav_event, 'etag', None),
+    'recurrence_id': _naive(recurrence_id.dt) if recurrence_id else None,
+    '_vevent':      vevent,
+    '_raw_event':   caldav_event,
+  }
+
+
+def _build_ical(dtstart, dtend, title: str, description: str = '', uid: str = None, x_data: str = None) -> str:
+  cal = _iCal()
+  cal.add('prodid', '-//room_cal//EN')
+  cal.add('version', '2.0')
+
+  event = _iEvent()
+  event.add('uid', uid or str(uuid.uuid4()))
+  event.add('dtstamp', datetime.now(timezone.utc))
+  event.add('dtstart', dtstart)
+  event.add('dtend', dtend)
+  event.add('summary', title)
+  if description:
+    event.add('description', description)
+  if x_data is not None:
+    event.add(_X_DATA_PROP, x_data)
+
+  cal.add_component(event)
+  return cal.to_ical().decode('utf-8')
+
+
+class Calendar:
+  """CalDAV client for a single named calendar with request-dedup and response caching."""
+
+  def __init__(self, calendar_name=DEFAULT_CALENDAR_NAME, *, url='', username='', password=''):
+    self._calendar_name = calendar_name
+    self._connection = {'url': url, 'username': username, 'password': password}
+    self._cached_url = None
+    self._event_cache: dict[tuple, list] = {}
+    self._inflight: dict[tuple, asyncio.Task] = {}
+
+  @property
+  def calendar_name(self) -> str:
+    return self._calendar_name
+
+  def _resolved_connection(self) -> dict:
+    if not self._connection.get('url'):
+      raise ValueError('CALDAV_URL must be set via store.caldav.url in config.json')
+    return self._connection
+
+  async def _client(self):
+    return await aio.get_async_davclient(**self._resolved_connection())
+
+  async def _get_calendar(self, client):
+    if self._cached_url is not None:
+      return client.calendar(url=self._cached_url)
+    from caldav.lib.error import AuthorizationError
+    try:
+      principal = await client.principal()
+    except AuthorizationError:
+      conn = self._resolved_connection()
+      raise AuthorizationError(
+        f"CalDAV auth failed for {conn['url']} (username={conn.get('username', '(none)')})"
+      ) from None
+    calendars = await principal.get_calendars()
+    for cal in calendars:
+      if await cal.get_display_name() == self._calendar_name:
+        self._cached_url = str(cal.url)
+        return cal
+    raise ValueError(f"Calendar '{self._calendar_name}' not found")
+
+  async def fetch_events(self, start: datetime, end: datetime) -> list[dict]:
+    async with await self._client() as client:
+      cal = await self._get_calendar(client)
+      raw_events = await cal.search(
+        start=start,
+        end=end,
+        event=True,
+        expand=True,
+      )
+    events = [_parse_event(e) for e in raw_events]
+    events.sort(key=lambda e: e['start'])
+    return events
+
+  async def fetch_date_range(self, start_date: date, end_date: date) -> list[dict]:
+    key = (start_date, end_date)
+    if key in self._event_cache:
+      return self._event_cache[key]
+    if key in self._inflight:
+      return await self._inflight[key]
+    task = asyncio.create_task(self._fetch_date_range_uncached(start_date, end_date))
+    self._inflight[key] = task
+    try:
+      result = await task
+    finally:
+      self._inflight.pop(key, None)
+    self._event_cache[key] = result
+    return result
+
+  async def _fetch_date_range_uncached(self, start_date: date, end_date: date) -> list[dict]:
+    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)
+    return await self.fetch_events(start, end)
+
+  async def create_timed_event(self, dtstart: datetime, dtend: datetime, title: str, description: str = '', x_data: str = None) -> dict:
+    ical_str = _build_ical(dtstart, dtend, title, description, x_data=x_data)
+    async with await self._client() as client:
+      cal = await self._get_calendar(client)
+      saved = await cal.save_event(ical_str)
+    self._event_cache.clear()
+    _log.info('create date=%s title=%r', dtstart.date(), title)
+    return _parse_event(saved)
+
+  async def create_event(self, target_date: date, title: str, description: str = '', x_data: str = None, end_date: date = None) -> dict:
+    if isinstance(target_date, datetime):
+      target_date = target_date.date()
+    dtend = end_date if end_date is not None else target_date + timedelta(days=1)
+    ical_str = _build_ical(target_date, dtend, title, description, x_data=x_data)
+    async with await self._client() as client:
+      cal = await self._get_calendar(client)
+      saved = await cal.save_event(ical_str)
+    self._event_cache.clear()
+    _log.info('create date=%s title=%r', target_date, title)
+    return _parse_event(saved)
+
+  async def delete_event(self, event_dict: dict) -> None:
+    async with await self._client() as client:
+      cal = await self._get_calendar(client)
+      raw = await cal.event_by_uid(event_dict['uid'])
+      await raw.delete()
+    self._event_cache.clear()
+    _log.info('delete date=%s title=%r', event_dict['start'].date(), event_dict.get('title', ''))
+
+  async def update_event(self, event_dict: dict, title: str = None, description: str = None, x_data: str = None) -> dict:
+    async with await self._client() as client:
+      cal = await self._get_calendar(client)
+      raw = await cal.event_by_uid(event_dict['uid'])
+      ical = raw.icalendar_instance
+      for component in ical.walk():
+        if component.name != 'VEVENT':
+          continue
+        if title is not None:
+          if 'SUMMARY' in component:
+            del component['SUMMARY']
+          component.add('summary', title)
+        if description is not None:
+          if 'DESCRIPTION' in component:
+            del component['DESCRIPTION']
+          component.add('description', description)
+        if x_data is not None:
+          if _X_DATA_PROP in component:
+            del component[_X_DATA_PROP]
+          component.add(_X_DATA_PROP, x_data)
+      raw.data = ical.to_ical().decode('utf-8')
+      await raw.save()
+    self._event_cache.clear()
+    effective_title = title if title is not None else event_dict.get('title', '')
+    _log.info('update date=%s title=%r', event_dict['start'].date(), effective_title)
+    return _parse_event(raw)

+ 20 - 210
room_cal/store/_caldav.py

@@ -1,220 +1,16 @@
-import asyncio
 import logging
-import uuid
-from caldav import aio
-from datetime import date, datetime, timedelta, timezone
-from icalendar import Calendar as _iCal, Event as _iEvent
+from datetime import datetime, timedelta
 
+# Re-export for backward compatibility — Calendar lives in room_cal.caldav
+from room_cal.caldav import (
+  Calendar, DEFAULT_CALENDAR_NAME,
+  _parse_event, _build_ical, _X_DATA_PROP,
+)
 from room_cal.store._backend import EventRecord, register_backend
 
 _log = logging.getLogger(__name__)
 
 
-def _naive(dt) -> datetime:
-  if isinstance(dt, datetime):
-    return dt.replace(tzinfo=None)
-  return datetime(dt.year, dt.month, dt.day)
-
-
-_X_DATA_PROP = 'X-ROOMCAL-DATA'
-
-
-def _parse_event(caldav_event) -> dict:
-  cal = caldav_event.icalendar_instance
-  vevent = next(c for c in cal.walk() if c.name == 'VEVENT')
-
-  dtstart = vevent.get('DTSTART').dt
-  dtend_prop = vevent.get('DTEND') or vevent.get('DUE')
-  dtend = dtend_prop.dt if dtend_prop else dtstart
-
-  all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime)
-
-  recurrence_id = vevent.get('RECURRENCE-ID')
-
-  x_data_prop = vevent.get(_X_DATA_PROP)
-  x_data = str(x_data_prop) if x_data_prop is not None else None
-
-  return {
-    'start':        _naive(dtstart),
-    'end':          _naive(dtend),
-    'all_day':      all_day,
-    'title':        str(vevent.get('SUMMARY', '')),
-    'description':  str(vevent.get('DESCRIPTION', '')),
-    'x_data':       x_data,
-    'uid':          str(vevent.get('UID', '')),
-    'url':          str(caldav_event.url),
-    'etag':         getattr(caldav_event, 'etag', None),
-    'recurrence_id': _naive(recurrence_id.dt) if recurrence_id else None,
-    '_vevent':      vevent,
-    '_raw_event':   caldav_event,
-  }
-
-
-def _build_ical(dtstart, dtend, title: str, description: str = '', uid: str = None, x_data: str = None) -> str:
-  cal = _iCal()
-  cal.add('prodid', '-//room_cal//EN')
-  cal.add('version', '2.0')
-
-  event = _iEvent()
-  event.add('uid', uid or str(uuid.uuid4()))
-  event.add('dtstamp', datetime.now(timezone.utc))
-  event.add('dtstart', dtstart)
-  event.add('dtend', dtend)
-  event.add('summary', title)
-  if description:
-    event.add('description', description)
-  if x_data is not None:
-    event.add(_X_DATA_PROP, x_data)
-
-  cal.add_component(event)
-  return cal.to_ical().decode('utf-8')
-
-
-DEFAULT_CALENDAR_NAME = 'Room'
-
-
-class Calendar:
-  def __init__(self, calendar_name=DEFAULT_CALENDAR_NAME, *, url='', username='', password=''):
-    self._calendar_name = calendar_name
-    self._connection = {'url': url, 'username': username, 'password': password}
-    self._cached_url = None
-    self._event_cache: dict[tuple, list] = {}
-    self._inflight: dict[tuple, asyncio.Task] = {}
-
-  @property
-  def calendar_name(self) -> str:
-    return self._calendar_name
-
-  def _resolved_connection(self) -> dict:
-    if not self._connection.get('url'):
-      raise ValueError('CALDAV_URL must be set via store.caldav.url in config.json')
-    return self._connection
-
-  async def _client(self):
-    return await aio.get_async_davclient(**self._resolved_connection())
-
-  async def _get_calendar(self, client):
-    if self._cached_url is not None:
-      return client.calendar(url=self._cached_url)
-    from caldav.lib.error import AuthorizationError
-    try:
-      principal = await client.principal()
-    except AuthorizationError:
-      conn = self._resolved_connection()
-      raise AuthorizationError(
-        f"CalDAV auth failed for {conn['url']} (username={conn.get('username', '(none)')})"
-      ) from None
-    calendars = await principal.get_calendars()
-    for cal in calendars:
-      if await cal.get_display_name() == self._calendar_name:
-        self._cached_url = str(cal.url)
-        return cal
-    raise ValueError(f"Calendar '{self._calendar_name}' not found")
-
-  async def fetch_events(self, start: datetime, end: datetime) -> list[dict]:
-    async with await self._client() as client:
-      cal = await self._get_calendar(client)
-      raw_events = await cal.search(
-        start=start,
-        end=end,
-        event=True,
-        expand=True,
-      )
-    events = [_parse_event(e) for e in raw_events]
-    events.sort(key=lambda e: e['start'])
-    return events
-
-  async def fetch_date_range(self, start_date: date, end_date: date) -> list[dict]:
-    key = (start_date, end_date)
-    if key in self._event_cache:
-      return self._event_cache[key]
-    if key in self._inflight:
-      return await self._inflight[key]
-    task = asyncio.create_task(self._fetch_date_range_uncached(start_date, end_date))
-    self._inflight[key] = task
-    try:
-      result = await task
-    finally:
-      self._inflight.pop(key, None)
-    self._event_cache[key] = result
-    return result
-
-  async def _fetch_date_range_uncached(self, start_date: date, end_date: date) -> list[dict]:
-    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)
-    return await self.fetch_events(start, end)
-
-  async def create_timed_event(self, dtstart: datetime, dtend: datetime, title: str, description: str = '', x_data: str = None) -> dict:
-    ical_str = _build_ical(dtstart, dtend, title, description, x_data=x_data)
-    async with await self._client() as client:
-      cal = await self._get_calendar(client)
-      saved = await cal.save_event(ical_str)
-    self._event_cache.clear()
-    _log.info('create date=%s title=%r', dtstart.date(), title)
-    return _parse_event(saved)
-
-  async def create_event(self, target_date: date, title: str, description: str = '', x_data: str = None, end_date: date = None) -> dict:
-    if isinstance(target_date, datetime):
-      target_date = target_date.date()
-    dtend = end_date if end_date is not None else target_date + timedelta(days=1)
-    ical_str = _build_ical(target_date, dtend, title, description, x_data=x_data)
-    async with await self._client() as client:
-      cal = await self._get_calendar(client)
-      saved = await cal.save_event(ical_str)
-    self._event_cache.clear()
-    _log.info('create date=%s title=%r', target_date, title)
-    return _parse_event(saved)
-
-  async def delete_event(self, event_dict: dict) -> None:
-    async with await self._client() as client:
-      cal = await self._get_calendar(client)
-      raw = await cal.event_by_uid(event_dict['uid'])
-      await raw.delete()
-    self._event_cache.clear()
-    _log.info('delete date=%s title=%r', event_dict['start'].date(), event_dict.get('title', ''))
-
-  async def update_event(self, event_dict: dict, title: str = None, description: str = None, x_data: str = None) -> dict:
-    async with await self._client() as client:
-      cal = await self._get_calendar(client)
-      raw = await cal.event_by_uid(event_dict['uid'])
-      ical = raw.icalendar_instance
-      for component in ical.walk():
-        if component.name != 'VEVENT':
-          continue
-        if title is not None:
-          if 'SUMMARY' in component:
-            del component['SUMMARY']
-          component.add('summary', title)
-        if description is not None:
-          if 'DESCRIPTION' in component:
-            del component['DESCRIPTION']
-          component.add('description', description)
-        if x_data is not None:
-          if _X_DATA_PROP in component:
-            del component[_X_DATA_PROP]
-          component.add(_X_DATA_PROP, x_data)
-      raw.data = ical.to_ical().decode('utf-8')
-      await raw.save()
-    self._event_cache.clear()
-    effective_title = title if title is not None else event_dict.get('title', '')
-    _log.info('update date=%s title=%r', event_dict['start'].date(), effective_title)
-    return _parse_event(raw)
-
-
-_calendar_registry: dict[str, Calendar] = {}
-
-
-def get_calendar(name: str = DEFAULT_CALENDAR_NAME) -> Calendar:
-  """Return the shared Calendar instance for the given name, creating it on first call."""
-  if name not in _calendar_registry:
-    from room_cal._utils import get_config
-    cal_cfg = get_config().store.caldav
-    _calendar_registry[name] = Calendar(name, url=cal_cfg.url, username=cal_cfg.username, password=cal_cfg.password)
-  return _calendar_registry[name]
-
-
-# ── CalDAVBackend ─────────────────────────────────────────────────────────────
-
 def _dict_to_record(d: dict) -> EventRecord:
   return EventRecord(
     uid=d['uid'],
@@ -228,8 +24,22 @@ def _dict_to_record(d: dict) -> EventRecord:
   )
 
 
+_calendar_registry: dict[str, Calendar] = {}
+
+
+def get_calendar(name: str = DEFAULT_CALENDAR_NAME) -> Calendar:
+  """Return the store's shared Calendar instance for the given name, creating it on first call."""
+  if name not in _calendar_registry:
+    from room_cal._utils import get_config
+    cal_cfg = get_config().store.caldav
+    _calendar_registry[name] = Calendar(name, url=cal_cfg.url, username=cal_cfg.username, password=cal_cfg.password)
+  return _calendar_registry[name]
+
+
 @register_backend('caldav')
 class CalDAVBackend:
+  """EventBackend adapter that bridges a Calendar instance to the store EventBackend protocol."""
+
   def __init__(self, cal: Calendar):
     self._cal = cal
 

+ 27 - 5
room_cal/user_cal/__init__.py

@@ -1,20 +1,42 @@
 import json
-from datetime import date
-from room_cal.store import StoreBacked
-from room_cal.store._caldav import CalDAVBackend, Calendar
+import uuid
+from datetime import date, datetime, timedelta
+
+from room_cal.caldav import Calendar
+from room_cal.store._backend import EventRecord
+from room_cal.store._caldav import CalDAVBackend
 from room_cal._utils import EventSource, get_config, safe_json_loads, services as _services, service_name
 
 _ROOM_CLOSED_TITLE = 'Room Closed'
 
 
-class UserCal(StoreBacked, EventSource):
+class UserCal(EventSource):
   def __init__(self, *, backend=None):
     if backend is None:
       cfg = get_config().user_cal
       backend = CalDAVBackend(Calendar(cfg.calendar_name, url=cfg.url, username=cfg.username, password=cfg.password))
-    StoreBacked.__init__(self, backend=backend)
+    self._backend = backend
     EventSource.__init__(self, 'user_cal')
 
+  async def _fetch_date_range(self, start_date: date, end_date: date) -> list[EventRecord]:
+    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)
+    return await self._backend.fetch_range(start, end)
+
+  async def _create_all_day(self, start_date: date, title: str, description: str = '',
+                             x_data: str | None = None, *, end_date: date | None = None) -> EventRecord:
+    end = end_date if end_date is not None else start_date + timedelta(days=1)
+    record = EventRecord(
+      uid=str(uuid.uuid4()),
+      start=datetime(start_date.year, start_date.month, start_date.day),
+      end=datetime(end.year, end.month, end.day),
+      all_day=True,
+      title=title,
+      description=description,
+      x_data=x_data,
+    )
+    return await self._backend.create(record)
+
   async def find_closed(self, start_date: date, end_date: date) -> list:
     """Return all 'Room Closed' events in the date range (timed or all-day)."""
     events = await self._fetch_date_range(start_date, end_date)