mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-21 20:25:23 -04:00
feat(calendar): support multiple CalDAV accounts (#2942)
* feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
This commit is contained in:
+49
-21
@@ -23,11 +23,10 @@ from datetime import timezone
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _stable_cal_id(remote_url: str) -> str:
|
||||
# Reuse the sync module's hashing so a local CalDAV calendar id maps back to
|
||||
# the same remote URL it was pulled from.
|
||||
def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
|
||||
# Reuse the sync module's hashing so owner+account_id scoping stays consistent.
|
||||
from src.caldav_sync import _stable_cal_id as _sync_id
|
||||
return _sync_id(remote_url)
|
||||
return _sync_id(remote_url, owner=owner, account_id=account_id)
|
||||
|
||||
|
||||
def build_event_ical(ev: dict) -> str:
|
||||
@@ -76,28 +75,34 @@ def build_event_ical(ev: dict) -> str:
|
||||
return cal.to_ical().decode("utf-8")
|
||||
|
||||
|
||||
def find_remote_calendar(calendars, local_cal_id: str):
|
||||
"""Find the remote calendar whose URL hashes to ``local_cal_id``, or None."""
|
||||
def find_remote_calendar(calendars, local_cal_id: str, owner: str = "", account_id: str = ""):
|
||||
"""Find the remote calendar whose URL hashes to ``local_cal_id``, or None.
|
||||
|
||||
``owner`` and ``account_id`` must match what was used when the local calendar
|
||||
id was originally computed in ``_sync_blocking`` so the hash round-trips."""
|
||||
for cal in calendars:
|
||||
try:
|
||||
if _stable_cal_id(str(cal.url)) == local_cal_id:
|
||||
if _stable_cal_id(str(cal.url), owner=owner, account_id=account_id) == local_cal_id:
|
||||
return cal
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False) -> dict:
|
||||
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
||||
owner: str = "", account_id: str = "") -> dict:
|
||||
"""Create/update (or delete) ``ev`` on the matching remote calendar.
|
||||
|
||||
Returns ``{"ok": bool, ...}``. ``calendars`` is the discovered caldav
|
||||
calendar list (injected so this is unit-testable with fakes).
|
||||
``owner`` and ``account_id`` are forwarded to ``find_remote_calendar``
|
||||
so the URL hash round-trips correctly (#2765).
|
||||
"""
|
||||
uid = (ev or {}).get("uid") if isinstance(ev, dict) else None
|
||||
if not uid:
|
||||
return {"ok": False, "error": "event uid is required"}
|
||||
|
||||
remote = find_remote_calendar(calendars, local_cal_id)
|
||||
remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
|
||||
if remote is None:
|
||||
return {"ok": False, "error": "remote calendar not found"}
|
||||
|
||||
@@ -136,13 +141,15 @@ def _discover_calendars(client):
|
||||
return []
|
||||
|
||||
|
||||
def _writeback_blocking(local_cal_id, ev, delete, url, username, password) -> dict:
|
||||
def _writeback_blocking(local_cal_id, ev, delete, url, username, password,
|
||||
owner="", account_id="") -> dict:
|
||||
import caldav
|
||||
client = caldav.DAVClient(url=url, username=username, password=password)
|
||||
calendars = _discover_calendars(client)
|
||||
if not calendars:
|
||||
return {"ok": False, "error": "no remote calendars discovered"}
|
||||
return push_event(calendars, local_cal_id, ev, delete=delete)
|
||||
return push_event(calendars, local_cal_id, ev, delete=delete,
|
||||
owner=owner, account_id=account_id)
|
||||
|
||||
|
||||
async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
|
||||
@@ -156,24 +163,45 @@ async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
|
||||
if calendar_source != "caldav":
|
||||
return {"skipped": "not a caldav calendar"}
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user
|
||||
from src.caldav_sync import _load_caldav_accounts
|
||||
from src.secret_storage import decrypt
|
||||
cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {}
|
||||
url = (cfg.get("url") or "").strip()
|
||||
user = (cfg.get("username") or "").strip()
|
||||
# Stored encrypted by routes/calendar_routes; decrypt before use so
|
||||
# the remote sees the real password (decrypt is a no-op on legacy
|
||||
# plaintext). The pull path src/caldav_sync.py already does this.
|
||||
pw = decrypt(cfg.get("password") or "")
|
||||
if not (url and user and pw):
|
||||
from core.database import CalendarCal, SessionLocal
|
||||
|
||||
accounts = _load_caldav_accounts(owner)
|
||||
if not accounts:
|
||||
return {"skipped": "caldav not configured"}
|
||||
|
||||
# Find which account owns this calendar.
|
||||
acc = None
|
||||
if len(accounts) > 1:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cal_row = db.query(CalendarCal).filter(CalendarCal.id == calendar_id).first()
|
||||
cal_account_id = cal_row.account_id if cal_row else None
|
||||
finally:
|
||||
db.close()
|
||||
if cal_account_id:
|
||||
acc = next((a for a in accounts if a.get("id") == cal_account_id), None)
|
||||
# Fall back to first account (covers single-account and legacy rows with
|
||||
# no account_id stamped).
|
||||
if acc is None:
|
||||
acc = accounts[0]
|
||||
|
||||
url = (acc.get("url") or "").strip()
|
||||
user = (acc.get("username") or "").strip()
|
||||
pw = decrypt(acc.get("password") or "")
|
||||
if not (url and user and pw):
|
||||
return {"skipped": "caldav account credentials incomplete"}
|
||||
from src.caldav_sync import validate_caldav_url
|
||||
try:
|
||||
url = validate_caldav_url(url)
|
||||
except ValueError as e:
|
||||
logger.warning("CalDAV write-back URL rejected: %s", e)
|
||||
return {"ok": False, "error": str(e)[:200]}
|
||||
result = await asyncio.to_thread(_writeback_blocking, calendar_id, ev, delete, url, user, pw)
|
||||
acc_id = acc.get("id") or ""
|
||||
result = await asyncio.to_thread(
|
||||
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id
|
||||
)
|
||||
if not result.get("ok"):
|
||||
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user