diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index a4a9a5d..d4e16c8 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -386,8 +386,17 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict parsed = ical_io.parse_ics(raw) imported = 0 skipped = 0 + errors = list(parsed["errors"]) + # local_events.uid is globally unique. Dedupe against the DB AND within this + # file — e.g. Nextcloud exports recurring events as several VEVENTs sharing a + # UID (RECURRENCE-ID overrides), which would otherwise violate the constraint. + seen_uids: set[str] = set() for item in parsed["events"]: uid = item.get("uid") or str(uuid.uuid4()) + if uid in seen_uids: + skipped += 1 + continue + seen_uids.add(uid) existing = db.query(models.LocalEvent).filter(models.LocalEvent.uid == uid).first() if existing: skipped += 1 @@ -407,8 +416,12 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict ) db.add(ev) imported += 1 - db.commit() - return {"imported": imported, "skipped": skipped, "errors": parsed["errors"]} + try: + db.commit() + except Exception as exc: + db.rollback() + raise ValueError(f"Import fehlgeschlagen: {exc}") + return {"imported": imported, "skipped": skipped, "errors": errors} @router.post("/calendars/{calendar_id}/import") diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py index aa22346..a93bae5 100644 --- a/backend/tests/test_collaboration.py +++ b/backend/tests/test_collaboration.py @@ -289,6 +289,39 @@ def test_import_dedupes_by_uid(client): assert imported and imported[0]["creator"]["display_name"] == "Max Mustermann (importiert)" +DUP_UID_ICS = b"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nextcloud +BEGIN:VEVENT +UID:recurring@nc +SUMMARY:Standup +DTSTART:20260601T090000Z +DTEND:20260601T091500Z +RRULE:FREQ=WEEKLY;BYDAY=MO +END:VEVENT +BEGIN:VEVENT +UID:recurring@nc +RECURRENCE-ID:20260608T090000Z +SUMMARY:Standup verschoben +DTSTART:20260608T100000Z +DTEND:20260608T101500Z +END:VEVENT +END:VCALENDAR +""" + + +def test_import_handles_duplicate_uid_in_file(client): + """Nextcloud exports recurring events as multiple VEVENTs sharing a UID; + importing must not 500 on the unique constraint.""" + admin = register_admin(client) + cal_id = _make_calendar(client, admin, "NC") + files = {"file": ("nc.ics", DUP_UID_ICS, "text/calendar")} + r = client.post(f"/api/local/calendars/{cal_id}/import", headers=auth(admin), files=files) + assert r.status_code == 200, r.text + body = r.json() + assert body["imported"] == 1 and body["skipped"] == 1 + + def test_export_contains_organizer_and_rrule(client): admin = register_admin(client) cal_id = _make_calendar(client, admin, "Export-Test") diff --git a/frontend/css/app.css b/frontend/css/app.css index b1b3629..c20b8e9 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1859,3 +1859,36 @@ a { color: var(--primary); text-decoration: none; } .cal-radio-item:last-child { border-bottom: none; } .cal-radio-item:hover { background: var(--bg-surface); } .cal-radio-item .cal-item-dot { border-radius: 50%; flex: 0 0 auto; } + +/* Picker rows (group-visible calendar radio + group member checkboxes). + Deliberately NOT