microsoft.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. # -*- coding: utf-8 -*-
  2. """
  3. Microsoft Graph Calendar Provider.
  4. Unterstuetzt Client Credentials Flow und Device Code Flow.
  5. Benoetigt: httpx (bereits installiert), msal (optional)
  6. """
  7. import json
  8. from datetime import datetime, timezone
  9. from pathlib import Path
  10. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  11. from plugins.calendar.models import CalendarEvent, EventStatus
  12. from plugins.calendar.providers.base import CalendarProvider
  13. # Optionale Dependency
  14. try:
  15. import msal
  16. MSAL_AVAILABLE = True
  17. except ImportError:
  18. MSAL_AVAILABLE = False
  19. try:
  20. import httpx
  21. HTTPX_AVAILABLE = True
  22. except ImportError:
  23. HTTPX_AVAILABLE = False
  24. # Microsoft Graph API Basis-URL
  25. GRAPH_BASE = "https://graph.microsoft.com/v1.0"
  26. GRAPH_SCOPES = ["https://graph.microsoft.com/.default"]
  27. class MicrosoftProvider(CalendarProvider):
  28. """
  29. Microsoft Graph API Calendar Provider.
  30. Unterstuetzt zwei Auth-Flows:
  31. - client_credentials: Service Account (tenant_id + client_id + client_secret)
  32. - device_code: Interaktiver Login via MSAL Device Code Flow
  33. """
  34. def __init__(self, name: str, config: dict) -> None:
  35. super().__init__(name, config)
  36. self._access_token: str = ""
  37. self._token_expires: datetime | None = None
  38. self._http_client: object | None = None
  39. self._msal_app: object | None = None
  40. self._auth_flow: str = config.get("auth_flow", "client_credentials")
  41. self._token_cache_path: Path | None = None
  42. async def connect(self) -> bool:
  43. """Verbindet mit Microsoft Graph API."""
  44. if not HTTPX_AVAILABLE:
  45. pwarn(f"MicrosoftProvider({self.name}): httpx nicht installiert")
  46. return False
  47. tenant_id = self.config.get("tenant_id", "")
  48. client_id = self.config.get("client_id", "")
  49. if not tenant_id or not client_id:
  50. perror(f"MicrosoftProvider({self.name}): tenant_id und client_id erforderlich")
  51. return False
  52. try:
  53. self._http_client = httpx.AsyncClient(timeout=30.0)
  54. if self._auth_flow == "device_code":
  55. success = await self._connect_device_code(tenant_id, client_id)
  56. else:
  57. success = await self._connect_client_credentials(tenant_id, client_id)
  58. if success:
  59. self._connected = True
  60. pinfo(f"MicrosoftProvider({self.name}): Verbunden (Flow: {self._auth_flow})")
  61. return success
  62. except Exception as e:
  63. perror(f"MicrosoftProvider({self.name}): Verbindung fehlgeschlagen: {e}")
  64. self._connected = False
  65. return False
  66. async def _connect_client_credentials(self, tenant_id: str, client_id: str) -> bool:
  67. """Authentifizierung via Client Credentials Flow."""
  68. client_secret = self.config.get("client_secret", "")
  69. if not client_secret:
  70. perror(f"MicrosoftProvider({self.name}): client_secret erforderlich fuer client_credentials")
  71. return False
  72. token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
  73. response = await self._http_client.post(
  74. token_url,
  75. data={
  76. "grant_type": "client_credentials",
  77. "client_id": client_id,
  78. "client_secret": client_secret,
  79. "scope": GRAPH_SCOPES[0],
  80. },
  81. )
  82. if response.status_code != 200:
  83. perror(f"MicrosoftProvider({self.name}): Token-Anfrage fehlgeschlagen: {response.status_code}")
  84. return False
  85. data = response.json()
  86. self._access_token = data.get("access_token", "")
  87. expires_in = data.get("expires_in", 3600)
  88. self._token_expires = datetime.now(timezone.utc).replace(tzinfo=None) + \
  89. __import__("datetime").timedelta(seconds=expires_in)
  90. return bool(self._access_token)
  91. async def _connect_device_code(self, tenant_id: str, client_id: str) -> bool:
  92. """Authentifizierung via Device Code Flow (benoetigt msal)."""
  93. if not MSAL_AVAILABLE:
  94. pwarn(f"MicrosoftProvider({self.name}): msal nicht installiert, Device Code Flow nicht verfuegbar")
  95. return False
  96. # Token-Cache laden
  97. cache = msal.SerializableTokenCache()
  98. self._token_cache_path = Path(f"plugins/calendar/.msal_cache_{self.name}.json")
  99. if self._token_cache_path.exists():
  100. cache.deserialize(self._token_cache_path.read_text())
  101. self._msal_app = msal.PublicClientApplication(
  102. client_id,
  103. authority=f"https://login.microsoftonline.com/{tenant_id}",
  104. token_cache=cache,
  105. )
  106. # Versuche Silent-Token
  107. accounts = self._msal_app.get_accounts()
  108. if accounts:
  109. result = self._msal_app.acquire_token_silent(
  110. ["https://graph.microsoft.com/Calendars.ReadWrite"],
  111. account=accounts[0],
  112. )
  113. if result and "access_token" in result:
  114. self._access_token = result["access_token"]
  115. self._save_token_cache(cache)
  116. return True
  117. # Device Code Flow
  118. flow = self._msal_app.initiate_device_flow(
  119. ["https://graph.microsoft.com/Calendars.ReadWrite"]
  120. )
  121. if "user_code" not in flow:
  122. perror(f"MicrosoftProvider({self.name}): Device Code Flow konnte nicht gestartet werden")
  123. return False
  124. pinfo(f"MicrosoftProvider({self.name}): Bitte {flow['verification_uri']} oeffnen und Code '{flow['user_code']}' eingeben")
  125. result = self._msal_app.acquire_token_by_device_flow(flow)
  126. if "access_token" in result:
  127. self._access_token = result["access_token"]
  128. self._save_token_cache(cache)
  129. return True
  130. perror(f"MicrosoftProvider({self.name}): Device Code Flow fehlgeschlagen: {result.get('error_description', '')}")
  131. return False
  132. def _save_token_cache(self, cache: object) -> None:
  133. """Speichert den MSAL-Token-Cache."""
  134. if self._token_cache_path and hasattr(cache, "has_state_changed") and cache.has_state_changed:
  135. self._token_cache_path.write_text(cache.serialize())
  136. async def disconnect(self) -> None:
  137. """Trennt die Verbindung."""
  138. if self._http_client and hasattr(self._http_client, "aclose"):
  139. await self._http_client.aclose()
  140. self._http_client = None
  141. self._access_token = ""
  142. self._connected = False
  143. pdebug(f"MicrosoftProvider({self.name}): Getrennt")
  144. async def fetch_events(
  145. self,
  146. calendar_id: str,
  147. start: datetime,
  148. end: datetime,
  149. user: str = "",
  150. ) -> list[CalendarEvent]:
  151. """Ruft Events aus einem Microsoft-Kalender ab."""
  152. if not self._connected or not self._http_client:
  153. return []
  154. if not user:
  155. perror(f"MicrosoftProvider({self.name}): user (Email) erforderlich fuer fetch_events")
  156. return []
  157. try:
  158. # CalendarView Endpoint verwenden
  159. start_iso = start.strftime("%Y-%m-%dT%H:%M:%S")
  160. end_iso = end.strftime("%Y-%m-%dT%H:%M:%S")
  161. url = (
  162. f"{GRAPH_BASE}/users/{user}/calendars/{calendar_id}/calendarView"
  163. f"?startDateTime={start_iso}&endDateTime={end_iso}"
  164. f"&$top=100&$orderby=start/dateTime"
  165. )
  166. response = await self._graph_request("GET", url)
  167. if response is None:
  168. return []
  169. events: list[CalendarEvent] = []
  170. for item in response.get("value", []):
  171. event = self._parse_graph_event(item, calendar_id)
  172. if event:
  173. events.append(event)
  174. pdebug(f"MicrosoftProvider({self.name}): {len(events)} Events fuer {user} geladen")
  175. return events
  176. except Exception as e:
  177. perror(f"MicrosoftProvider({self.name}): Fehler beim Abrufen: {e}")
  178. return []
  179. async def create_event(
  180. self,
  181. calendar_id: str,
  182. title: str,
  183. start: datetime,
  184. end: datetime,
  185. user: str = "",
  186. location: str = "",
  187. description: str = "",
  188. attendees: list[str] | None = None,
  189. ) -> CalendarEvent | None:
  190. """Erstellt einen Termin via Microsoft Graph."""
  191. if not self._connected or not self._http_client:
  192. return None
  193. if not user:
  194. perror(f"MicrosoftProvider({self.name}): user (Email) erforderlich fuer create_event")
  195. return None
  196. try:
  197. body: dict = {
  198. "subject": title,
  199. "start": {
  200. "dateTime": start.strftime("%Y-%m-%dT%H:%M:%S"),
  201. "timeZone": "Europe/Berlin",
  202. },
  203. "end": {
  204. "dateTime": end.strftime("%Y-%m-%dT%H:%M:%S"),
  205. "timeZone": "Europe/Berlin",
  206. },
  207. }
  208. if location:
  209. body["location"] = {"displayName": location}
  210. if description:
  211. body["body"] = {"contentType": "text", "content": description}
  212. if attendees:
  213. body["attendees"] = [
  214. {"emailAddress": {"address": a}, "type": "required"}
  215. for a in attendees
  216. ]
  217. url = f"{GRAPH_BASE}/users/{user}/calendars/{calendar_id}/events"
  218. response = await self._graph_request("POST", url, json_body=body)
  219. if response and "id" in response:
  220. pinfo(f"MicrosoftProvider({self.name}): Termin '{title}' erstellt")
  221. return self._parse_graph_event(response, calendar_id)
  222. return None
  223. except Exception as e:
  224. perror(f"MicrosoftProvider({self.name}): Fehler beim Erstellen: {e}")
  225. return None
  226. async def health_check(self) -> bool:
  227. """Prueft die Graph-API-Verbindung."""
  228. if not self._connected or not self._http_client:
  229. return False
  230. try:
  231. # Einfacher API-Call zum Testen
  232. user_mapping = self.config.get("user_mapping", {})
  233. if not user_mapping:
  234. return True
  235. first_email = next(iter(user_mapping.values()))
  236. url = f"{GRAPH_BASE}/users/{first_email}"
  237. response = await self._graph_request("GET", url)
  238. return response is not None
  239. except Exception:
  240. return False
  241. # =========================================================================
  242. # Graph API Hilfsmethoden
  243. # =========================================================================
  244. async def _graph_request(
  245. self,
  246. method: str,
  247. url: str,
  248. json_body: dict | None = None,
  249. ) -> dict | None:
  250. """
  251. Fuehrt einen Graph-API-Request aus mit automatischem Token-Refresh.
  252. Args:
  253. method: HTTP-Methode (GET, POST, etc.)
  254. url: Vollstaendige URL
  255. json_body: Optionaler JSON-Body
  256. Returns:
  257. Response-Daten als dict oder None bei Fehler
  258. """
  259. if not self._http_client:
  260. return None
  261. headers = {
  262. "Authorization": f"Bearer {self._access_token}",
  263. "Content-Type": "application/json",
  264. }
  265. # Erster Versuch
  266. if method == "GET":
  267. response = await self._http_client.get(url, headers=headers)
  268. elif method == "POST":
  269. response = await self._http_client.post(url, headers=headers, json=json_body)
  270. else:
  271. return None
  272. # Bei 401: Token erneuern und erneut versuchen
  273. if response.status_code == 401:
  274. pdebug(f"MicrosoftProvider({self.name}): Token abgelaufen, erneuere...")
  275. refreshed = await self._refresh_token()
  276. if not refreshed:
  277. self._connected = False
  278. return None
  279. headers["Authorization"] = f"Bearer {self._access_token}"
  280. if method == "GET":
  281. response = await self._http_client.get(url, headers=headers)
  282. elif method == "POST":
  283. response = await self._http_client.post(url, headers=headers, json=json_body)
  284. if response.status_code >= 400:
  285. perror(f"MicrosoftProvider({self.name}): Graph-API Fehler {response.status_code}: {response.text[:200]}")
  286. return None
  287. if response.status_code == 204:
  288. return {}
  289. return response.json()
  290. async def _refresh_token(self) -> bool:
  291. """Erneuert den Access Token."""
  292. tenant_id = self.config.get("tenant_id", "")
  293. client_id = self.config.get("client_id", "")
  294. if self._auth_flow == "device_code" and self._msal_app:
  295. accounts = self._msal_app.get_accounts()
  296. if accounts:
  297. result = self._msal_app.acquire_token_silent(
  298. ["https://graph.microsoft.com/Calendars.ReadWrite"],
  299. account=accounts[0],
  300. )
  301. if result and "access_token" in result:
  302. self._access_token = result["access_token"]
  303. return True
  304. return False
  305. # Client Credentials: Neuen Token holen
  306. return await self._connect_client_credentials(tenant_id, client_id)
  307. def _parse_graph_event(self, item: dict, calendar_id: str) -> CalendarEvent | None:
  308. """Konvertiert ein Microsoft Graph Event in ein CalendarEvent."""
  309. try:
  310. event_id = item.get("id", "")
  311. title = item.get("subject", "")
  312. start_data = item.get("start", {})
  313. end_data = item.get("end", {})
  314. all_day = item.get("isAllDay", False)
  315. start_str = start_data.get("dateTime", "")
  316. end_str = end_data.get("dateTime", "")
  317. if not start_str:
  318. return None
  319. # Datetime parsen (Microsoft Format: 2024-01-15T14:00:00.0000000)
  320. start_dt = datetime.fromisoformat(start_str.split(".")[0])
  321. end_dt = datetime.fromisoformat(end_str.split(".")[0]) if end_str else start_dt
  322. location = ""
  323. loc_data = item.get("location", {})
  324. if loc_data:
  325. location = loc_data.get("displayName", "")
  326. description = ""
  327. body_data = item.get("body", {})
  328. if body_data:
  329. description = body_data.get("content", "")
  330. # Teilnehmer
  331. attendees: list[str] = []
  332. for att in item.get("attendees", []):
  333. email_data = att.get("emailAddress", {})
  334. name = email_data.get("name", email_data.get("address", ""))
  335. if name:
  336. attendees.append(name)
  337. organizer = ""
  338. org_data = item.get("organizer", {})
  339. if org_data:
  340. org_email = org_data.get("emailAddress", {})
  341. organizer = org_email.get("name", org_email.get("address", ""))
  342. # Status
  343. show_as = item.get("showAs", "busy")
  344. status_map = {
  345. "busy": EventStatus.CONFIRMED,
  346. "tentative": EventStatus.TENTATIVE,
  347. "free": EventStatus.TENTATIVE,
  348. }
  349. status = status_map.get(show_as, EventStatus.CONFIRMED)
  350. if item.get("isCancelled", False):
  351. status = EventStatus.CANCELLED
  352. return CalendarEvent(
  353. event_id=event_id,
  354. calendar_id=calendar_id,
  355. provider_name=self.name,
  356. title=title,
  357. start=start_dt,
  358. end=end_dt,
  359. all_day=all_day,
  360. location=location,
  361. description=description,
  362. attendees=attendees,
  363. organizer=organizer,
  364. status=status,
  365. raw_data=item,
  366. )
  367. except Exception as e:
  368. perror(f"MicrosoftProvider({self.name}): Fehler beim Parsen: {e}")
  369. return None