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