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["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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user