google.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. # -*- coding: utf-8 -*-
  2. """
  3. Google Calendar Provider.
  4. Unterstuetzt Service Account und OAuth2 Flow.
  5. Benoetigt: google-auth, google-api-python-client (optional)
  6. """
  7. from datetime import datetime
  8. from pathlib import Path
  9. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  10. from plugins.calendar.models import CalendarEvent, EventStatus
  11. from plugins.calendar.providers.base import CalendarProvider
  12. # Optionale Dependencies
  13. try:
  14. from google.oauth2 import service_account as google_service_account
  15. from google.auth.transport.requests import Request as GoogleAuthRequest
  16. GOOGLE_AUTH_AVAILABLE = True
  17. except ImportError:
  18. GOOGLE_AUTH_AVAILABLE = False
  19. try:
  20. from googleapiclient.discovery import build as google_build
  21. GOOGLE_API_AVAILABLE = True
  22. except ImportError:
  23. GOOGLE_API_AVAILABLE = False
  24. try:
  25. from google_auth_oauthlib.flow import InstalledAppFlow
  26. GOOGLE_OAUTH_AVAILABLE = True
  27. except ImportError:
  28. GOOGLE_OAUTH_AVAILABLE = False
  29. try:
  30. from google.oauth2.credentials import Credentials as GoogleCredentials
  31. GOOGLE_CREDENTIALS_AVAILABLE = True
  32. except ImportError:
  33. GOOGLE_CREDENTIALS_AVAILABLE = False
  34. GOOGLE_SCOPES = ["https://www.googleapis.com/auth/calendar"]
  35. class GoogleProvider(CalendarProvider):
  36. """
  37. Google Calendar API Provider.
  38. Unterstuetzt zwei Auth-Flows:
  39. - service_account: JSON Key mit Domain-Wide Delegation
  40. - oauth2: OAuth2 mit lokaler Token-Persistierung
  41. """
  42. def __init__(self, name: str, config: dict) -> None:
  43. super().__init__(name, config)
  44. self._service: object | None = None
  45. self._credentials: object | None = None
  46. self._auth_flow: str = config.get("auth_flow", "service_account")
  47. async def connect(self) -> bool:
  48. """Verbindet mit der Google Calendar API."""
  49. if not GOOGLE_AUTH_AVAILABLE:
  50. pwarn(f"GoogleProvider({self.name}): google-auth nicht installiert")
  51. return False
  52. if not GOOGLE_API_AVAILABLE:
  53. pwarn(f"GoogleProvider({self.name}): google-api-python-client nicht installiert")
  54. return False
  55. try:
  56. if self._auth_flow == "oauth2":
  57. success = self._connect_oauth2()
  58. else:
  59. success = self._connect_service_account()
  60. if success and self._credentials:
  61. self._service = google_build(
  62. "calendar", "v3",
  63. credentials=self._credentials,
  64. )
  65. self._connected = True
  66. pinfo(f"GoogleProvider({self.name}): Verbunden (Flow: {self._auth_flow})")
  67. return True
  68. return False
  69. except Exception as e:
  70. perror(f"GoogleProvider({self.name}): Verbindung fehlgeschlagen: {e}")
  71. self._connected = False
  72. return False
  73. def _connect_service_account(self) -> bool:
  74. """Authentifizierung via Service Account."""
  75. sa_file = self.config.get("service_account_file", "")
  76. if not sa_file:
  77. perror(f"GoogleProvider({self.name}): service_account_file erforderlich")
  78. return False
  79. sa_path = Path(sa_file)
  80. if not sa_path.exists():
  81. perror(f"GoogleProvider({self.name}): Service-Account-Datei nicht gefunden: {sa_file}")
  82. return False
  83. self._credentials = google_service_account.Credentials.from_service_account_file(
  84. str(sa_path),
  85. scopes=GOOGLE_SCOPES,
  86. )
  87. # Domain-Wide Delegation: Default-User setzen falls vorhanden
  88. user_mapping = self.config.get("user_mapping", {})
  89. if user_mapping:
  90. first_email = next(iter(user_mapping.values()))
  91. self._credentials = self._credentials.with_subject(first_email)
  92. return True
  93. def _connect_oauth2(self) -> bool:
  94. """Authentifizierung via OAuth2."""
  95. if not GOOGLE_OAUTH_AVAILABLE or not GOOGLE_CREDENTIALS_AVAILABLE:
  96. pwarn(f"GoogleProvider({self.name}): google-auth-oauthlib nicht installiert")
  97. return False
  98. token_path = Path(f"plugins/calendar/.google_token_{self.name}.json")
  99. client_secret_file = self.config.get("client_secret_file", "")
  100. # Gespeicherten Token laden
  101. if token_path.exists():
  102. self._credentials = GoogleCredentials.from_authorized_user_file(
  103. str(token_path), GOOGLE_SCOPES,
  104. )
  105. # Token erneuern oder neu anfordern
  106. if self._credentials and self._credentials.expired and self._credentials.refresh_token:
  107. self._credentials.refresh(GoogleAuthRequest())
  108. self._save_google_token(token_path)
  109. return True
  110. if self._credentials and self._credentials.valid:
  111. return True
  112. # Neuer OAuth2-Flow
  113. if not client_secret_file or not Path(client_secret_file).exists():
  114. perror(f"GoogleProvider({self.name}): client_secret_file erforderlich fuer OAuth2")
  115. return False
  116. flow = InstalledAppFlow.from_client_secrets_file(
  117. client_secret_file, GOOGLE_SCOPES,
  118. )
  119. self._credentials = flow.run_local_server(port=0)
  120. self._save_google_token(token_path)
  121. return True
  122. def _save_google_token(self, token_path: Path) -> None:
  123. """Speichert Google OAuth2 Token."""
  124. if self._credentials:
  125. token_path.write_text(self._credentials.to_json())
  126. async def disconnect(self) -> None:
  127. """Trennt die Verbindung."""
  128. self._service = None
  129. self._credentials = None
  130. self._connected = False
  131. pdebug(f"GoogleProvider({self.name}): Getrennt")
  132. async def fetch_events(
  133. self,
  134. calendar_id: str,
  135. start: datetime,
  136. end: datetime,
  137. user: str = "",
  138. ) -> list[CalendarEvent]:
  139. """Ruft Events aus einem Google-Kalender ab."""
  140. if not self._connected or not self._service:
  141. return []
  142. try:
  143. # Bei Service Account: Credentials fuer spezifischen User delegieren
  144. service = self._get_delegated_service(user)
  145. start_iso = start.isoformat() + "Z"
  146. end_iso = end.isoformat() + "Z"
  147. result = service.events().list(
  148. calendarId=calendar_id,
  149. timeMin=start_iso,
  150. timeMax=end_iso,
  151. singleEvents=True,
  152. orderBy="startTime",
  153. maxResults=100,
  154. ).execute()
  155. events: list[CalendarEvent] = []
  156. for item in result.get("items", []):
  157. event = self._parse_google_event(item, calendar_id)
  158. if event:
  159. events.append(event)
  160. pdebug(f"GoogleProvider({self.name}): {len(events)} Events geladen")
  161. return events
  162. except Exception as e:
  163. perror(f"GoogleProvider({self.name}): Fehler beim Abrufen: {e}")
  164. return []
  165. async def create_event(
  166. self,
  167. calendar_id: str,
  168. title: str,
  169. start: datetime,
  170. end: datetime,
  171. user: str = "",
  172. location: str = "",
  173. description: str = "",
  174. attendees: list[str] | None = None,
  175. ) -> CalendarEvent | None:
  176. """Erstellt einen Termin via Google Calendar API."""
  177. if not self._connected or not self._service:
  178. return None
  179. try:
  180. service = self._get_delegated_service(user)
  181. body: dict = {
  182. "summary": title,
  183. "start": {"dateTime": start.isoformat(), "timeZone": "Europe/Berlin"},
  184. "end": {"dateTime": end.isoformat(), "timeZone": "Europe/Berlin"},
  185. }
  186. if location:
  187. body["location"] = location
  188. if description:
  189. body["description"] = description
  190. if attendees:
  191. body["attendees"] = [{"email": a} for a in attendees]
  192. result = service.events().insert(
  193. calendarId=calendar_id,
  194. body=body,
  195. ).execute()
  196. if result and "id" in result:
  197. pinfo(f"GoogleProvider({self.name}): Termin '{title}' erstellt")
  198. return self._parse_google_event(result, calendar_id)
  199. return None
  200. except Exception as e:
  201. perror(f"GoogleProvider({self.name}): Fehler beim Erstellen: {e}")
  202. return None
  203. async def health_check(self) -> bool:
  204. """Prueft die Google Calendar API-Verbindung."""
  205. if not self._connected or not self._service:
  206. return False
  207. try:
  208. self._service.calendarList().list(maxResults=1).execute()
  209. return True
  210. except Exception:
  211. return False
  212. # =========================================================================
  213. # Hilfsmethoden
  214. # =========================================================================
  215. def _get_delegated_service(self, user: str) -> object:
  216. """
  217. Gibt einen Google-Service mit delegierten Credentials zurueck.
  218. Bei Service Account mit Domain-Wide Delegation wird der User gewechselt.
  219. """
  220. if (
  221. self._auth_flow == "service_account"
  222. and user
  223. and GOOGLE_AUTH_AVAILABLE
  224. and GOOGLE_API_AVAILABLE
  225. ):
  226. delegated = self._credentials.with_subject(user)
  227. return google_build("calendar", "v3", credentials=delegated)
  228. return self._service
  229. def _parse_google_event(self, item: dict, calendar_id: str) -> CalendarEvent | None:
  230. """Konvertiert ein Google Calendar Event in ein CalendarEvent."""
  231. try:
  232. event_id = item.get("id", "")
  233. title = item.get("summary", "")
  234. start_data = item.get("start", {})
  235. end_data = item.get("end", {})
  236. # Ganztaegig: "date" statt "dateTime"
  237. all_day = "date" in start_data and "dateTime" not in start_data
  238. if all_day:
  239. from datetime import date as date_type
  240. start_date = date_type.fromisoformat(start_data["date"])
  241. end_date = date_type.fromisoformat(end_data.get("date", start_data["date"]))
  242. start_dt = datetime.combine(start_date, datetime.min.time())
  243. end_dt = datetime.combine(end_date, datetime.min.time())
  244. else:
  245. start_str = start_data.get("dateTime", "")
  246. end_str = end_data.get("dateTime", "")
  247. if not start_str:
  248. return None
  249. start_dt = datetime.fromisoformat(start_str)
  250. end_dt = datetime.fromisoformat(end_str) if end_str else start_dt
  251. # Timezone-Info entfernen fuer lokale Vergleiche
  252. start_dt = start_dt.replace(tzinfo=None)
  253. end_dt = end_dt.replace(tzinfo=None)
  254. location = item.get("location", "")
  255. description = item.get("description", "")
  256. # Teilnehmer
  257. attendees: list[str] = []
  258. for att in item.get("attendees", []):
  259. name = att.get("displayName", att.get("email", ""))
  260. if name:
  261. attendees.append(name)
  262. organizer = ""
  263. org_data = item.get("organizer", {})
  264. if org_data:
  265. organizer = org_data.get("displayName", org_data.get("email", ""))
  266. # Status
  267. status_str = item.get("status", "confirmed")
  268. status_map = {
  269. "confirmed": EventStatus.CONFIRMED,
  270. "tentative": EventStatus.TENTATIVE,
  271. "cancelled": EventStatus.CANCELLED,
  272. }
  273. status = status_map.get(status_str, EventStatus.CONFIRMED)
  274. # Erinnerung
  275. reminder_minutes = 15
  276. reminders = item.get("reminders", {})
  277. overrides = reminders.get("overrides", [])
  278. if overrides:
  279. reminder_minutes = overrides[0].get("minutes", 15)
  280. return CalendarEvent(
  281. event_id=event_id,
  282. calendar_id=calendar_id,
  283. provider_name=self.name,
  284. title=title,
  285. start=start_dt,
  286. end=end_dt,
  287. all_day=all_day,
  288. location=location,
  289. description=description,
  290. attendees=attendees,
  291. organizer=organizer,
  292. status=status,
  293. reminder_minutes=reminder_minutes,
  294. raw_data=item,
  295. )
  296. except Exception as e:
  297. perror(f"GoogleProvider({self.name}): Fehler beim Parsen: {e}")
  298. return None