# -*- coding: utf-8 -*- """ Google Calendar Provider. Unterstuetzt Service Account und OAuth2 Flow. Benoetigt: google-auth, google-api-python-client (optional) """ from datetime import datetime 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 Dependencies try: from google.oauth2 import service_account as google_service_account from google.auth.transport.requests import Request as GoogleAuthRequest GOOGLE_AUTH_AVAILABLE = True except ImportError: GOOGLE_AUTH_AVAILABLE = False try: from googleapiclient.discovery import build as google_build GOOGLE_API_AVAILABLE = True except ImportError: GOOGLE_API_AVAILABLE = False try: from google_auth_oauthlib.flow import InstalledAppFlow GOOGLE_OAUTH_AVAILABLE = True except ImportError: GOOGLE_OAUTH_AVAILABLE = False try: from google.oauth2.credentials import Credentials as GoogleCredentials GOOGLE_CREDENTIALS_AVAILABLE = True except ImportError: GOOGLE_CREDENTIALS_AVAILABLE = False GOOGLE_SCOPES = ["https://www.googleapis.com/auth/calendar"] class GoogleProvider(CalendarProvider): """ Google Calendar API Provider. Unterstuetzt zwei Auth-Flows: - service_account: JSON Key mit Domain-Wide Delegation - oauth2: OAuth2 mit lokaler Token-Persistierung """ def __init__(self, name: str, config: dict) -> None: super().__init__(name, config) self._service: object | None = None self._credentials: object | None = None self._auth_flow: str = config.get("auth_flow", "service_account") async def connect(self) -> bool: """Verbindet mit der Google Calendar API.""" if not GOOGLE_AUTH_AVAILABLE: pwarn(f"GoogleProvider({self.name}): google-auth nicht installiert") return False if not GOOGLE_API_AVAILABLE: pwarn(f"GoogleProvider({self.name}): google-api-python-client nicht installiert") return False try: if self._auth_flow == "oauth2": success = self._connect_oauth2() else: success = self._connect_service_account() if success and self._credentials: self._service = google_build( "calendar", "v3", credentials=self._credentials, ) self._connected = True pinfo(f"GoogleProvider({self.name}): Verbunden (Flow: {self._auth_flow})") return True return False except Exception as e: perror(f"GoogleProvider({self.name}): Verbindung fehlgeschlagen: {e}") self._connected = False return False def _connect_service_account(self) -> bool: """Authentifizierung via Service Account.""" sa_file = self.config.get("service_account_file", "") if not sa_file: perror(f"GoogleProvider({self.name}): service_account_file erforderlich") return False sa_path = Path(sa_file) if not sa_path.exists(): perror(f"GoogleProvider({self.name}): Service-Account-Datei nicht gefunden: {sa_file}") return False self._credentials = google_service_account.Credentials.from_service_account_file( str(sa_path), scopes=GOOGLE_SCOPES, ) # Domain-Wide Delegation: Default-User setzen falls vorhanden user_mapping = self.config.get("user_mapping", {}) if user_mapping: first_email = next(iter(user_mapping.values())) self._credentials = self._credentials.with_subject(first_email) return True def _connect_oauth2(self) -> bool: """Authentifizierung via OAuth2.""" if not GOOGLE_OAUTH_AVAILABLE or not GOOGLE_CREDENTIALS_AVAILABLE: pwarn(f"GoogleProvider({self.name}): google-auth-oauthlib nicht installiert") return False token_path = Path(f"plugins/calendar/.google_token_{self.name}.json") client_secret_file = self.config.get("client_secret_file", "") # Gespeicherten Token laden if token_path.exists(): self._credentials = GoogleCredentials.from_authorized_user_file( str(token_path), GOOGLE_SCOPES, ) # Token erneuern oder neu anfordern if self._credentials and self._credentials.expired and self._credentials.refresh_token: self._credentials.refresh(GoogleAuthRequest()) self._save_google_token(token_path) return True if self._credentials and self._credentials.valid: return True # Neuer OAuth2-Flow if not client_secret_file or not Path(client_secret_file).exists(): perror(f"GoogleProvider({self.name}): client_secret_file erforderlich fuer OAuth2") return False flow = InstalledAppFlow.from_client_secrets_file( client_secret_file, GOOGLE_SCOPES, ) self._credentials = flow.run_local_server(port=0) self._save_google_token(token_path) return True def _save_google_token(self, token_path: Path) -> None: """Speichert Google OAuth2 Token.""" if self._credentials: token_path.write_text(self._credentials.to_json()) async def disconnect(self) -> None: """Trennt die Verbindung.""" self._service = None self._credentials = None self._connected = False pdebug(f"GoogleProvider({self.name}): Getrennt") async def fetch_events( self, calendar_id: str, start: datetime, end: datetime, user: str = "", ) -> list[CalendarEvent]: """Ruft Events aus einem Google-Kalender ab.""" if not self._connected or not self._service: return [] try: # Bei Service Account: Credentials fuer spezifischen User delegieren service = self._get_delegated_service(user) start_iso = start.isoformat() + "Z" end_iso = end.isoformat() + "Z" result = service.events().list( calendarId=calendar_id, timeMin=start_iso, timeMax=end_iso, singleEvents=True, orderBy="startTime", maxResults=100, ).execute() events: list[CalendarEvent] = [] for item in result.get("items", []): event = self._parse_google_event(item, calendar_id) if event: events.append(event) pdebug(f"GoogleProvider({self.name}): {len(events)} Events geladen") return events except Exception as e: perror(f"GoogleProvider({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 Google Calendar API.""" if not self._connected or not self._service: return None try: service = self._get_delegated_service(user) body: dict = { "summary": title, "start": {"dateTime": start.isoformat(), "timeZone": "Europe/Berlin"}, "end": {"dateTime": end.isoformat(), "timeZone": "Europe/Berlin"}, } if location: body["location"] = location if description: body["description"] = description if attendees: body["attendees"] = [{"email": a} for a in attendees] result = service.events().insert( calendarId=calendar_id, body=body, ).execute() if result and "id" in result: pinfo(f"GoogleProvider({self.name}): Termin '{title}' erstellt") return self._parse_google_event(result, calendar_id) return None except Exception as e: perror(f"GoogleProvider({self.name}): Fehler beim Erstellen: {e}") return None async def health_check(self) -> bool: """Prueft die Google Calendar API-Verbindung.""" if not self._connected or not self._service: return False try: self._service.calendarList().list(maxResults=1).execute() return True except Exception: return False # ========================================================================= # Hilfsmethoden # ========================================================================= def _get_delegated_service(self, user: str) -> object: """ Gibt einen Google-Service mit delegierten Credentials zurueck. Bei Service Account mit Domain-Wide Delegation wird der User gewechselt. """ if ( self._auth_flow == "service_account" and user and GOOGLE_AUTH_AVAILABLE and GOOGLE_API_AVAILABLE ): delegated = self._credentials.with_subject(user) return google_build("calendar", "v3", credentials=delegated) return self._service def _parse_google_event(self, item: dict, calendar_id: str) -> CalendarEvent | None: """Konvertiert ein Google Calendar Event in ein CalendarEvent.""" try: event_id = item.get("id", "") title = item.get("summary", "") start_data = item.get("start", {}) end_data = item.get("end", {}) # Ganztaegig: "date" statt "dateTime" all_day = "date" in start_data and "dateTime" not in start_data if all_day: from datetime import date as date_type start_date = date_type.fromisoformat(start_data["date"]) end_date = date_type.fromisoformat(end_data.get("date", start_data["date"])) start_dt = datetime.combine(start_date, datetime.min.time()) end_dt = datetime.combine(end_date, datetime.min.time()) else: start_str = start_data.get("dateTime", "") end_str = end_data.get("dateTime", "") if not start_str: return None start_dt = datetime.fromisoformat(start_str) end_dt = datetime.fromisoformat(end_str) if end_str else start_dt # Timezone-Info entfernen fuer lokale Vergleiche start_dt = start_dt.replace(tzinfo=None) end_dt = end_dt.replace(tzinfo=None) location = item.get("location", "") description = item.get("description", "") # Teilnehmer attendees: list[str] = [] for att in item.get("attendees", []): name = att.get("displayName", att.get("email", "")) if name: attendees.append(name) organizer = "" org_data = item.get("organizer", {}) if org_data: organizer = org_data.get("displayName", org_data.get("email", "")) # Status status_str = item.get("status", "confirmed") status_map = { "confirmed": EventStatus.CONFIRMED, "tentative": EventStatus.TENTATIVE, "cancelled": EventStatus.CANCELLED, } status = status_map.get(status_str, EventStatus.CONFIRMED) # Erinnerung reminder_minutes = 15 reminders = item.get("reminders", {}) overrides = reminders.get("overrides", []) if overrides: reminder_minutes = overrides[0].get("minutes", 15) 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, reminder_minutes=reminder_minutes, raw_data=item, ) except Exception as e: perror(f"GoogleProvider({self.name}): Fehler beim Parsen: {e}") return None