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) <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 10:11:30 +02:00
co-authored by Claude Opus 5
parent 0e8a1a6a3d
commit b885e731ee
5 changed files with 88 additions and 8 deletions
+13 -3
View File
@@ -71,6 +71,9 @@ def mask_busy_event(event: dict) -> dict:
masked["rrule"] = None masked["rrule"] = None
masked["exdate"] = None masked["exdate"] = None
masked["private"] = True 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 return masked
@@ -103,6 +106,7 @@ def build_local_event_dict(
owner: Optional[dict] = None, owner: Optional[dict] = None,
is_group_event: bool = False, is_group_event: bool = False,
read_only: bool = False, read_only: bool = False,
attachment_count: int = 0,
) -> dict: ) -> dict:
"""Build the unified dict for a single local event (or occurrence). """Build the unified dict for a single local event (or occurrence).
@@ -131,6 +135,11 @@ def build_local_event_dict(
"creator": creator, "creator": creator,
"private": bool(ev.is_private), "private": bool(ev.is_private),
"reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()], "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 # 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 # 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, owner: Optional[dict] = None,
is_group_event: bool = False, is_group_event: bool = False,
read_only: bool = False, read_only: bool = False,
attachment_count: int = 0,
) -> list: ) -> list:
"""Expand a recurring LocalEvent into individual occurrences in the range.""" """Expand a recurring LocalEvent into individual occurrences in the range."""
results = [] results = []
@@ -201,7 +211,7 @@ def expand_recurring_local(
ev, local_cal, ev, local_cal,
start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True, start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True,
creator=creator, owner=owner, is_group_event=is_group_event, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only, read_only=read_only, attachment_count=attachment_count,
)) ))
else: else:
ev_start = dt_datetime.fromisoformat(ev_start_str) ev_start = dt_datetime.fromisoformat(ev_start_str)
@@ -228,13 +238,13 @@ def expand_recurring_local(
ev, local_cal, ev, local_cal,
start=occ.isoformat(), end=occ_end.isoformat(), all_day=False, start=occ.isoformat(), end=occ_end.isoformat(), all_day=False,
creator=creator, owner=owner, is_group_event=is_group_event, 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: except Exception as exc:
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
# Fall back to a single event. # Fall back to a single event.
results.append(build_local_event_dict( results.append(build_local_event_dict(
ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event, 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 return results
+12 -2
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_
import attachments_store
import caldav_client import caldav_client
import models import models
import permissions import permissions
@@ -400,6 +401,9 @@ def get_events(
) )
.all() .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: for ev in local_events:
creator = resolve_creator(ev, name_cache=name_cache) creator = resolve_creator(ev, name_cache=name_cache)
# A private event belonging to someone else (shared calendar or group # A private event belonging to someone else (shared calendar or group
@@ -413,9 +417,15 @@ def get_events(
if foreign_private and visibility == "hidden": if foreign_private and visibility == "hidden":
continue continue
if ev.rrule: 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: 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: for b in built:
if shared_owner_name: if shared_owner_name:
b["calendar_name"] = shared_owner_name b["calendar_name"] = shared_owner_name
+3 -2
View File
@@ -398,6 +398,7 @@ def combined_events(
) )
.all() .all()
) )
att_counts = attachments_store.counts_for_events(db, [e.id for e in events])
for ev in events: for ev in events:
creator_owner_id = ev.creator_id or owner_id creator_owner_id = ev.creator_id or owner_id
# Private filtering for events that belong to someone else. # 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)"} creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
if ev.rrule: 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: 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: for b in built:
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy": if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
+5 -1
View File
@@ -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: 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 ───────────────────────────────────────── # ── Calendar CRUD ─────────────────────────────────────────
+55
View File
@@ -461,3 +461,58 @@ def test_capability_url_serves_without_authentication(client):
assert client.get("/api/attach/voellig-erfunden").status_code == 404 assert client.get("/api/attach/voellig-erfunden").status_code == 404
client.delete(f"/api/local/attachments/{att_id}", headers=auth(token)) client.delete(f"/api/local/attachments/{att_id}", headers=auth(token))
assert client.get(f"/api/attach/{cap}").status_code == 404 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