sync.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. # -*- coding: utf-8 -*-
  2. """
  3. Calendar Sync Manager.
  4. Periodische Synchronisation der Kalender-Events von Cloud-Providern.
  5. Cache-Verwaltung und Event-Abfragen.
  6. """
  7. import asyncio
  8. from datetime import datetime, timedelta
  9. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  10. from plugins.calendar.models import CalendarEvent, EventStatus
  11. from plugins.calendar.providers.base import CalendarProvider
  12. class CalendarSyncManager:
  13. """
  14. Verwaltet die Synchronisation von Kalender-Events.
  15. - In-Memory Cache pro Kalender-Key
  16. - Lock gegen parallele Syncs
  17. - Fehlerbehandlung pro Kalender (ein Fehler blockiert nicht die anderen)
  18. """
  19. def __init__(
  20. self,
  21. providers: dict[str, CalendarProvider],
  22. calendars_config: dict,
  23. lookahead_hours: int = 48,
  24. max_retries: int = 3,
  25. ) -> None:
  26. """
  27. Args:
  28. providers: Provider-Instanzen {name: provider}
  29. calendars_config: Kalender-Konfiguration aus config.json
  30. lookahead_hours: Zeitfenster fuer Event-Abfrage in Stunden
  31. max_retries: Maximale Wiederholungen bei Fehlern
  32. """
  33. self._providers = providers
  34. self._calendars_config = calendars_config
  35. self._lookahead_hours = lookahead_hours
  36. self._max_retries = max_retries
  37. self._event_cache: dict[str, list[CalendarEvent]] = {}
  38. self._sync_lock = asyncio.Lock()
  39. self._last_sync: datetime | None = None
  40. @property
  41. def last_sync(self) -> datetime | None:
  42. """Zeitpunkt der letzten Synchronisation."""
  43. return self._last_sync
  44. async def sync_all(self) -> dict[str, int]:
  45. """
  46. Synchronisiert alle konfigurierten Kalender.
  47. Returns:
  48. Dict mit {calendar_key: anzahl_events}
  49. """
  50. async with self._sync_lock:
  51. results: dict[str, int] = {}
  52. for cal_key, cal_config in self._calendars_config.items():
  53. try:
  54. events = await self.sync_calendar(cal_key)
  55. results[cal_key] = len(events)
  56. except Exception as e:
  57. perror(f"CalendarSync: Fehler bei '{cal_key}': {e}")
  58. results[cal_key] = -1
  59. self._last_sync = datetime.now()
  60. total = sum(v for v in results.values() if v >= 0)
  61. pinfo(f"CalendarSync: {total} Events aus {len(results)} Kalendern synchronisiert")
  62. return results
  63. async def sync_calendar(self, calendar_key: str) -> list[CalendarEvent]:
  64. """
  65. Synchronisiert einen einzelnen Kalender.
  66. Args:
  67. calendar_key: Kalender-Schluessel aus Config
  68. Returns:
  69. Liste der synchronisierten Events
  70. """
  71. cal_config = self._calendars_config.get(calendar_key, {})
  72. if not cal_config:
  73. pwarn(f"CalendarSync: Kalender '{calendar_key}' nicht in Config gefunden")
  74. return []
  75. provider_name = cal_config.get("provider", "")
  76. provider = self._providers.get(provider_name)
  77. if not provider:
  78. pwarn(f"CalendarSync: Provider '{provider_name}' fuer '{calendar_key}' nicht verfuegbar")
  79. return []
  80. if not provider.is_connected:
  81. # Reconnect-Versuch
  82. pdebug(f"CalendarSync: Provider '{provider_name}' nicht verbunden, versuche erneut...")
  83. connected = await provider.connect()
  84. if not connected:
  85. pwarn(f"CalendarSync: Provider '{provider_name}' weiterhin nicht erreichbar")
  86. return self._event_cache.get(calendar_key, [])
  87. calendar_id = cal_config.get("calendar_id", "")
  88. user = cal_config.get("user", "")
  89. # User-Email aus Provider-Config aufloeser
  90. user_email = self._resolve_user_email(provider_name, user)
  91. now = datetime.now()
  92. end = now + timedelta(hours=self._lookahead_hours)
  93. # Retry-Logik
  94. last_error: Exception | None = None
  95. for attempt in range(self._max_retries):
  96. try:
  97. events = await provider.fetch_events(
  98. calendar_id=calendar_id,
  99. start=now,
  100. end=end,
  101. user=user_email,
  102. )
  103. # Abgesagte Termine filtern
  104. events = [e for e in events if e.status != EventStatus.CANCELLED]
  105. # Cache aktualisieren
  106. self._event_cache[calendar_key] = events
  107. pdebug(f"CalendarSync: '{calendar_key}' -> {len(events)} Events")
  108. return events
  109. except Exception as e:
  110. last_error = e
  111. if attempt < self._max_retries - 1:
  112. pdebug(f"CalendarSync: Retry {attempt + 1}/{self._max_retries} fuer '{calendar_key}'")
  113. await asyncio.sleep(2 ** attempt)
  114. perror(f"CalendarSync: '{calendar_key}' fehlgeschlagen nach {self._max_retries} Versuchen: {last_error}")
  115. return self._event_cache.get(calendar_key, [])
  116. def get_events(
  117. self,
  118. calendar_keys: list[str],
  119. start: datetime | None = None,
  120. end: datetime | None = None,
  121. ) -> list[CalendarEvent]:
  122. """
  123. Gibt gecachte Events fuer die angegebenen Kalender zurueck.
  124. Args:
  125. calendar_keys: Liste von Kalender-Schluesseln
  126. start: Optionaler Start-Filter
  127. end: Optionaler End-Filter
  128. Returns:
  129. Sortierte Liste von CalendarEvent
  130. """
  131. all_events: list[CalendarEvent] = []
  132. for key in calendar_keys:
  133. events = self._event_cache.get(key, [])
  134. all_events.extend(events)
  135. # Zeitfilter anwenden
  136. if start:
  137. all_events = [e for e in all_events if e.end >= start]
  138. if end:
  139. all_events = [e for e in all_events if e.start <= end]
  140. # Nach Startzeit sortieren
  141. all_events.sort(key=lambda e: e.start)
  142. return all_events
  143. def get_next_event(self, calendar_keys: list[str]) -> CalendarEvent | None:
  144. """
  145. Gibt den naechsten bevorstehenden Termin zurueck.
  146. Args:
  147. calendar_keys: Liste von Kalender-Schluesseln
  148. Returns:
  149. Naechstes CalendarEvent oder None
  150. """
  151. now = datetime.now()
  152. events = self.get_events(calendar_keys, start=now)
  153. return events[0] if events else None
  154. def search_events(
  155. self,
  156. calendar_keys: list[str],
  157. query: str,
  158. ) -> list[CalendarEvent]:
  159. """
  160. Sucht Events anhand eines Suchbegriffs.
  161. Durchsucht Titel, Beschreibung und Ort.
  162. Args:
  163. calendar_keys: Liste von Kalender-Schluesseln
  164. query: Suchbegriff
  165. Returns:
  166. Gefundene Events, sortiert nach Startzeit
  167. """
  168. now = datetime.now()
  169. events = self.get_events(calendar_keys, start=now)
  170. query_lower = query.lower()
  171. results: list[CalendarEvent] = []
  172. for event in events:
  173. if (
  174. query_lower in event.title.lower()
  175. or query_lower in event.description.lower()
  176. or query_lower in event.location.lower()
  177. ):
  178. results.append(event)
  179. return results
  180. def get_all_cached_events(self) -> list[CalendarEvent]:
  181. """Gibt alle gecachten Events zurueck (fuer Notifications)."""
  182. all_events: list[CalendarEvent] = []
  183. for events in self._event_cache.values():
  184. all_events.extend(events)
  185. return all_events
  186. def get_calendar_key_for_event(self, event: CalendarEvent) -> str:
  187. """Gibt den Kalender-Key fuer ein Event zurueck."""
  188. for key, events in self._event_cache.items():
  189. if any(e.event_id == event.event_id for e in events):
  190. return key
  191. return ""
  192. def _resolve_user_email(self, provider_name: str, user: str) -> str:
  193. """Loest einen User-Key in eine Email-Adresse auf."""
  194. if not user:
  195. return ""
  196. provider = self._providers.get(provider_name)
  197. if not provider:
  198. return user
  199. user_mapping = provider.config.get("user_mapping", {})
  200. return user_mapping.get(user, user)