feat(webui): complete calendar quick access
This commit is contained in:
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user