Google Calendar OAuth2 Integration + CalDAV-Kalender ausblenden statt löschen

- Google OAuth2 Flow: Admin konfiguriert Client-ID/Secret, User verbindet per Klick
- Google Calendar API v3: Events lesen, erstellen, bearbeiten, löschen
- GoogleAccount Model + google_router mit Token-Refresh
- Google-Events in Event-Pipeline integriert
- Frontend: Google Kalender in Sidebar, Dropdown, Event-CRUD-Routing
- CalDAV-Kalender: Ausblenden statt ganzes Konto löschen, Einblenden in Einstellungen
- Ausgeblendete Kalender Sektion in Einstellungen
This commit is contained in:
2026-03-27 08:44:51 +01:00
parent cd46b45ec6
commit 0ffb6e5c49
6 changed files with 528 additions and 24 deletions

View File

@@ -12,7 +12,7 @@ from sqlalchemy import text
sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine
from routers import auth_router, caldav_router, ical_router, local_router, profile_router, settings_router, users_router
from routers import auth_router, caldav_router, google_router, ical_router, local_router, profile_router, settings_router, users_router
logging.basicConfig(level=logging.INFO)
@@ -43,6 +43,7 @@ app.include_router(settings_router.router, prefix="/api/settings", tags=["settin
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")

View File

@@ -27,6 +27,9 @@ class User(Base):
ical_subscriptions = relationship(
"ICalSubscription", back_populates="user", cascade="all, delete-orphan"
)
google_accounts = relationship(
"GoogleAccount", back_populates="user", cascade="all, delete-orphan"
)
class CalDAVAccount(Base):
@@ -138,3 +141,16 @@ class ICalOverride(Base):
color = Column(String(7), nullable=True)
subscription = relationship("ICalSubscription", back_populates="overrides")
class GoogleAccount(Base):
__tablename__ = "google_accounts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
email = Column(String(255), nullable=False)
access_token = Column(Text, nullable=False)
refresh_token = Column(Text, nullable=False)
token_expiry = Column(DateTime, nullable=True)
user = relationship("User", back_populates="google_accounts")

View File

@@ -336,6 +336,19 @@ def get_events(
except Exception as exc:
logger.error("Error fetching iCal subscription %s: %s", sub.id, exc)
# ── Google Calendar events ───────────────────────────
from routers.google_router import get_google_events
google_accounts = (
db.query(models.GoogleAccount)
.filter(models.GoogleAccount.user_id == current_user.id)
.all()
)
for g_acc in google_accounts:
try:
all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
except Exception as exc:
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
return all_events

View File

@@ -0,0 +1,363 @@
import logging
import os
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import urlencode
import requests as http_requests
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")
GOOGLE_REDIRECT_URI = os.environ.get("GOOGLE_REDIRECT_URI", "")
SCOPES = "https://www.googleapis.com/auth/calendar"
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_URL = "https://oauth2.googleapis.com/token"
CALENDAR_API = "https://www.googleapis.com/calendar/v3"
def _google_configured() -> bool:
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
def _refresh_access_token(account: models.GoogleAccount, db: Session) -> str:
"""Refresh the access token if expired, return valid access token."""
now = datetime.now(timezone.utc)
if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now:
return account.access_token
resp = http_requests.post(TOKEN_URL, data={
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"refresh_token": account.refresh_token,
"grant_type": "refresh_token",
}, timeout=15)
if resp.status_code != 200:
logger.error("Google token refresh failed: %s", resp.text)
raise HTTPException(401, "Google-Token abgelaufen, bitte neu verbinden")
data = resp.json()
account.access_token = data["access_token"]
account.token_expiry = datetime.fromtimestamp(
now.timestamp() + data.get("expires_in", 3600), tz=timezone.utc
)
db.commit()
return account.access_token
def _google_api(token: str, path: str, method: str = "GET", json_body=None, params=None):
"""Make an authenticated request to Google Calendar API."""
headers = {"Authorization": f"Bearer {token}"}
url = f"{CALENDAR_API}{path}"
if method == "GET":
resp = http_requests.get(url, headers=headers, params=params, timeout=15)
elif method == "POST":
resp = http_requests.post(url, headers=headers, json=json_body, timeout=15)
elif method == "PUT":
resp = http_requests.put(url, headers=headers, json=json_body, timeout=15)
elif method == "PATCH":
resp = http_requests.patch(url, headers=headers, json=json_body, timeout=15)
elif method == "DELETE":
resp = http_requests.delete(url, headers=headers, timeout=15)
if resp.status_code == 204:
return {"ok": True}
else:
raise ValueError(f"Unsupported method: {method}")
if resp.status_code >= 400:
logger.error("Google API error %s %s: %s", method, path, resp.text)
raise HTTPException(resp.status_code, f"Google API Fehler: {resp.status_code}")
return resp.json() if resp.text else {}
def _build_event_body(data: dict) -> dict:
"""Convert our event format to Google Calendar API format."""
body = {"summary": data.get("title", "")}
if data.get("location"):
body["location"] = data["location"]
if data.get("description"):
body["description"] = data["description"]
if data.get("allDay"):
body["start"] = {"date": data["start"][:10]}
body["end"] = {"date": data["end"][:10]}
else:
body["start"] = {"dateTime": data["start"]}
body["end"] = {"dateTime": data["end"]}
return body
def _parse_google_event(ev: dict, account_id: int, cal_name: str, cal_color: str) -> dict:
"""Convert Google Calendar event to our format."""
start = ev.get("start", {})
end = ev.get("end", {})
all_day = "date" in start
return {
"id": ev["id"],
"url": f"google://{account_id}/{ev['id']}",
"title": ev.get("summary", "(Kein Titel)"),
"start": start.get("date") or start.get("dateTime", ""),
"end": end.get("date") or end.get("dateTime", ""),
"allDay": all_day,
"location": ev.get("location", ""),
"description": ev.get("description", ""),
"color": None,
"calendar_id": f"google-{account_id}",
"calendar_name": cal_name,
"calendarColor": cal_color,
"source": "google",
}
# ── OAuth2 Flow ──────────────────────────────────────────
@router.get("/configured")
def is_configured():
return {"configured": _google_configured()}
@router.get("/auth-url")
def get_auth_url(current_user: models.User = Depends(get_current_user)):
if not _google_configured():
raise HTTPException(400, "Google OAuth nicht konfiguriert")
redirect_uri = GOOGLE_REDIRECT_URI or ""
params = {
"client_id": GOOGLE_CLIENT_ID,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": SCOPES,
"access_type": "offline",
"prompt": "consent",
"state": str(current_user.id),
}
return {"url": f"{AUTH_URL}?{urlencode(params)}"}
@router.get("/callback")
def oauth_callback(
code: str = Query(...),
state: str = Query(""),
db: Session = Depends(get_db),
):
if not _google_configured():
raise HTTPException(400, "Google OAuth nicht konfiguriert")
# Exchange code for tokens
resp = http_requests.post(TOKEN_URL, data={
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": GOOGLE_REDIRECT_URI,
}, timeout=15)
if resp.status_code != 200:
logger.error("Google token exchange failed: %s", resp.text)
raise HTTPException(400, "Fehler beim Google-Login")
tokens = resp.json()
access_token = tokens["access_token"]
refresh_token = tokens.get("refresh_token", "")
expires_in = tokens.get("expires_in", 3600)
# Get user email
user_info = http_requests.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
).json()
email = user_info.get("email", "Unbekannt")
user_id = int(state) if state.isdigit() else None
if not user_id:
raise HTTPException(400, "Ungültiger State-Parameter")
# Check if account already exists
existing = (
db.query(models.GoogleAccount)
.filter(models.GoogleAccount.user_id == user_id, models.GoogleAccount.email == email)
.first()
)
if existing:
existing.access_token = access_token
if refresh_token:
existing.refresh_token = refresh_token
existing.token_expiry = datetime.fromtimestamp(
datetime.now(timezone.utc).timestamp() + expires_in, tz=timezone.utc
)
else:
db.add(models.GoogleAccount(
user_id=user_id,
email=email,
access_token=access_token,
refresh_token=refresh_token,
token_expiry=datetime.fromtimestamp(
datetime.now(timezone.utc).timestamp() + expires_in, tz=timezone.utc
),
))
db.commit()
# Redirect back to app
return RedirectResponse(url="/", status_code=302)
# ── Account Management ───────────────────────────────────
@router.get("/accounts")
def list_accounts(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
accounts = (
db.query(models.GoogleAccount)
.filter(models.GoogleAccount.user_id == current_user.id)
.all()
)
return [{"id": a.id, "email": a.email} for a in accounts]
@router.delete("/accounts/{account_id}")
def delete_account(
account_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
acc = (
db.query(models.GoogleAccount)
.filter(models.GoogleAccount.id == account_id, models.GoogleAccount.user_id == current_user.id)
.first()
)
if not acc:
raise HTTPException(404, "Account not found")
db.delete(acc)
db.commit()
return {"ok": True}
# ── Events ───────────────────────────────────────────────
def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list:
"""Fetch events from all calendars in a Google account."""
try:
token = _refresh_access_token(account, db)
except Exception:
return []
all_events = []
try:
cal_list = _google_api(token, "/users/me/calendarList")
for cal in cal_list.get("items", []):
if cal.get("deleted"):
continue
cal_id = cal["id"]
cal_name = cal.get("summary", cal_id)
cal_color = cal.get("backgroundColor", "#4285f4")
events_resp = _google_api(token, f"/calendars/{cal_id}/events", params={
"timeMin": start_dt.isoformat(),
"timeMax": end_dt.isoformat(),
"singleEvents": "true",
"maxResults": 500,
})
for ev in events_resp.get("items", []):
if ev.get("status") == "cancelled":
continue
all_events.append(_parse_google_event(ev, account.id, cal_name, cal_color))
except Exception as exc:
logger.error("Error fetching Google Calendar for %s: %s", account.email, exc)
return all_events
class GoogleEventCreate(BaseModel):
account_id: int
title: str
start: str
end: str
allDay: bool = False
location: Optional[str] = None
description: Optional[str] = None
class GoogleEventUpdate(BaseModel):
title: Optional[str] = None
start: Optional[str] = None
end: Optional[str] = None
allDay: Optional[bool] = None
location: Optional[str] = None
description: Optional[str] = None
@router.post("/events")
def create_event(
data: GoogleEventCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
acc = (
db.query(models.GoogleAccount)
.filter(models.GoogleAccount.id == data.account_id, models.GoogleAccount.user_id == current_user.id)
.first()
)
if not acc:
raise HTTPException(404, "Google account not found")
token = _refresh_access_token(acc, db)
body = _build_event_body(data.model_dump())
result = _google_api(token, "/calendars/primary/events", method="POST", json_body=body)
return {"id": result.get("id"), "ok": True}
@router.put("/events/{account_id}/{event_id}")
def update_event(
account_id: int,
event_id: str,
data: GoogleEventUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
acc = (
db.query(models.GoogleAccount)
.filter(models.GoogleAccount.id == account_id, models.GoogleAccount.user_id == current_user.id)
.first()
)
if not acc:
raise HTTPException(404, "Google account not found")
token = _refresh_access_token(acc, db)
body = _build_event_body(data.model_dump(exclude_none=True))
_google_api(token, f"/calendars/primary/events/{event_id}", method="PATCH", json_body=body)
return {"ok": True}
@router.delete("/events/{account_id}/{event_id}")
def delete_event(
account_id: int,
event_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
acc = (
db.query(models.GoogleAccount)
.filter(models.GoogleAccount.id == account_id, models.GoogleAccount.user_id == current_user.id)
.first()
)
if not acc:
raise HTTPException(404, "Google account not found")
token = _refresh_access_token(acc, db)
_google_api(token, f"/calendars/primary/events/{event_id}", method="DELETE")
return {"ok": True}