| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- # -*- 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
|