diff --git a/backend/models.py b/backend/models.py index eec4771..55920db 100644 --- a/backend/models.py +++ b/backend/models.py @@ -154,3 +154,19 @@ class GoogleAccount(Base): token_expiry = Column(DateTime, nullable=True) user = relationship("User", back_populates="google_accounts") + calendars = relationship( + "GoogleCalendar", back_populates="account", cascade="all, delete-orphan" + ) + + +class GoogleCalendar(Base): + __tablename__ = "google_calendars" + + id = Column(Integer, primary_key=True, index=True) + account_id = Column(Integer, ForeignKey("google_accounts.id"), nullable=False) + cal_id = Column(String(500), nullable=False) + name = Column(String(255), nullable=False) + color = Column(String(7), nullable=True) + enabled = Column(Boolean, default=True) + + account = relationship("GoogleAccount", back_populates="calendars") diff --git a/backend/routers/google_router.py b/backend/routers/google_router.py index 7312007..e2a7e03 100644 --- a/backend/routers/google_router.py +++ b/backend/routers/google_router.py @@ -5,7 +5,7 @@ from typing import Optional from urllib.parse import urlencode import requests as http_requests -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import RedirectResponse from pydantic import BaseModel from sqlalchemy.orm import Session @@ -98,7 +98,7 @@ def _build_event_body(data: dict) -> dict: return body -def _parse_google_event(ev: dict, account_id: int, cal_name: str, cal_color: str) -> dict: +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", {}) @@ -106,7 +106,7 @@ def _parse_google_event(ev: dict, account_id: int, cal_name: str, cal_color: str return { "id": ev["id"], - "url": f"google://{account_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", ""), @@ -114,13 +114,57 @@ def _parse_google_event(ev: dict, account_id: int, cal_name: str, cal_color: str "location": ev.get("location", ""), "description": ev.get("description", ""), "color": None, - "calendar_id": f"google-{account_id}", + "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, + } + for c in a.calendars + ], + } + + +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 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") @@ -198,8 +242,10 @@ def oauth_callback( existing.token_expiry = datetime.fromtimestamp( datetime.now(timezone.utc).timestamp() + expires_in, tz=timezone.utc ) + db.commit() + account = existing else: - db.add(models.GoogleAccount( + account = models.GoogleAccount( user_id=user_id, email=email, access_token=access_token, @@ -207,10 +253,14 @@ def oauth_callback( token_expiry=datetime.fromtimestamp( datetime.now(timezone.utc).timestamp() + expires_in, tz=timezone.utc ), - )) - db.commit() + ) + db.add(account) + db.commit() + db.refresh(account) + + # Sync calendars after connecting/reconnecting + _sync_google_calendars(account, db) - # Redirect back to app return RedirectResponse(url="/", status_code=302) @@ -226,7 +276,11 @@ def list_accounts( .filter(models.GoogleAccount.user_id == current_user.id) .all() ) - return [{"id": a.id, "email": a.email} for a in accounts] + # 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}") @@ -247,10 +301,64 @@ def delete_account( 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 + + +@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 + 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.""" + """Fetch events from all enabled Google calendars for an account.""" try: token = _refresh_access_token(account, db) except Exception: @@ -258,15 +366,10 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: all_events = [] try: - cal_list = _google_api(token, "/users/me/calendarList") - for cal in cal_list.get("items", []): - if cal.get("deleted"): + for gcal in account.calendars: + if not gcal.enabled: 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={ + events_resp = _google_api(token, f"/calendars/{gcal.cal_id}/events", params={ "timeMin": start_dt.isoformat(), "timeMax": end_dt.isoformat(), "singleEvents": "true", @@ -275,7 +378,7 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: 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)) + 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) @@ -283,7 +386,7 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt: class GoogleEventCreate(BaseModel): - account_id: int + calendar_db_id: int title: str start: str end: str @@ -307,57 +410,69 @@ def create_event( 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) + 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 acc: - raise HTTPException(404, "Google account not found") + if not gcal: + raise HTTPException(404, "Google calendar not found") - token = _refresh_access_token(acc, db) + token = _refresh_access_token(gcal.account, db) body = _build_event_body(data.model_dump()) - result = _google_api(token, "/calendars/primary/events", method="POST", json_body=body) + 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/{account_id}/{event_id}") +@router.put("/events/{gcal_db_id}/{event_id}") def update_event( - account_id: int, + gcal_db_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) + 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 acc: - raise HTTPException(404, "Google account not found") + if not gcal: + raise HTTPException(404, "Google calendar not found") - token = _refresh_access_token(acc, db) + token = _refresh_access_token(gcal.account, 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) + _google_api(token, f"/calendars/{gcal.cal_id}/events/{event_id}", method="PATCH", json_body=body) return {"ok": True} -@router.delete("/events/{account_id}/{event_id}") +@router.delete("/events/{gcal_db_id}/{event_id}") def delete_event( - account_id: int, + gcal_db_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) + 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 acc: - raise HTTPException(404, "Google account not found") + if not gcal: + raise HTTPException(404, "Google calendar not found") - token = _refresh_access_token(acc, db) - _google_api(token, f"/calendars/primary/events/{event_id}", method="DELETE") + token = _refresh_access_token(gcal.account, db) + _google_api(token, f"/calendars/{gcal.cal_id}/events/{event_id}", method="DELETE") return {"ok": True} diff --git a/frontend/index.html b/frontend/index.html index f197327..0cf1c27 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -428,6 +428,11 @@ +
+

Google Konten

+
Keine Google-Konten verbunden
+
+

Ausgeblendete Kalender

