caldav_provider.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. # -*- coding: utf-8 -*-
  2. """
  3. CalDAV Calendar Provider.
  4. Unterstuetzt Nextcloud, iCloud und andere CalDAV-kompatible Server.
  5. Benoetigt: caldav, icalendar (optional)
  6. """
  7. from datetime import datetime
  8. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  9. from plugins.calendar.models import CalendarEvent, EventStatus
  10. from plugins.calendar.providers.base import CalendarProvider
  11. # Optionale Dependencies
  12. try:
  13. import caldav as caldav_lib
  14. CALDAV_AVAILABLE = True
  15. except ImportError:
  16. CALDAV_AVAILABLE = False
  17. try:
  18. from icalendar import Calendar as ICalCalendar
  19. ICALENDAR_AVAILABLE = True
  20. except ImportError:
  21. ICALENDAR_AVAILABLE = False
  22. class CalDAVProvider(CalendarProvider):
  23. """
  24. CalDAV-basierter Calendar Provider.
  25. Authentifizierung: Username + Password (oder App-Passwort).
  26. """
  27. def __init__(self, name: str, config: dict) -> None:
  28. super().__init__(name, config)
  29. self._client: object | None = None
  30. self._principal: object | None = None
  31. async def connect(self) -> bool:
  32. """Verbindet mit dem CalDAV-Server."""
  33. if not CALDAV_AVAILABLE:
  34. pwarn(f"CalDAVProvider({self.name}): caldav nicht installiert")
  35. return False
  36. if not ICALENDAR_AVAILABLE:
  37. pwarn(f"CalDAVProvider({self.name}): icalendar nicht installiert")
  38. return False
  39. url = self.config.get("url", "")
  40. username = self.config.get("username", "")
  41. password = self.config.get("password", "")
  42. if not url:
  43. perror(f"CalDAVProvider({self.name}): Keine URL konfiguriert")
  44. return False
  45. try:
  46. self._client = caldav_lib.DAVClient(
  47. url=url,
  48. username=username,
  49. password=password,
  50. )
  51. self._principal = self._client.principal()
  52. self._connected = True
  53. pinfo(f"CalDAVProvider({self.name}): Verbunden mit {url}")
  54. return True
  55. except Exception as e:
  56. perror(f"CalDAVProvider({self.name}): Verbindung fehlgeschlagen: {e}")
  57. self._connected = False
  58. return False
  59. async def disconnect(self) -> None:
  60. """Trennt die Verbindung."""
  61. self._client = None
  62. self._principal = None
  63. self._connected = False
  64. pdebug(f"CalDAVProvider({self.name}): Getrennt")
  65. async def fetch_events(
  66. self,
  67. calendar_id: str,
  68. start: datetime,
  69. end: datetime,
  70. user: str = "",
  71. ) -> list[CalendarEvent]:
  72. """Ruft Events aus einem CalDAV-Kalender ab."""
  73. if not self._connected or not self._principal:
  74. return []
  75. try:
  76. # Kalender finden
  77. cal = self._find_calendar(calendar_id)
  78. if not cal:
  79. pwarn(f"CalDAVProvider({self.name}): Kalender '{calendar_id}' nicht gefunden")
  80. return []
  81. # Events im Zeitfenster abrufen
  82. results = cal.search(
  83. start=start,
  84. end=end,
  85. event=True,
  86. expand=True,
  87. )
  88. events: list[CalendarEvent] = []
  89. for item in results:
  90. event = self._parse_vevent(item, calendar_id)
  91. if event:
  92. events.append(event)
  93. pdebug(f"CalDAVProvider({self.name}): {len(events)} Events aus '{calendar_id}' geladen")
  94. return events
  95. except Exception as e:
  96. perror(f"CalDAVProvider({self.name}): Fehler beim Abrufen von Events: {e}")
  97. return []
  98. async def create_event(
  99. self,
  100. calendar_id: str,
  101. title: str,
  102. start: datetime,
  103. end: datetime,
  104. user: str = "",
  105. location: str = "",
  106. description: str = "",
  107. attendees: list[str] | None = None,
  108. ) -> CalendarEvent | None:
  109. """Erstellt einen neuen Termin im CalDAV-Kalender."""
  110. if not self._connected or not self._principal:
  111. return None
  112. try:
  113. cal = self._find_calendar(calendar_id)
  114. if not cal:
  115. perror(f"CalDAVProvider({self.name}): Kalender '{calendar_id}' nicht gefunden")
  116. return None
  117. # iCalendar-Event erstellen
  118. import uuid
  119. event_uid = str(uuid.uuid4())
  120. vcal = (
  121. "BEGIN:VCALENDAR\r\n"
  122. "VERSION:2.0\r\n"
  123. "PRODID:-//Trixy//Calendar Plugin//DE\r\n"
  124. "BEGIN:VEVENT\r\n"
  125. f"UID:{event_uid}\r\n"
  126. f"DTSTART:{start.strftime('%Y%m%dT%H%M%S')}\r\n"
  127. f"DTEND:{end.strftime('%Y%m%dT%H%M%S')}\r\n"
  128. f"SUMMARY:{title}\r\n"
  129. )
  130. if location:
  131. vcal += f"LOCATION:{location}\r\n"
  132. if description:
  133. vcal += f"DESCRIPTION:{description}\r\n"
  134. vcal += "END:VEVENT\r\nEND:VCALENDAR\r\n"
  135. cal.save_event(vcal)
  136. pinfo(f"CalDAVProvider({self.name}): Termin '{title}' erstellt")
  137. return CalendarEvent(
  138. event_id=event_uid,
  139. calendar_id=calendar_id,
  140. provider_name=self.name,
  141. title=title,
  142. start=start,
  143. end=end,
  144. location=location,
  145. description=description,
  146. attendees=attendees or [],
  147. )
  148. except Exception as e:
  149. perror(f"CalDAVProvider({self.name}): Fehler beim Erstellen: {e}")
  150. return None
  151. async def health_check(self) -> bool:
  152. """Prueft die Verbindung zum CalDAV-Server."""
  153. if not self._connected or not self._principal:
  154. return False
  155. try:
  156. self._principal.calendars()
  157. return True
  158. except Exception:
  159. return False
  160. # =========================================================================
  161. # Hilfsmethoden
  162. # =========================================================================
  163. def _find_calendar(self, calendar_id: str) -> object | None:
  164. """Findet einen Kalender anhand seiner ID."""
  165. if not self._principal:
  166. return None
  167. try:
  168. for cal in self._principal.calendars():
  169. # Vergleich per Name oder URL-Fragment
  170. cal_name = getattr(cal, "name", "")
  171. cal_url = str(getattr(cal, "url", ""))
  172. if calendar_id in (cal_name, cal_url) or cal_url.rstrip("/").endswith(calendar_id):
  173. return cal
  174. except Exception as e:
  175. perror(f"CalDAVProvider({self.name}): Fehler beim Suchen des Kalenders: {e}")
  176. return None
  177. def _parse_vevent(self, item: object, calendar_id: str) -> CalendarEvent | None:
  178. """Parst ein CalDAV-Event in ein CalendarEvent."""
  179. try:
  180. ical_data = item.data
  181. if not ICALENDAR_AVAILABLE:
  182. return None
  183. cal = ICalCalendar.from_ical(ical_data)
  184. for component in cal.walk():
  185. if component.name != "VEVENT":
  186. continue
  187. uid = str(component.get("UID", ""))
  188. summary = str(component.get("SUMMARY", ""))
  189. dtstart = component.get("DTSTART")
  190. dtend = component.get("DTEND")
  191. if not dtstart:
  192. continue
  193. start_dt = dtstart.dt
  194. end_dt = dtend.dt if dtend else start_dt
  195. # date zu datetime konvertieren (ganztaegige Termine)
  196. all_day = not isinstance(start_dt, datetime)
  197. if all_day:
  198. start_dt = datetime.combine(start_dt, datetime.min.time())
  199. end_dt = datetime.combine(end_dt, datetime.min.time())
  200. location = str(component.get("LOCATION", ""))
  201. description = str(component.get("DESCRIPTION", ""))
  202. organizer = str(component.get("ORGANIZER", ""))
  203. # Teilnehmer
  204. attendees: list[str] = []
  205. attendee_list = component.get("ATTENDEE", [])
  206. if not isinstance(attendee_list, list):
  207. attendee_list = [attendee_list]
  208. for att in attendee_list:
  209. att_str = str(att).replace("mailto:", "")
  210. if att_str:
  211. attendees.append(att_str)
  212. # Status
  213. status_str = str(component.get("STATUS", "CONFIRMED")).upper()
  214. status_map = {
  215. "CONFIRMED": EventStatus.CONFIRMED,
  216. "TENTATIVE": EventStatus.TENTATIVE,
  217. "CANCELLED": EventStatus.CANCELLED,
  218. }
  219. status = status_map.get(status_str, EventStatus.CONFIRMED)
  220. return CalendarEvent(
  221. event_id=uid,
  222. calendar_id=calendar_id,
  223. provider_name=self.name,
  224. title=summary,
  225. start=start_dt,
  226. end=end_dt,
  227. all_day=all_day,
  228. location=location,
  229. description=description,
  230. attendees=attendees,
  231. organizer=organizer,
  232. status=status,
  233. raw_data={"ical": ical_data},
  234. )
  235. except Exception as e:
  236. perror(f"CalDAVProvider({self.name}): Fehler beim Parsen eines Events: {e}")
  237. return None