diff --git a/backend/main.py b/backend/main.py index 9092c6f..8834467 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") diff --git a/backend/models.py b/backend/models.py index 9f69af0..eec4771 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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") diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index f8a0a60..67b851b 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -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 diff --git a/backend/routers/google_router.py b/backend/routers/google_router.py new file mode 100644 index 0000000..42f908d --- /dev/null +++ b/backend/routers/google_router.py @@ -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} diff --git a/frontend/index.html b/frontend/index.html index dd7e419..f197327 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -159,6 +159,7 @@ + @@ -427,6 +428,11 @@ +
+