Keine ausgeblendeten Kalender
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index f7ad0d0..ac402e2 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -310,16 +310,21 @@ function renderCalendarList() { // ── Google accounts ─────────────────────────────────── if (state.googleAccounts.length) { - html += `
Google Kalender
`; - html += state.googleAccounts.map(acc => - `
-
- ${escHtml(acc.email)} - -
` - ).join(''); + html += state.googleAccounts.map(acc => { + const visibleCals = acc.calendars.filter(c => !c._hidden); + if (!visibleCals.length) return `
${escHtml(acc.email)}
`; + return `
${escHtml(acc.email)}
` + + visibleCals.map(cal => + `
+ +
+ ${escHtml(cal.name)} + +
` + ).join(''); + }).join(''); } if (!html) { @@ -351,6 +356,13 @@ function renderCalendarList() { await api.put(`/ical/subscriptions/${subId}`, { enabled: cb.checked }); const sub = state.icalSubscriptions.find(s => s.id === subId); if (sub) sub.enabled = cb.checked; + } else if (source === 'google') { + const calId = parseInt(cb.dataset.calId); + await api.put(`/google/calendars/${calId}`, { enabled: cb.checked }); + for (const acc of state.googleAccounts) { + const cal = acc.calendars.find(c => c.id === calId); + if (cal) cal.enabled = cb.checked; + } } fetchAndRender(); }); @@ -383,6 +395,20 @@ function renderCalendarList() { renderCalendarList(); fetchAndRender(); } + } else if (source === 'google') { + const calId = parseInt(dot.dataset.calId); + let gcal = null; + for (const acc of state.googleAccounts) { + gcal = acc.calendars.find(c => c.id === calId); + if (gcal) break; + } + const picked = await openColorPicker(dot, gcal?.color || '#4285f4'); + if (picked) { + await api.put(`/google/calendars/${calId}`, { color: picked }); + if (gcal) gcal.color = picked; + renderCalendarList(); + fetchAndRender(); + } } }); }); @@ -458,10 +484,13 @@ function renderCalendarList() { 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); + const calId = parseInt(btn.dataset.calId); + await api.put(`/google/calendars/${calId}`, { enabled: false }); + for (const acc of state.googleAccounts) { + for (const cal of acc.calendars) { + if (cal.id === calId) { cal.enabled = false; cal._hidden = true; } + } + } } renderCalendarList(); fetchAndRender(); @@ -650,11 +679,13 @@ function populateCalendarSelect(selectedId) { // 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); + acc.calendars.filter(c => c.enabled).forEach(cal => { + const opt = document.createElement('option'); + opt.value = `google-${cal.id}`; + opt.textContent = `${acc.email} / ${cal.name}`; + if (`google-${cal.id}` === selectedId) opt.selected = true; + sel.appendChild(opt); + }); }); } @@ -798,9 +829,9 @@ function bindEventModal() { } showToast('Termin aktualisiert'); } else if (isGoogle) { - const accId = parseInt(calVal.replace('google-', '')); + const calDbId = parseInt(calVal.replace('google-', '')); await api.post('/google/events', { - account_id: accId, title, start, end, allDay, + calendar_db_id: calDbId, title, start, end, allDay, location: loc, description: desc, }); showToast('Termin erstellt'); @@ -1022,18 +1053,72 @@ function openSettingsModal() { usersSection.classList.add('hidden'); } + // Render Google accounts + renderGoogleAccounts(); + // Render hidden calendars renderHiddenCalendars(); openModal('modal-settings'); } +function renderGoogleAccounts() { + const list = document.getElementById('google-accounts-list'); + if (!list) return; + if (!state.googleAccounts.length) { + list.innerHTML = 'Keine Google-Konten verbunden'; + return; + } + list.innerHTML = state.googleAccounts.map(acc => + `
+ ${escHtml(acc.email)} +
+ + +
+
` + ).join(''); + list.querySelectorAll('[data-sync-acc]').forEach(btn => { + btn.addEventListener('click', async () => { + btn.disabled = true; + btn.textContent = '…'; + try { + const updated = await api.post(`/google/accounts/${btn.dataset.syncAcc}/sync`); + const idx = state.googleAccounts.findIndex(a => a.id === updated.id); + if (idx !== -1) state.googleAccounts[idx] = updated; + renderGoogleAccounts(); + renderCalendarList(); + fetchAndRender(); + showToast('Kalender synchronisiert'); + } catch (e) { showToast(e.message, true); } + }); + }); + list.querySelectorAll('[data-disconnect-acc]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Google-Konto wirklich trennen?')) return; + try { + await api.delete(`/google/accounts/${btn.dataset.disconnectAcc}`); + state.googleAccounts = state.googleAccounts.filter(a => a.id !== parseInt(btn.dataset.disconnectAcc)); + renderGoogleAccounts(); + renderCalendarList(); + fetchAndRender(); + showToast('Google-Konto getrennt'); + } catch (e) { showToast(e.message, true); } + }); + }); +} + 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 (!cal.enabled || cal._hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.name, source: 'caldav' }); + } + } + for (const acc of state.googleAccounts) { + for (const cal of acc.calendars) { + if (!cal.enabled || cal._hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.email, source: 'google' }); } } if (!hidden.length) { @@ -1043,16 +1128,26 @@ function renderHiddenCalendars() { 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; } + const source = btn.dataset.restoreSource; + if (source === 'google') { + await api.put(`/google/calendars/${calId}`, { enabled: true }); + for (const acc of state.googleAccounts) { + for (const cal of acc.calendars) { + if (cal.id === calId) { cal.enabled = true; delete cal._hidden; } + } + } + } else { + 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();