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 @@ +
+

Ausgeblendete Kalender

+
Keine ausgeblendeten Kalender
+
+

Benutzerverwaltung Admin

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index b5c9234..f7ad0d0 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -29,6 +29,7 @@ let state = { accounts: [], localCalendars: [], icalSubscriptions: [], + googleAccounts: [], settings: {}, dimPast: false, editingEvent: null, // null = new event @@ -37,17 +38,19 @@ let state = { // ── Public init ─────────────────────────────────────────── export async function initCalendar() { - const [settings, accounts, localCalendars, icalSubscriptions] = await Promise.all([ + const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts] = await Promise.all([ api.get('/settings/'), api.get('/caldav/accounts'), - api.get('/local/calendars'), - api.get('/ical/subscriptions'), + api.get('/local/calendars').catch(() => []), + api.get('/ical/subscriptions').catch(() => []), + api.get('/google/accounts').catch(() => []), ]); state.settings = settings; state.accounts = accounts; state.localCalendars = localCalendars; state.icalSubscriptions = icalSubscriptions; + state.googleAccounts = googleAccounts; state.currentView = settings.default_view || 'month'; state.dimPast = settings.dim_past_events; weekStartDay = settings.week_start_day || 'monday'; @@ -258,19 +261,21 @@ function renderCalendarList() { // ── CalDAV accounts ──────────────────────────────────── if (state.accounts.length) { - html += state.accounts.map(acc => - `
${escHtml(acc.name)}
` + - acc.calendars.map(cal => - `
- -
- ${escHtml(cal.name)} - -
` - ).join('') - ).join(''); + html += state.accounts.map(acc => { + const visibleCals = acc.calendars.filter(c => !c._hidden); + if (!visibleCals.length) return ''; + return `
${escHtml(acc.name)}
` + + visibleCals.map(cal => + `
+ +
+ ${escHtml(cal.name)} + +
` + ).join(''); + }).join(''); } // ── Local calendars ──────────────────────────────────── @@ -303,6 +308,20 @@ function renderCalendarList() { ).join(''); } + // ── Google accounts ─────────────────────────────────── + if (state.googleAccounts.length) { + html += `
Google Kalender
`; + html += state.googleAccounts.map(acc => + `
+
+ ${escHtml(acc.email)} + +
` + ).join(''); + } + if (!html) { container.innerHTML = `
Keine Kalender
`; return; @@ -421,10 +440,13 @@ function renderCalendarList() { e.stopPropagation(); const source = btn.dataset.source; if (source === 'caldav') { - if (!confirm('CalDAV-Konto wirklich entfernen?')) return; - const accId = parseInt(btn.dataset.accId); - await api.delete(`/caldav/accounts/${accId}`); - state.accounts = state.accounts.filter(a => a.id !== accId); + const calId = parseInt(btn.dataset.calId); + await api.put(`/caldav/calendars/${calId}`, { enabled: false }); + for (const acc of state.accounts) { + for (const cal of acc.calendars) { + if (cal.id === calId) { cal.enabled = false; cal._hidden = true; } + } + } } else if (source === 'local') { if (!confirm('Lokalen Kalender wirklich löschen?')) return; const calId = parseInt(btn.dataset.calId); @@ -435,6 +457,11 @@ function renderCalendarList() { const subId = parseInt(btn.dataset.subId); await api.delete(`/ical/subscriptions/${subId}`); state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId); + } else if (source === 'google') { + if (!confirm('Google-Konto wirklich entfernen?')) return; + const accId = parseInt(btn.dataset.accId); + await api.delete(`/google/accounts/${accId}`); + state.googleAccounts = state.googleAccounts.filter(a => a.id !== accId); } renderCalendarList(); fetchAndRender(); @@ -513,6 +540,20 @@ function bindSidebar() { dropdown.classList.add('hidden'); openICalSubModal(); }; + dropdown.querySelector('[data-action="google"]').onclick = async () => { + dropdown.classList.add('hidden'); + try { + const { configured } = await api.get('/google/configured'); + if (!configured) { + showToast('Google OAuth ist nicht konfiguriert (Admin muss GOOGLE_CLIENT_ID/SECRET setzen)', true); + return; + } + const { url } = await api.get('/google/auth-url'); + window.location.href = url; + } catch (e) { + showToast('Fehler: ' + e.message, true); + } + }; } // ── Event Popup ─────────────────────────────────────────── @@ -558,7 +599,10 @@ function showEventPopup(ev, anchor) { if (!confirm(`"${ev.title}" wirklich löschen?`)) return; popup.classList.add('hidden'); try { - if (ev.source === 'local') { + if (ev.source === 'google') { + const accId = ev.calendar_id.replace('google-', ''); + await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`); + } else if (ev.source === 'local') { await api.delete(`/local/events/${encodeURIComponent(ev.id)}`); } else if (ev.source === 'ical') { const subId = ev.calendar_id.replace('ical-', ''); @@ -604,6 +648,14 @@ function populateCalendarSelect(selectedId) { sel.appendChild(opt); }); // iCal subscriptions are read-only, not shown here + // Google calendars (read/write) + state.googleAccounts.forEach(acc => { + const opt = document.createElement('option'); + opt.value = `google-${acc.id}`; + opt.textContent = `Google / ${acc.email}`; + if (`google-${acc.id}` === selectedId) opt.selected = true; + sel.appendChild(opt); + }); } function openNewEventModal(date) { @@ -702,6 +754,7 @@ function bindEventModal() { const allDay = document.getElementById('ev-allday').checked; const calVal = document.getElementById('ev-calendar').value; const isLocal = calVal.startsWith('local-'); + const isGoogle = calVal.startsWith('google-'); const loc = document.getElementById('ev-location').value.trim(); const desc = document.getElementById('ev-description').value.trim(); const color = state.selectedEventColor; @@ -723,7 +776,12 @@ function bindEventModal() { try { if (state.editingEvent) { const ev = state.editingEvent; - if (ev.source === 'local') { + if (ev.source === 'google') { + const accId = ev.calendar_id.replace('google-', ''); + await api.put(`/google/events/${accId}/${encodeURIComponent(ev.id)}`, + { title, start, end, allDay, location: loc, description: desc } + ); + } else if (ev.source === 'local') { await api.put(`/local/events/${encodeURIComponent(ev.id)}`, { title, start, end, allDay, location: loc, description: desc, color: color || null } ); @@ -739,6 +797,13 @@ function bindEventModal() { ); } showToast('Termin aktualisiert'); + } else if (isGoogle) { + const accId = parseInt(calVal.replace('google-', '')); + await api.post('/google/events', { + account_id: accId, title, start, end, allDay, + location: loc, description: desc, + }); + showToast('Termin erstellt'); } else if (isLocal) { const calId = parseInt(calVal.replace('local-', '')); await api.post('/local/events', { @@ -766,7 +831,10 @@ function bindEventModal() { if (!ev) return; if (!confirm(`"${ev.title}" wirklich löschen?`)) return; try { - if (ev.source === 'local') { + if (ev.source === 'google') { + const accId = ev.calendar_id.replace('google-', ''); + await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`); + } else if (ev.source === 'local') { await api.delete(`/local/events/${encodeURIComponent(ev.id)}`); } else if (ev.source === 'ical') { const subId = ev.calendar_id.replace('ical-', ''); @@ -954,9 +1022,46 @@ function openSettingsModal() { usersSection.classList.add('hidden'); } + // Render hidden calendars + renderHiddenCalendars(); + openModal('modal-settings'); } +function renderHiddenCalendars() { + const list = document.getElementById('hidden-cals-list'); + const hidden = []; + for (const acc of state.accounts) { + for (const cal of acc.calendars) { + if (!cal.enabled || cal._hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.name }); + } + } + if (!hidden.length) { + list.innerHTML = 'Keine ausgeblendeten Kalender'; + return; + } + list.innerHTML = hidden.map(c => + `
+ ${escHtml(c.acc)} / ${escHtml(c.name)} + +
` + ).join(''); + list.querySelectorAll('[data-restore-cal]').forEach(btn => { + btn.addEventListener('click', async () => { + const calId = parseInt(btn.dataset.restoreCal); + await api.put(`/caldav/calendars/${calId}`, { enabled: true }); + for (const acc of state.accounts) { + for (const cal of acc.calendars) { + if (cal.id === calId) { cal.enabled = true; delete cal._hidden; } + } + } + renderHiddenCalendars(); + renderCalendarList(); + fetchAndRender(); + }); + }); +} + async function loadUsers() { try { const users = await api.get('/users/');