- #weekNum@group.v.calendar.google.com (Kalenderwochen) wird beim Google- Sync, in der Kalenderliste und beim Event-Abruf übersprungen - layoutEvents: fehlende `end`-Variable im zweiten Pass ergänzt; ohne sie war die Bedingung immer false, sodass Spaltenanzahl überlappender Termine falsch berechnet wurde und Termine sich visuell überdeckten
493 lines
16 KiB
Python
493 lines
16 KiB
Python
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
|
|
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 https://www.googleapis.com/auth/userinfo.email"
|
|
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"
|
|
|
|
# System/virtual Google calendars that should never be shown
|
|
SKIP_GOOGLE_CALENDAR_IDS = {
|
|
"#weekNum@group.v.calendar.google.com", # Kalenderwochen / Week numbers
|
|
}
|
|
|
|
|
|
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, gcal_db_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://{gcal_db_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-{gcal_db_id}",
|
|
"calendar_name": cal_name,
|
|
"calendarColor": cal_color,
|
|
"source": "google",
|
|
}
|
|
|
|
|
|
def _account_dict(a: models.GoogleAccount) -> dict:
|
|
return {
|
|
"id": a.id,
|
|
"email": a.email,
|
|
"calendars": [
|
|
{
|
|
"id": c.id,
|
|
"name": c.name,
|
|
"color": c.color or "#4285f4",
|
|
"enabled": c.enabled,
|
|
"sidebar_hidden": bool(c.sidebar_hidden),
|
|
}
|
|
for c in a.calendars
|
|
if c.cal_id not in SKIP_GOOGLE_CALENDAR_IDS
|
|
],
|
|
}
|
|
|
|
|
|
def _sync_google_calendars(account: models.GoogleAccount, db: Session):
|
|
"""Fetch calendar list from Google and persist/update GoogleCalendar records."""
|
|
try:
|
|
token = _refresh_access_token(account, db)
|
|
cal_list = _google_api(token, "/users/me/calendarList")
|
|
existing = {c.cal_id: c for c in account.calendars}
|
|
for cal in cal_list.get("items", []):
|
|
if cal.get("deleted"):
|
|
continue
|
|
cal_id = cal["id"]
|
|
if cal_id in SKIP_GOOGLE_CALENDAR_IDS:
|
|
continue
|
|
if cal_id not in existing:
|
|
db.add(models.GoogleCalendar(
|
|
account_id=account.id,
|
|
cal_id=cal_id,
|
|
name=cal.get("summary", cal_id),
|
|
color=cal.get("backgroundColor", "#4285f4"),
|
|
enabled=True,
|
|
))
|
|
else:
|
|
existing[cal_id].name = cal.get("summary", cal_id)
|
|
if not existing[cal_id].color:
|
|
existing[cal_id].color = cal.get("backgroundColor", "#4285f4")
|
|
db.commit()
|
|
db.refresh(account)
|
|
except Exception as exc:
|
|
logger.error("Error syncing Google calendars for %s: %s", account.email, exc)
|
|
|
|
|
|
# ── 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
|
|
)
|
|
db.commit()
|
|
account = existing
|
|
else:
|
|
account = 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.add(account)
|
|
db.commit()
|
|
db.refresh(account)
|
|
|
|
# Sync calendars after connecting/reconnecting
|
|
_sync_google_calendars(account, db)
|
|
|
|
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()
|
|
)
|
|
# Auto-sync calendars for accounts that have none yet
|
|
for acc in accounts:
|
|
if not acc.calendars:
|
|
_sync_google_calendars(acc, db)
|
|
return [_account_dict(a) 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}
|
|
|
|
|
|
@router.post("/accounts/{account_id}/sync")
|
|
def sync_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")
|
|
_sync_google_calendars(acc, db)
|
|
db.refresh(acc)
|
|
return _account_dict(acc)
|
|
|
|
|
|
# ── Calendar Management ──────────────────────────────────
|
|
|
|
class GoogleCalendarUpdate(BaseModel):
|
|
enabled: Optional[bool] = None
|
|
color: Optional[str] = None
|
|
name: Optional[str] = None
|
|
sidebar_hidden: Optional[bool] = None
|
|
|
|
|
|
@router.put("/calendars/{calendar_id}")
|
|
def update_calendar(
|
|
calendar_id: int,
|
|
data: GoogleCalendarUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user),
|
|
):
|
|
gcal = (
|
|
db.query(models.GoogleCalendar)
|
|
.join(models.GoogleAccount)
|
|
.filter(
|
|
models.GoogleCalendar.id == calendar_id,
|
|
models.GoogleAccount.user_id == current_user.id,
|
|
)
|
|
.first()
|
|
)
|
|
if not gcal:
|
|
raise HTTPException(404, "Calendar not found")
|
|
if data.enabled is not None:
|
|
gcal.enabled = data.enabled
|
|
if data.color is not None:
|
|
gcal.color = data.color
|
|
if data.name is not None:
|
|
gcal.name = data.name
|
|
if data.sidebar_hidden is not None:
|
|
gcal.sidebar_hidden = data.sidebar_hidden
|
|
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 enabled Google calendars for an account."""
|
|
try:
|
|
token = _refresh_access_token(account, db)
|
|
except Exception:
|
|
return []
|
|
|
|
all_events = []
|
|
try:
|
|
for gcal in account.calendars:
|
|
if not gcal.enabled:
|
|
continue
|
|
if gcal.cal_id in SKIP_GOOGLE_CALENDAR_IDS:
|
|
continue
|
|
events_resp = _google_api(token, f"/calendars/{gcal.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, gcal.id, gcal.name, gcal.color or "#4285f4"))
|
|
except Exception as exc:
|
|
logger.error("Error fetching Google Calendar for %s: %s", account.email, exc)
|
|
|
|
return all_events
|
|
|
|
|
|
class GoogleEventCreate(BaseModel):
|
|
calendar_db_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),
|
|
):
|
|
gcal = (
|
|
db.query(models.GoogleCalendar)
|
|
.join(models.GoogleAccount)
|
|
.filter(
|
|
models.GoogleCalendar.id == data.calendar_db_id,
|
|
models.GoogleAccount.user_id == current_user.id,
|
|
)
|
|
.first()
|
|
)
|
|
if not gcal:
|
|
raise HTTPException(404, "Google calendar not found")
|
|
|
|
token = _refresh_access_token(gcal.account, db)
|
|
body = _build_event_body(data.model_dump())
|
|
result = _google_api(token, f"/calendars/{gcal.cal_id}/events", method="POST", json_body=body)
|
|
return {"id": result.get("id"), "ok": True}
|
|
|
|
|
|
@router.put("/events/{gcal_db_id}/{event_id}")
|
|
def update_event(
|
|
gcal_db_id: int,
|
|
event_id: str,
|
|
data: GoogleEventUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user),
|
|
):
|
|
gcal = (
|
|
db.query(models.GoogleCalendar)
|
|
.join(models.GoogleAccount)
|
|
.filter(
|
|
models.GoogleCalendar.id == gcal_db_id,
|
|
models.GoogleAccount.user_id == current_user.id,
|
|
)
|
|
.first()
|
|
)
|
|
if not gcal:
|
|
raise HTTPException(404, "Google calendar not found")
|
|
|
|
token = _refresh_access_token(gcal.account, db)
|
|
body = _build_event_body(data.model_dump(exclude_none=True))
|
|
_google_api(token, f"/calendars/{gcal.cal_id}/events/{event_id}", method="PATCH", json_body=body)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/events/{gcal_db_id}/{event_id}")
|
|
def delete_event(
|
|
gcal_db_id: int,
|
|
event_id: str,
|
|
db: Session = Depends(get_db),
|
|
current_user: models.User = Depends(get_current_user),
|
|
):
|
|
gcal = (
|
|
db.query(models.GoogleCalendar)
|
|
.join(models.GoogleAccount)
|
|
.filter(
|
|
models.GoogleCalendar.id == gcal_db_id,
|
|
models.GoogleAccount.user_id == current_user.id,
|
|
)
|
|
.first()
|
|
)
|
|
if not gcal:
|
|
raise HTTPException(404, "Google calendar not found")
|
|
|
|
token = _refresh_access_token(gcal.account, db)
|
|
_google_api(token, f"/calendars/{gcal.cal_id}/events/{event_id}", method="DELETE")
|
|
return {"ok": True}
|