# -*- coding: utf-8 -*- """ Microsoft Graph Calendar Provider. Unterstuetzt Client Credentials Flow und Device Code Flow. Benoetigt: httpx (bereits installiert), msal (optional) """ import json from datetime import datetime, timezone from pathlib import Path from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn from plugins.calendar.models import CalendarEvent, EventStatus from plugins.calendar.providers.base import CalendarProvider # Optionale Dependency try: import msal MSAL_AVAILABLE = True except ImportError: MSAL_AVAILABLE = False try: import httpx HTTPX_AVAILABLE = True except ImportError: HTTPX_AVAILABLE = False # Microsoft Graph API Basis-URL GRAPH_BASE = "https://graph.microsoft.com/v1.0" GRAPH_SCOPES = ["https://graph.microsoft.com/.default"] class MicrosoftProvider(CalendarProvider): """ Microsoft Graph API Calendar Provider. Unterstuetzt zwei Auth-Flows: - client_credentials: Service Account (tenant_id + client_id + client_secret) - device_code: Interaktiver Login via MSAL Device Code Flow """ def __init__(self, name: str, config: dict) -> None: super().__init__(name, config) self._access_token: str = "" self._token_expires: datetime | None = None self._http_client: object | None = None self._msal_app: object | None = None self._auth_flow: str = config.get("auth_flow", "client_credentials") self._token_cache_path: Path | None = None async def connect(self) -> bool: """Verbindet mit Microsoft Graph API.""" if not HTTPX_AVAILABLE: pwarn(f"MicrosoftProvider({self.name}): httpx nicht installiert") return False tenant_id = self.config.get("tenant_id", "") client_id = self.config.get("client_id", "") if not tenant_id or not client_id: perror(f"MicrosoftProvider({self.name}): tenant_id und client_id erforderlich") return False try: self._http_client = httpx.AsyncClient(timeout=30.0) if self._auth_flow == "device_code": success = await self._connect_device_code(tenant_id, client_id) else: success = await self._connect_client_credentials(tenant_id, client_id) if success: self._connected = True pinfo(f"MicrosoftProvider({self.name}): Verbunden (Flow: {self._auth_flow})") return success except Exception as e: perror(f"MicrosoftProvider({self.name}): Verbindung fehlgeschlagen: {e}") self._connected = False return False async def _connect_client_credentials(self, tenant_id: str, client_id: str) -> bool: """Authentifizierung via Client Credentials Flow.""" client_secret = self.config.get("client_secret", "") if not client_secret: perror(f"MicrosoftProvider({self.name}): client_secret erforderlich fuer client_credentials") return False token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" response = await self._http_client.post( token_url, data={ "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": GRAPH_SCOPES[0], }, ) if response.status_code != 200: perror(f"MicrosoftProvider({self.name}): Token-Anfrage fehlgeschlagen: {response.status_code}") return False data = response.json() self._access_token = data.get("access_token", "") expires_in = data.get("expires_in", 3600) self._token_expires = datetime.now(timezone.utc).replace(tzinfo=None) + \ __import__("datetime").timedelta(seconds=expires_in) return bool(self._access_token) async def _connect_device_code(self, tenant_id: str, client_id: str) -> bool: """Authentifizierung via Device Code Flow (benoetigt msal).""" if not MSAL_AVAILABLE: pwarn(f"MicrosoftProvider({self.name}): msal nicht installiert, Device Code Flow nicht verfuegbar") return False # Token-Cache laden cache = msal.SerializableTokenCache() self._token_cache_path = Path(f"plugins/calendar/.msal_cache_{self.name}.json") if self._token_cache_path.exists(): cache.deserialize(self._token_cache_path.read_text()) self._msal_app = msal.PublicClientApplication( client_id, authority=f"https://login.microsoftonline.com/{tenant_id}", token_cache=cache, ) # Versuche Silent-Token accounts = self._msal_app.get_accounts() if accounts: result = self._msal_app.acquire_token_silent( ["https://graph.microsoft.com/Calendars.ReadWrite"], account=accounts[0], ) if result and "access_token" in result: self._access_token = result["access_token"] self._save_token_cache(cache) return True # Device Code Flow flow = self._msal_app.initiate_device_flow( ["https://graph.microsoft.com/Calendars.ReadWrite"] ) if "user_code" not in flow: perror(f"MicrosoftProvider({self.name}): Device Code Flow konnte nicht gestartet werden") return False pinfo(f"MicrosoftProvider({self.name}): Bitte {flow['verification_uri']} oeffnen und Code '{flow['user_code']}' eingeben") result = self._msal_app.acquire_token_by_device_flow(flow) if "access_token" in result: self._access_token = result["access_token"] self._save_token_cache(cache) return True perror(f"MicrosoftProvider({self.name}): Device Code Flow fehlgeschlagen: {result.get('error_description', '')}") return False def _save_token_cache(self, cache: object) -> None: """Speichert den MSAL-Token-Cache.""" if self._token_cache_path and hasattr(cache, "has_state_changed") and cache.has_state_changed: self._token_cache_path.write_text(cache.serialize()) async def disconnect(self) -> None: """Trennt die Verbindung.""" if self._http_client and hasattr(self._http_client, "aclose"): await self._http_client.aclose() self._http_client = None self._access_token = "" self._connected = False pdebug(f"MicrosoftProvider({self.name}): Getrennt") async def fetch_events( self, calendar_id: str, start: datetime, end: datetime, user: str = "", ) -> list[CalendarEvent]: """Ruft Events aus einem Microsoft-Kalender ab.""" if not self._connected or not self._http_client: return [] if not user: perror(f"MicrosoftProvider({self.name}): user (Email) erforderlich fuer fetch_events") return [] try: # CalendarView Endpoint verwenden start_iso = start.strftime("%Y-%m-%dT%H:%M:%S") end_iso = end.strftime("%Y-%m-%dT%H:%M:%S") url = ( f"{GRAPH_BASE}/users/{user}/calendars/{calendar_id}/calendarView" f"?startDateTime={start_iso}&endDateTime={end_iso}" f"&$top=100&$orderby=start/dateTime" ) response = await self._graph_request("GET", url) if response is None: return [] events: list[CalendarEvent] = [] for item in response.get("value", []): event = self._parse_graph_event(item, calendar_id) if event: events.append(event) pdebug(f"MicrosoftProvider({self.name}): {len(events)} Events fuer {user} geladen") return events except Exception as e: perror(f"MicrosoftProvider({self.name}): Fehler beim Abrufen: {e}") return [] async def create_event( self, calendar_id: str, title: str, start: datetime, end: datetime, user: str = "", location: str = "", description: str = "", attendees: list[str] | None = None, ) -> CalendarEvent | None: """Erstellt einen Termin via Microsoft Graph.""" if not self._connected or not self._http_client: return None if not user: perror(f"MicrosoftProvider({self.name}): user (Email) erforderlich fuer create_event") return None try: body: dict = { "subject": title, "start": { "dateTime": start.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin", }, "end": { "dateTime": end.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "Europe/Berlin", }, } if location: body["location"] = {"displayName": location} if description: body["body"] = {"contentType": "text", "content": description} if attendees: body["attendees"] = [ {"emailAddress": {"address": a}, "type": "required"} for a in attendees ] url = f"{GRAPH_BASE}/users/{user}/calendars/{calendar_id}/events" response = await self._graph_request("POST", url, json_body=body) if response and "id" in response: pinfo(f"MicrosoftProvider({self.name}): Termin '{title}' erstellt") return self._parse_graph_event(response, calendar_id) return None except Exception as e: perror(f"MicrosoftProvider({self.name}): Fehler beim Erstellen: {e}") return None async def health_check(self) -> bool: """Prueft die Graph-API-Verbindung.""" if not self._connected or not self._http_client: return False try: # Einfacher API-Call zum Testen user_mapping = self.config.get("user_mapping", {}) if not user_mapping: return True first_email = next(iter(user_mapping.values())) url = f"{GRAPH_BASE}/users/{first_email}" response = await self._graph_request("GET", url) return response is not None except Exception: return False # ========================================================================= # Graph API Hilfsmethoden # ========================================================================= async def _graph_request( self, method: str, url: str, json_body: dict | None = None, ) -> dict | None: """ Fuehrt einen Graph-API-Request aus mit automatischem Token-Refresh. Args: method: HTTP-Methode (GET, POST, etc.) url: Vollstaendige URL json_body: Optionaler JSON-Body Returns: Response-Daten als dict oder None bei Fehler """ if not self._http_client: return None headers = { "Authorization": f"Bearer {self._access_token}", "Content-Type": "application/json", } # Erster Versuch if method == "GET": response = await self._http_client.get(url, headers=headers) elif method == "POST": response = await self._http_client.post(url, headers=headers, json=json_body) else: return None # Bei 401: Token erneuern und erneut versuchen if response.status_code == 401: pdebug(f"MicrosoftProvider({self.name}): Token abgelaufen, erneuere...") refreshed = await self._refresh_token() if not refreshed: self._connected = False return None headers["Authorization"] = f"Bearer {self._access_token}" if method == "GET": response = await self._http_client.get(url, headers=headers) elif method == "POST": response = await self._http_client.post(url, headers=headers, json=json_body) if response.status_code >= 400: perror(f"MicrosoftProvider({self.name}): Graph-API Fehler {response.status_code}: {response.text[:200]}") return None if response.status_code == 204: return {} return response.json() async def _refresh_token(self) -> bool: """Erneuert den Access Token.""" tenant_id = self.config.get("tenant_id", "") client_id = self.config.get("client_id", "") if self._auth_flow == "device_code" and self._msal_app: accounts = self._msal_app.get_accounts() if accounts: result = self._msal_app.acquire_token_silent( ["https://graph.microsoft.com/Calendars.ReadWrite"], account=accounts[0], ) if result and "access_token" in result: self._access_token = result["access_token"] return True return False # Client Credentials: Neuen Token holen return await self._connect_client_credentials(tenant_id, client_id) def _parse_graph_event(self, item: dict, calendar_id: str) -> CalendarEvent | None: """Konvertiert ein Microsoft Graph Event in ein CalendarEvent.""" try: event_id = item.get("id", "") title = item.get("subject", "") start_data = item.get("start", {}) end_data = item.get("end", {}) all_day = item.get("isAllDay", False) start_str = start_data.get("dateTime", "") end_str = end_data.get("dateTime", "") if not start_str: return None # Datetime parsen (Microsoft Format: 2024-01-15T14:00:00.0000000) start_dt = datetime.fromisoformat(start_str.split(".")[0]) end_dt = datetime.fromisoformat(end_str.split(".")[0]) if end_str else start_dt location = "" loc_data = item.get("location", {}) if loc_data: location = loc_data.get("displayName", "") description = "" body_data = item.get("body", {}) if body_data: description = body_data.get("content", "") # Teilnehmer attendees: list[str] = [] for att in item.get("attendees", []): email_data = att.get("emailAddress", {}) name = email_data.get("name", email_data.get("address", "")) if name: attendees.append(name) organizer = "" org_data = item.get("organizer", {}) if org_data: org_email = org_data.get("emailAddress", {}) organizer = org_email.get("name", org_email.get("address", "")) # Status show_as = item.get("showAs", "busy") status_map = { "busy": EventStatus.CONFIRMED, "tentative": EventStatus.TENTATIVE, "free": EventStatus.TENTATIVE, } status = status_map.get(show_as, EventStatus.CONFIRMED) if item.get("isCancelled", False): status = EventStatus.CANCELLED return CalendarEvent( event_id=event_id, calendar_id=calendar_id, provider_name=self.name, title=title, start=start_dt, end=end_dt, all_day=all_day, location=location, description=description, attendees=attendees, organizer=organizer, status=status, raw_data=item, ) except Exception as e: perror(f"MicrosoftProvider({self.name}): Fehler beim Parsen: {e}") return None