feat(webui): complete calendar quick access

This commit is contained in:
2026-08-19 19:48:51 +02:00
parent 734501281e
commit 4bbf80e634
12 changed files with 493 additions and 23 deletions
+11 -4
View File
@@ -562,8 +562,11 @@ manifest = ModuleManifest(
summary="Use Calendar in Meetings and decisions and keep an optional compact agenda beside current work.",
body=(
"Calendar contributes its workspace to Meetings and decisions. When Quick Access is enabled, "
"the owner-rendered agenda shows authorized upcoming events in the Calendar category and links to the full workspace. "
"View and Quick Access settings affect presentation only; Calendar reauthorizes every event read and action."
"the owner-rendered agenda shows at most seven authorized events across the current or explicitly selected temporal "
"context and links to the full workspace. Event selection returns a typed Calendar reference; accounts with event-write "
"permission can launch the full owner-rendered creation dialog at that date. Calendar rechecks tenant, scope, private "
"collection ownership or group membership, and the requested time range on every read. View and Quick Access settings "
"affect presentation only."
),
layer="configured",
documentation_types=("user", "admin"),
@@ -575,8 +578,10 @@ manifest = ModuleManifest(
"summary": "Den Kalender unter Termine und Entscheidungen sowie optional als kompakte Agenda neben der aktuellen Arbeit verwenden.",
"body": (
"Calendar ordnet seinen Arbeitsbereich Termine und Entscheidungen zu. Ist der Schnellzugriff aktiviert, "
"zeigt die vom Kalender gerenderte Agenda berechtigte anstehende Termine und verweist auf den vollständigen Arbeitsbereich. "
"Ansichts- und Schnellzugriffseinstellungen ändern nur die Darstellung."
"zeigt die vom Kalender gerenderte Agenda höchstens sieben berechtigte Termine im aktuellen oder ausdrücklich "
"gewählten Zeitkontext. Die Terminauswahl liefert eine typisierte Kalenderreferenz; mit Schreibberechtigung lässt "
"sich der vollständige Termineditor für dieses Datum öffnen. Calendar prüft Mandant, Berechtigung, private "
"Kalendereigentümerschaft beziehungsweise Gruppenmitgliedschaft und Zeitraum bei jedem Abruf erneut."
),
}
},
@@ -842,6 +847,8 @@ manifest = ModuleManifest(
required_any=("calendar:event:read",),
order=10,
modes=("browse", "create"),
returned_reference_kinds=("calendar.event",),
help_context_id="calendar.quick_access.agenda",
),
),
),
+71 -6
View File
@@ -221,6 +221,7 @@ def _visible_events_for_delta(
calendar_id: str | None,
start_at: datetime | None,
end_at: datetime | None,
visible_calendar_ids: set[str] | None = None,
) -> list[CalendarEvent]:
if not event_ids:
return []
@@ -229,6 +230,10 @@ def _visible_events_for_delta(
CalendarEvent.id.in_(event_ids),
CalendarEvent.deleted_at.is_(None),
)
if visible_calendar_ids is not None:
if not visible_calendar_ids:
return []
query = query.filter(CalendarEvent.calendar_id.in_(visible_calendar_ids))
if calendar_id:
query = query.filter(CalendarEvent.calendar_id == calendar_id)
if start_at is not None:
@@ -246,8 +251,16 @@ def _full_event_delta_response(
calendar_id: str | None,
start_at: datetime | None,
end_at: datetime | None,
visible_calendar_ids: set[str] | None = None,
) -> CalendarEventDeltaResponse:
events = list_events(session, tenant_id=tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
events = list_events(
session,
tenant_id=tenant_id,
calendar_id=calendar_id,
start_at=start_at,
end_at=end_at,
visible_calendar_ids=visible_calendar_ids,
)
return CalendarEventDeltaResponse(
events=[_event_response(event) for event in events],
deleted=[],
@@ -257,6 +270,37 @@ def _full_event_delta_response(
)
def _principal_visible_calendar_ids(
session: Session,
principal: ApiPrincipal,
) -> set[str]:
return {
calendar.id
for calendar in list_calendars(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
can_admin=principal.has("calendar:calendar:admin"),
)
}
def _event_entry_matches_visible_calendars(
entry,
visible_calendar_ids: set[str],
) -> bool:
payload = entry.payload or {}
return any(
calendar_id in visible_calendar_ids
for calendar_id in (
payload.get("calendar_id"),
payload.get("previous_calendar_id"),
)
if isinstance(calendar_id, str)
)
def _event_delta_entries(session: Session, *, tenant_id: str, since: str, limit: int):
try:
since_sequence = decode_sequence_watermark(since)
@@ -858,10 +902,12 @@ def api_list_events(
start_at: datetime | None = Query(default=None),
end_at: datetime | None = Query(default=None),
expand_recurring: bool = Query(default=False),
limit: int | None = Query(default=None, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:read")
visible_calendar_ids = _principal_visible_calendar_ids(session, principal)
if expand_recurring:
if start_at is None or end_at is None:
raise HTTPException(
@@ -878,6 +924,8 @@ def api_list_events(
calendar_id=calendar_id,
start_at=start_at,
end_at=end_at,
visible_calendar_ids=visible_calendar_ids,
limit=limit,
)
return CalendarEventListResponse(
events=[
@@ -890,7 +938,15 @@ def api_list_events(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
events = list_events(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
events = list_events(
session,
tenant_id=principal.tenant_id,
calendar_id=calendar_id,
start_at=start_at,
end_at=end_at,
visible_calendar_ids=visible_calendar_ids,
limit=limit,
)
return CalendarEventListResponse(events=[_event_response(event) for event in events])
@@ -905,15 +961,18 @@ def api_list_events_delta(
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:read")
visible_calendar_ids = _principal_visible_calendar_ids(session, principal)
if since is None:
return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at, visible_calendar_ids=visible_calendar_ids)
entries, has_more = _event_delta_entries(session, tenant_id=principal.tenant_id, since=since, limit=limit)
if entries is None:
return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at, visible_calendar_ids=visible_calendar_ids)
scoped_entries = [
entry
for entry in entries
if entry.resource_type == CALENDAR_EVENT_RESOURCE and _event_payload_matches_window(entry, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
if entry.resource_type == CALENDAR_EVENT_RESOURCE
and _event_payload_matches_window(entry, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
and _event_entry_matches_visible_calendars(entry, visible_calendar_ids)
]
changed_ids = list(dict.fromkeys(entry.resource_id for entry in scoped_entries if entry.operation != "deleted"))
visible_events = _visible_events_for_delta(
@@ -923,6 +982,7 @@ def api_list_events_delta(
calendar_id=calendar_id,
start_at=start_at,
end_at=end_at,
visible_calendar_ids=visible_calendar_ids,
)
visible_ids = {event.id for event in visible_events}
deleted = [
@@ -970,7 +1030,10 @@ def api_get_event(
):
_require_scope(principal, "calendar:event:read")
try:
return _event_response(get_event(session, tenant_id=principal.tenant_id, event_id=event_id))
event = get_event(session, tenant_id=principal.tenant_id, event_id=event_id)
if event.calendar_id not in _principal_visible_calendar_ids(session, principal):
raise CalendarError("Calendar event not found")
return _event_response(event)
except CalendarError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -1154,6 +1217,8 @@ def api_export_ics_event(
_require_scope(principal, "calendar:event:export")
try:
event = get_event(session, tenant_id=principal.tenant_id, event_id=event_id)
if event.calendar_id not in _principal_visible_calendar_ids(session, principal):
raise CalendarError("Calendar event not found")
except CalendarError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
ics = event.raw_ics or event_to_ics(event)
+22 -2
View File
@@ -3829,15 +3829,25 @@ def list_events(
calendar_id: str | None = None,
start_at: datetime | None = None,
end_at: datetime | None = None,
visible_calendar_ids: Iterable[str] | None = None,
limit: int | None = None,
) -> list[CalendarEvent]:
query = session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None))
if visible_calendar_ids is not None:
normalized_calendar_ids = tuple(dict.fromkeys(visible_calendar_ids))
if not normalized_calendar_ids:
return []
query = query.filter(CalendarEvent.calendar_id.in_(normalized_calendar_ids))
if calendar_id:
query = query.filter(CalendarEvent.calendar_id == calendar_id)
if start_at is not None:
query = query.filter(or_(CalendarEvent.end_at.is_(None), CalendarEvent.end_at >= normalize_datetime(start_at)))
if end_at is not None:
query = query.filter(CalendarEvent.start_at <= normalize_datetime(end_at))
return query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all()
query = query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc())
if limit is not None:
query = query.limit(max(1, limit))
return query.all()
def list_event_occurrences(
@@ -3847,6 +3857,8 @@ def list_event_occurrences(
start_at: datetime,
end_at: datetime,
calendar_id: str | None = None,
visible_calendar_ids: Iterable[str] | None = None,
limit: int | None = None,
) -> list[dict[str, Any]]:
"""Return range-bounded events with recurring series fully reconciled."""
@@ -3863,6 +3875,13 @@ def list_event_occurrences(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.deleted_at.is_(None),
)
if visible_calendar_ids is not None:
normalized_calendar_ids = tuple(dict.fromkeys(visible_calendar_ids))
if not normalized_calendar_ids:
return []
base_query = base_query.filter(
CalendarEvent.calendar_id.in_(normalized_calendar_ids)
)
if calendar_id:
base_query = base_query.filter(CalendarEvent.calendar_id == calendar_id)
@@ -3991,7 +4010,7 @@ def list_event_occurrences(
is_override=is_override,
)
)
return sorted(
sorted_results = sorted(
results,
key=lambda item: (
item["start_at"],
@@ -4000,6 +4019,7 @@ def list_event_occurrences(
item["instance_id"],
),
)
return sorted_results[: max(1, limit)] if limit is not None else sorted_results
def event_overlaps_range(