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:
co-authored by
Claude Opus 5
parent
0e8a1a6a3d
commit
b885e731ee
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user