From b885e731ee9bb9249b6f8a8cdaff13ba97c43f8d Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 14 Sep 2026 10:11:30 +0200 Subject: [PATCH] feat(anhaenge): attachment_count im Termin-JSON Die Terminliste traegt nur die Anzahl, nicht die Anhaenge selbst. Der Merge-Read laeuft pro Termin durch eine Schleife -- eine Lazy-Relationship waere dort eine Abfrage je Termin ueber eine ganze Monatsansicht, und ein wiederkehrender Termin wuerde dieselbe Liste an jeder Instanz wiederholen. Stattdessen je Kalender eine gruppierte Abfrage; die Liste holen die Clients beim Oeffnen der Detailansicht. mask_busy_event setzt die Zahl ausdruecklich auf 0: dass ein als "Beschaeftigt" maskierter Termin drei Dateien haette, ist auch eine Information. Co-Authored-By: Claude Opus 5 (1M context) --- backend/local_events_util.py | 16 +++++++-- backend/routers/caldav_router.py | 14 ++++++-- backend/routers/groups_router.py | 5 +-- backend/routers/local_router.py | 6 +++- backend/tests/test_attachments.py | 55 +++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 8 deletions(-) diff --git a/backend/local_events_util.py b/backend/local_events_util.py index c45e6d4..28ca03a 100644 --- a/backend/local_events_util.py +++ b/backend/local_events_util.py @@ -71,6 +71,9 @@ def mask_busy_event(event: dict) -> dict: masked["rrule"] = None masked["exdate"] = None masked["private"] = True + # "This busy block has 3 files" is a small but real leak, and _BUSY_KEEP + # would drop the key entirely — set it so clients get a defined value. + masked["attachment_count"] = 0 return masked @@ -103,6 +106,7 @@ def build_local_event_dict( owner: Optional[dict] = None, is_group_event: bool = False, read_only: bool = False, + attachment_count: int = 0, ) -> dict: """Build the unified dict for a single local event (or occurrence). @@ -131,6 +135,11 @@ def build_local_event_dict( "creator": creator, "private": bool(ev.is_private), "reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()], + # Only a COUNT, never the list: the merged read loops over every event, + # so a lazy relationship here would be one query per event across a + # whole month, and a recurring event would repeat the same array on + # every occurrence. Clients fetch the list when a detail view opens. + "attachment_count": attachment_count, } # Birthday calendars: the server owns the presentation. It flags the event so # clients can show a cake icon, appends the age computed for THIS occurrence @@ -170,6 +179,7 @@ def expand_recurring_local( owner: Optional[dict] = None, is_group_event: bool = False, read_only: bool = False, + attachment_count: int = 0, ) -> list: """Expand a recurring LocalEvent into individual occurrences in the range.""" results = [] @@ -201,7 +211,7 @@ def expand_recurring_local( ev, local_cal, start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True, creator=creator, owner=owner, is_group_event=is_group_event, - read_only=read_only, + read_only=read_only, attachment_count=attachment_count, )) else: ev_start = dt_datetime.fromisoformat(ev_start_str) @@ -228,13 +238,13 @@ def expand_recurring_local( ev, local_cal, start=occ.isoformat(), end=occ_end.isoformat(), all_day=False, creator=creator, owner=owner, is_group_event=is_group_event, - read_only=read_only, + read_only=read_only, attachment_count=attachment_count, )) except Exception as exc: logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) # Fall back to a single event. results.append(build_local_event_dict( ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event, - read_only=read_only, + read_only=read_only, attachment_count=attachment_count, )) return results diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index d5d3088..00eed00 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import or_ +import attachments_store import caldav_client import models import permissions @@ -400,6 +401,9 @@ def get_events( ) .all() ) + # One grouped query for the whole calendar: a per-event lookup here + # would be N+1 across a month view. + att_counts = attachments_store.counts_for_events(db, [e.id for e in local_events]) for ev in local_events: creator = resolve_creator(ev, name_cache=name_cache) # A private event belonging to someone else (shared calendar or group @@ -413,9 +417,15 @@ def get_events( if foreign_private and visibility == "hidden": continue if ev.rrule: - built = expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator) + built = expand_recurring_local( + ev, local_cal, start_dt, end_dt, creator=creator, + attachment_count=att_counts.get(ev.id, 0), + ) else: - built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)] + built = [build_local_event_dict( + ev, local_cal, rrule=None, creator=creator, + attachment_count=att_counts.get(ev.id, 0), + )] for b in built: if shared_owner_name: b["calendar_name"] = shared_owner_name diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py index e3ddbc0..382ba26 100644 --- a/backend/routers/groups_router.py +++ b/backend/routers/groups_router.py @@ -398,6 +398,7 @@ def combined_events( ) .all() ) + att_counts = attachments_store.counts_for_events(db, [e.id for e in events]) for ev in events: creator_owner_id = ev.creator_id or owner_id # Private filtering for events that belong to someone else. @@ -412,9 +413,9 @@ def combined_events( creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"} if ev.rrule: - built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only) + built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only, attachment_count=att_counts.get(ev.id, 0)) else: - built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)] + built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only, attachment_count=att_counts.get(ev.id, 0))] for b in built: if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy": diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index 407f1d2..f87f427 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -110,7 +110,11 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True, def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar, db: Session) -> dict: - return build_local_event_dict(ev, cal, creator=resolve_creator(ev)) + counts = attachments_store.counts_for_events(db, [ev.id]) + return build_local_event_dict( + ev, cal, creator=resolve_creator(ev), + attachment_count=counts.get(ev.id, 0), + ) # ── Calendar CRUD ───────────────────────────────────────── diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py index 420090b..31c6d16 100644 --- a/backend/tests/test_attachments.py +++ b/backend/tests/test_attachments.py @@ -461,3 +461,58 @@ def test_capability_url_serves_without_authentication(client): assert client.get("/api/attach/voellig-erfunden").status_code == 404 client.delete(f"/api/local/attachments/{att_id}", headers=auth(token)) assert client.get(f"/api/attach/{cap}").status_code == 404 + + +# ── attachment_count in the merged event read ───────────── + +def test_attachment_count_appears_on_the_merged_read(client): + token = register_admin(client) + cal_id = _make_calendar(client, token) + ev = _make_event(client, token, cal_id) + + def count_for(uid): + events = client.get("/api/caldav/events", headers=auth(token), + params=RANGE).json()["events"] + return next(e["attachment_count"] for e in events if e["id"] == uid) + + assert count_for(ev["id"]) == 0 + _upload(client, token, ev["id"], name="a.pdf") + _upload(client, token, ev["id"], name="b.pdf") + assert count_for(ev["id"]) == 2 + + +def test_recurring_event_carries_the_count_on_every_occurrence(client): + token = register_admin(client) + cal_id = _make_calendar(client, token) + r = client.post("/api/local/events", headers=auth(token), json={ + "calendar_id": cal_id, "title": "Taeglich", "rrule": "FREQ=DAILY;COUNT=5", + "start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00", + }) + ev = r.json() + _upload(client, token, ev["id"], name="a.pdf") + + events = client.get("/api/caldav/events", headers=auth(token), + params=RANGE).json()["events"] + occurrences = [e for e in events if e["id"] == ev["id"]] + assert len(occurrences) == 5 + assert all(e["attachment_count"] == 1 for e in occurrences) + + +def test_busy_masked_event_reports_no_attachments(client): + """A count is metadata too: "this busy block has 3 files" would leak.""" + admin = register_admin(client) + bob_id, bob = create_user(client, admin, "bob") + cal_id = _make_calendar(client, admin) + r = client.post("/api/local/events", headers=auth(admin), json={ + "calendar_id": cal_id, "title": "Vertraulich", "private": True, + "start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00", + }) + ev = r.json() + _upload(client, admin, ev["id"], name="geheim.pdf") + _share(client, admin, cal_id, bob_id, "read") + + events = client.get("/api/caldav/events", headers=auth(bob), + params=RANGE).json()["events"] + masked = next(e for e in events if e["id"] == ev["id"]) + assert masked["title"] == "Beschäftigt" + assert masked["attachment_count"] == 0