Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde8c50e55 | ||
|
|
8e36f8b3d2 | ||
|
|
3e5fc05ca3 | ||
|
|
3792e9ab8a | ||
|
|
ae0f3990f1 | ||
|
|
4f02425a28 | ||
|
|
a2a9e8e814 | ||
|
|
93fb8aeff8 | ||
|
|
4bbf80e634 | ||
|
|
734501281e | ||
|
|
35671dec73 | ||
|
|
f9385519f3 | ||
|
|
a6e10fd120 | ||
|
|
b6f939eaba | ||
|
|
089d07b60a | ||
|
|
d42153edc7 | ||
|
|
128c78597e |
@@ -81,6 +81,15 @@ and appointment modules. It is not yet a CalDAV network server: external clients
|
||||
cannot use GovOPlaN itself as their CalDAV endpoint, and scheduling inbox/outbox
|
||||
delivery remains owned by the scheduling and mail integration work.
|
||||
|
||||
Calendar also publishes `privacy.dsar.calendar`. Core's governed
|
||||
data-subject-request workflow can use it to find tenant-scoped organizer,
|
||||
attendee, preference, synchronization, outbox, and migration metadata. The
|
||||
provider isolates the matching party and omits raw ICS, connector locators,
|
||||
credentials, tokens, worker claims, and unrelated attendees. It classifies
|
||||
synchronized and correlated state as retained evidence, leaves shared event
|
||||
changes for manual review, and can safely delete only the subject's personal
|
||||
view preference after revalidating tenant and ownership.
|
||||
|
||||
## Development
|
||||
|
||||
Install through the core environment:
|
||||
|
||||
@@ -191,6 +191,35 @@ the same tenant scope.
|
||||
|
||||
## Integration Points
|
||||
|
||||
## Data-subject requests and retention boundaries
|
||||
|
||||
Calendar implements the optional `privacy.dsar.calendar` capability used by
|
||||
Core's governed data-subject-request workflow. A search is bounded to the
|
||||
effective tenant and matches normalized organizer or attendee email, direct
|
||||
membership references, and independently corroborated namespaced Calendar
|
||||
collection or event references. Only the matching party fragment is projected;
|
||||
unrelated attendees and unrelated events in the same collection are not copied
|
||||
into the case.
|
||||
|
||||
The projection includes safe event and collection context, personal view
|
||||
preferences, subject-owned synchronization configuration, outbox outcomes, and
|
||||
migration state. It never includes raw ICS or complete iCalendar objects,
|
||||
collection URLs, remote resource hrefs or ETags, sync tokens, usernames,
|
||||
credential references or ciphertext, idempotency keys, worker leases, provider
|
||||
error text, or opaque metadata. An authorized reviewer must use Calendar's own
|
||||
screens when excluded content is necessary to decide the request.
|
||||
|
||||
Synchronized, correlated, deleted, queued, and migrated state is classified as
|
||||
retained evidence with an explicit reason. Local collections and events and
|
||||
active credential metadata remain manual-review items because erasure can
|
||||
affect recurrence, other attendees, institutional scheduling, remote systems,
|
||||
and retention duties. The only executable provider action is deletion of the
|
||||
subject's personal view preference; execution locks and revalidates its tenant
|
||||
and owner and is idempotent. Event, attendee, credential, outbox, and migration
|
||||
records are never mutated directly by the DSAR provider. Related meeting-poll,
|
||||
mail, and delivery data remains owned by Scheduling, Poll, Mail, and their own
|
||||
providers.
|
||||
|
||||
### Scheduling
|
||||
|
||||
`govoplan-scheduling` should use calendar for:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/calendar-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -19,7 +19,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.44",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+3
-3
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-calendar"
|
||||
version = "0.1.15"
|
||||
version = "0.1.24"
|
||||
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.15",
|
||||
"govoplan-access>=0.1.15",
|
||||
"govoplan-core>=0.1.46",
|
||||
"govoplan-access>=0.1.18",
|
||||
"defusedxml>=0.7,<1",
|
||||
"icalendar>=7.2",
|
||||
"python-dateutil>=2.9",
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.15"
|
||||
__version__ = "0.1.24"
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
from govoplan_core.core.calendar import (
|
||||
CalendarCapabilityError,
|
||||
CalendarEventRef,
|
||||
CalendarEventReleaseRef,
|
||||
CalendarEventRequest,
|
||||
CalendarExternalProfileProvider,
|
||||
CalendarExternalProfileRef,
|
||||
@@ -37,6 +38,7 @@ from govoplan_calendar.backend.service import (
|
||||
CalendarError,
|
||||
create_sync_source,
|
||||
create_event,
|
||||
delete_event,
|
||||
list_calendars as list_calendar_collections,
|
||||
list_freebusy,
|
||||
update_event,
|
||||
@@ -312,6 +314,96 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
|
||||
outbox_operation_id=outbox_operation_id,
|
||||
)
|
||||
|
||||
def promote_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
event_id: str,
|
||||
request: CalendarEventRequest,
|
||||
) -> CalendarEventRef:
|
||||
try:
|
||||
payload = CalendarEventUpdateRequest(
|
||||
calendar_id=request.calendar_id,
|
||||
summary=request.summary,
|
||||
description=request.description,
|
||||
location=request.location,
|
||||
status=request.status,
|
||||
transparency=request.transparency,
|
||||
classification=request.classification,
|
||||
start_at=request.start_at,
|
||||
end_at=request.end_at,
|
||||
timezone=request.timezone,
|
||||
attendees=[dict(item) for item in request.attendees],
|
||||
categories=list(request.categories),
|
||||
related_to=[dict(item) for item in request.related_to],
|
||||
metadata=dict(request.metadata),
|
||||
)
|
||||
event = update_event(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
event_id=event_id,
|
||||
payload=payload,
|
||||
)
|
||||
except (CalendarError, TypeError, ValueError) as exc:
|
||||
raise CalendarCapabilityError(str(exc)) from exc
|
||||
external_state, outbox_operation_id = _external_state(event)
|
||||
return CalendarEventRef(
|
||||
id=event.id,
|
||||
calendar_id=event.calendar_id,
|
||||
uid=event.uid,
|
||||
external_state=external_state,
|
||||
outbox_operation_id=outbox_operation_id,
|
||||
)
|
||||
|
||||
def release_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
event_id: str,
|
||||
) -> CalendarEventReleaseRef:
|
||||
if not isinstance(session, Session):
|
||||
raise CalendarCapabilityError("Calendar release requires a database session")
|
||||
event = (
|
||||
session.query(CalendarEvent)
|
||||
.filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.id == event_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if event is None:
|
||||
return CalendarEventReleaseRef(
|
||||
event_id=event_id,
|
||||
already_released=True,
|
||||
external_state="not_found",
|
||||
)
|
||||
was_released = event.deleted_at is not None
|
||||
if not was_released:
|
||||
try:
|
||||
delete_event(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
event_id=event_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except CalendarError as exc:
|
||||
raise CalendarCapabilityError(str(exc)) from exc
|
||||
session.flush()
|
||||
external_state, outbox_operation_id = _external_state(event)
|
||||
return CalendarEventReleaseRef(
|
||||
event_id=event_id,
|
||||
already_released=was_released,
|
||||
external_state=(
|
||||
external_state if external_state != "local" else "local_released"
|
||||
),
|
||||
outbox_operation_id=outbox_operation_id,
|
||||
)
|
||||
|
||||
|
||||
class SqlCalendarExternalProfileProvider(CalendarExternalProfileProvider):
|
||||
"""Configure groupware profiles while Calendar retains event semantics."""
|
||||
|
||||
@@ -0,0 +1,888 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Text, cast, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_calendar.backend.db.models import (
|
||||
CalendarCollection,
|
||||
CalendarEvent,
|
||||
CalendarMigrationBatch,
|
||||
CalendarMigrationResource,
|
||||
CalendarOutboxOperation,
|
||||
CalendarSyncCredential,
|
||||
CalendarSyncSource,
|
||||
CalendarViewPreference,
|
||||
)
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
|
||||
|
||||
CALENDAR_DSAR_CAPABILITY = dsar_capability_name("calendar")
|
||||
_MAX_RECORDS = 5_000
|
||||
_SECRET_PARTS = (
|
||||
"authorization",
|
||||
"credential",
|
||||
"idempotency",
|
||||
"lease",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
)
|
||||
_LOCATOR_PARTS = ("href", "path", "url")
|
||||
|
||||
|
||||
class CalendarDsarProvider:
|
||||
provider_id = "calendar"
|
||||
module_id = "calendar"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
subject_user_id = _subject_user_id(subject)
|
||||
subject_email = _subject_email(subject)
|
||||
references = _calendar_references(subject)
|
||||
if subject_user_id is None and subject_email is None and not references:
|
||||
return ()
|
||||
|
||||
records: list[DsarRecordRef] = []
|
||||
|
||||
def append(record: DsarRecordRef) -> None:
|
||||
if len(records) >= _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Calendar DSAR match limit exceeded; narrow the subject selectors."
|
||||
)
|
||||
records.append(record)
|
||||
|
||||
events = _matching_events(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
subject_user_id=subject_user_id,
|
||||
subject_email=subject_email,
|
||||
event_id=references.get("event"),
|
||||
)
|
||||
event_ids = {row.id for row in events}
|
||||
collection_ids = {row.calendar_id for row in events}
|
||||
if references.get("collection"):
|
||||
collection_ids.add(references["collection"])
|
||||
|
||||
collections = _matching_collections(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
subject_user_id=subject_user_id,
|
||||
collection_ids=collection_ids,
|
||||
)
|
||||
collection_ids = {row.id for row in collections}
|
||||
owned_collection_ids = {
|
||||
row.id
|
||||
for row in collections
|
||||
if subject_user_id is not None
|
||||
and (
|
||||
(row.owner_type == "user" and row.owner_id == subject_user_id)
|
||||
or row.created_by_user_id == subject_user_id
|
||||
)
|
||||
}
|
||||
|
||||
outbox_by_event = _outbox_by_event(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
event_ids=event_ids,
|
||||
)
|
||||
for collection in collections:
|
||||
match_fields = _matching_fields(
|
||||
collection,
|
||||
subject_user_id,
|
||||
("created_by_user_id",),
|
||||
)
|
||||
if (
|
||||
subject_user_id is not None
|
||||
and collection.owner_type == "user"
|
||||
and collection.owner_id == subject_user_id
|
||||
):
|
||||
match_fields.insert(0, "owner_id")
|
||||
immutable = collection.deleted_at is not None
|
||||
append(
|
||||
_record(
|
||||
"calendar_collection",
|
||||
collection.id,
|
||||
"calendar_collection",
|
||||
collection.name,
|
||||
{
|
||||
"match_fields": match_fields,
|
||||
"event_context": collection.id
|
||||
in {row.calendar_id for row in events},
|
||||
"slug": collection.slug,
|
||||
"name": collection.name,
|
||||
"description": collection.description,
|
||||
"timezone": collection.timezone,
|
||||
"color": collection.color,
|
||||
"owner_type": collection.owner_type,
|
||||
"visibility": collection.visibility,
|
||||
"is_default": collection.is_default,
|
||||
"deleted_at": _iso(collection.deleted_at),
|
||||
},
|
||||
observed_at=collection.updated_at,
|
||||
immutable=immutable,
|
||||
retention_reason=(
|
||||
"Deleted collection state is retained until Calendar lifecycle cleanup completes."
|
||||
if immutable
|
||||
else None
|
||||
),
|
||||
source_path="/calendar",
|
||||
)
|
||||
)
|
||||
|
||||
for event in events:
|
||||
organizer = _matching_party(event.organizer, subject_email)
|
||||
attendees = _matching_parties(event.attendees, subject_email)
|
||||
actor_fields = _matching_fields(
|
||||
event,
|
||||
subject_user_id,
|
||||
("created_by_user_id", "updated_by_user_id"),
|
||||
)
|
||||
immutable = bool(
|
||||
event.deleted_at
|
||||
or event.source_kind != "local"
|
||||
or event.correlation_key
|
||||
or event.producer_module
|
||||
or outbox_by_event.get(event.id)
|
||||
)
|
||||
append(
|
||||
_record(
|
||||
"calendar_event",
|
||||
event.id,
|
||||
"calendar_event",
|
||||
event.summary,
|
||||
{
|
||||
"match_fields": actor_fields
|
||||
+ (["organizer"] if organizer else [])
|
||||
+ (["attendees"] if attendees else []),
|
||||
"calendar_id": event.calendar_id,
|
||||
"uid": event.uid,
|
||||
"recurrence_id": event.recurrence_id,
|
||||
"sequence": event.sequence,
|
||||
"summary": event.summary,
|
||||
"description": event.description,
|
||||
"location": event.location,
|
||||
"status": event.status,
|
||||
"transparency": event.transparency,
|
||||
"classification": event.classification,
|
||||
"start_at": _iso(event.start_at),
|
||||
"end_at": _iso(event.end_at),
|
||||
"duration_seconds": event.duration_seconds,
|
||||
"all_day": event.all_day,
|
||||
"timezone": event.timezone,
|
||||
"organizer": organizer,
|
||||
"matching_attendees": attendees,
|
||||
"categories": _safe_value(event.categories),
|
||||
"rrule": _safe_value(event.rrule),
|
||||
"rdate": _safe_value(event.rdate),
|
||||
"exdate": _safe_value(event.exdate),
|
||||
"reminders": _safe_value(event.reminders),
|
||||
"attachment_count": len(event.attachments or ()),
|
||||
"source_kind": event.source_kind,
|
||||
"producer_module": event.producer_module,
|
||||
"producer_resource_type": event.producer_resource_type,
|
||||
"producer_resource_id": event.producer_resource_id,
|
||||
"deleted_at": _iso(event.deleted_at),
|
||||
},
|
||||
observed_at=event.updated_at,
|
||||
immutable=immutable,
|
||||
retention_reason=(
|
||||
"Synchronized, correlated, deleted, or externally queued event state is retained as Calendar execution evidence."
|
||||
if immutable
|
||||
else None
|
||||
),
|
||||
source_path="/calendar",
|
||||
)
|
||||
)
|
||||
|
||||
for operations in outbox_by_event.values():
|
||||
for operation in operations:
|
||||
append(
|
||||
_record(
|
||||
"calendar_outbox_operation",
|
||||
operation.id,
|
||||
"calendar_sync_evidence",
|
||||
f"Calendar {operation.operation_kind} operation",
|
||||
{
|
||||
"event_id": operation.event_id,
|
||||
"operation_kind": operation.operation_kind,
|
||||
"payload_fingerprint": operation.payload_fingerprint,
|
||||
"status": operation.status,
|
||||
"attempt_count": operation.attempt_count,
|
||||
"max_attempts": operation.max_attempts,
|
||||
"available_at": _iso(operation.available_at),
|
||||
"last_attempt_at": _iso(operation.last_attempt_at),
|
||||
"completed_at": _iso(operation.completed_at),
|
||||
"reconciled_at": _iso(operation.reconciled_at),
|
||||
},
|
||||
observed_at=operation.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Calendar outbox outcomes are synchronization and recovery evidence.",
|
||||
)
|
||||
)
|
||||
|
||||
self._append_user_resources(
|
||||
db,
|
||||
append=append,
|
||||
tenant_id=tenant_id,
|
||||
subject_user_id=subject_user_id,
|
||||
)
|
||||
self._append_sync_and_migration_resources(
|
||||
db,
|
||||
append=append,
|
||||
tenant_id=tenant_id,
|
||||
subject_user_id=subject_user_id,
|
||||
owned_collection_ids=owned_collection_ids,
|
||||
)
|
||||
return tuple(records)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del session
|
||||
subject_user_id = _subject_user_id(subject)
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
if (
|
||||
record.provider_id != self.provider_id
|
||||
or record.module_id != self.module_id
|
||||
):
|
||||
raise ValueError("Calendar DSAR received a foreign provider record.")
|
||||
actions.append(
|
||||
_action(
|
||||
f"calendar:{'retain' if record.immutable_evidence else 'review'}:{record.resource_type}:{record.resource_id}",
|
||||
"retain" if record.immutable_evidence else "manual_review",
|
||||
record,
|
||||
f"{'Retain' if record.immutable_evidence else 'Review'} {record.title}",
|
||||
record.retention_reason
|
||||
or "Calendar content can span recurrence, attendees, synchronized resources, and external recovery state; review it through Calendar lifecycle controls.",
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
if (
|
||||
record.resource_type == "calendar_view_preference"
|
||||
and "user_id" in record.data.get("match_fields", ())
|
||||
and subject_user_id is not None
|
||||
):
|
||||
actions.append(
|
||||
_action(
|
||||
f"calendar:delete:calendar_view_preference:{record.resource_id}",
|
||||
"delete",
|
||||
record,
|
||||
"Delete personal Calendar view preference",
|
||||
"The personal presentation preference can be removed without changing events or synchronization evidence.",
|
||||
executable=True,
|
||||
irreversible=True,
|
||||
metadata={
|
||||
"subject_user_id": subject_user_id,
|
||||
"tenant_id": tenant_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
action_ids = [action.action_id for action in actions]
|
||||
if len(action_ids) != len(set(action_ids)):
|
||||
raise ValueError("Calendar DSAR produced duplicate action ids.")
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
db = _session(session)
|
||||
subject_user_id = _subject_user_id(subject)
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
if (
|
||||
subject_user_id is None
|
||||
or action.provider_id != self.provider_id
|
||||
or action.module_id != self.module_id
|
||||
or action.metadata.get("subject_user_id") != subject_user_id
|
||||
or action.metadata.get("tenant_id") != tenant_id
|
||||
):
|
||||
results.append(
|
||||
_blocked(action, "The Calendar DSAR action is stale or invalid.")
|
||||
)
|
||||
continue
|
||||
if action.action_id.startswith("calendar:delete:calendar_view_preference:"):
|
||||
results.append(
|
||||
_delete_view_preference(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
subject_user_id=subject_user_id,
|
||||
action=action,
|
||||
request_id=request_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
_blocked(action, "Calendar does not execute this action kind.")
|
||||
)
|
||||
db.flush()
|
||||
return tuple(results)
|
||||
|
||||
def _append_user_resources(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
append: object,
|
||||
tenant_id: str,
|
||||
subject_user_id: str | None,
|
||||
) -> None:
|
||||
if subject_user_id is None:
|
||||
return
|
||||
for preference in _bounded_rows(
|
||||
db.query(CalendarViewPreference)
|
||||
.filter(
|
||||
CalendarViewPreference.tenant_id == tenant_id,
|
||||
CalendarViewPreference.user_id == subject_user_id,
|
||||
)
|
||||
.order_by(CalendarViewPreference.id)
|
||||
):
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"calendar_view_preference",
|
||||
preference.id,
|
||||
"personal_calendar_preference",
|
||||
"Calendar view preference",
|
||||
{
|
||||
"match_fields": ["user_id"],
|
||||
"dim_weekends": preference.dim_weekends,
|
||||
"dim_off_hours": preference.dim_off_hours,
|
||||
"workday_start_hour": preference.workday_start_hour,
|
||||
"workday_end_hour": preference.workday_end_hour,
|
||||
"continuous_virtualization": preference.continuous_virtualization,
|
||||
"continuous_overscan_weeks": preference.continuous_overscan_weeks,
|
||||
"alternate_continuous_months": preference.alternate_continuous_months,
|
||||
},
|
||||
observed_at=preference.updated_at,
|
||||
source_path="/settings?section=calendar",
|
||||
)
|
||||
)
|
||||
for credential in _bounded_rows(
|
||||
db.query(CalendarSyncCredential)
|
||||
.filter(
|
||||
CalendarSyncCredential.tenant_id == tenant_id,
|
||||
CalendarSyncCredential.created_by_user_id == subject_user_id,
|
||||
)
|
||||
.order_by(CalendarSyncCredential.id)
|
||||
):
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"calendar_sync_credential",
|
||||
credential.id,
|
||||
"calendar_sync_configuration",
|
||||
credential.label or "Calendar sync credential",
|
||||
{
|
||||
"match_fields": ["created_by_user_id"],
|
||||
"credential_kind": credential.credential_kind,
|
||||
"label": credential.label,
|
||||
"deleted_at": _iso(credential.deleted_at),
|
||||
},
|
||||
observed_at=credential.updated_at,
|
||||
immutable=credential.deleted_at is not None,
|
||||
retention_reason=(
|
||||
"Retired credential metadata remains synchronization governance evidence."
|
||||
if credential.deleted_at is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def _append_sync_and_migration_resources(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
append: object,
|
||||
tenant_id: str,
|
||||
subject_user_id: str | None,
|
||||
owned_collection_ids: set[str],
|
||||
) -> None:
|
||||
if owned_collection_ids:
|
||||
for source in _bounded_rows(
|
||||
db.query(CalendarSyncSource)
|
||||
.filter(
|
||||
CalendarSyncSource.tenant_id == tenant_id,
|
||||
CalendarSyncSource.calendar_id.in_(owned_collection_ids),
|
||||
)
|
||||
.order_by(CalendarSyncSource.id)
|
||||
):
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"calendar_sync_source",
|
||||
source.id,
|
||||
"calendar_sync_configuration",
|
||||
source.display_name or source.source_kind,
|
||||
{
|
||||
"calendar_id": source.calendar_id,
|
||||
"source_kind": source.source_kind,
|
||||
"display_name": source.display_name,
|
||||
"auth_type": source.auth_type,
|
||||
"sync_enabled": source.sync_enabled,
|
||||
"sync_interval_seconds": source.sync_interval_seconds,
|
||||
"sync_direction": source.sync_direction,
|
||||
"conflict_policy": source.conflict_policy,
|
||||
"last_attempt_at": _iso(source.last_attempt_at),
|
||||
"last_synced_at": _iso(source.last_synced_at),
|
||||
"next_sync_at": _iso(source.next_sync_at),
|
||||
"last_status": source.last_status,
|
||||
"deleted_at": _iso(source.deleted_at),
|
||||
},
|
||||
observed_at=source.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="External source configuration and synchronization state require coordinated Calendar review.",
|
||||
)
|
||||
)
|
||||
|
||||
migration_conditions = []
|
||||
if subject_user_id is not None:
|
||||
migration_conditions.append(
|
||||
CalendarMigrationBatch.created_by_user_id == subject_user_id
|
||||
)
|
||||
if owned_collection_ids:
|
||||
migration_conditions.extend(
|
||||
(
|
||||
CalendarMigrationBatch.source_calendar_id.in_(owned_collection_ids),
|
||||
CalendarMigrationBatch.target_calendar_id.in_(owned_collection_ids),
|
||||
)
|
||||
)
|
||||
if not migration_conditions:
|
||||
return
|
||||
batches = _bounded_rows(
|
||||
db.query(CalendarMigrationBatch)
|
||||
.filter(
|
||||
CalendarMigrationBatch.tenant_id == tenant_id,
|
||||
or_(*migration_conditions),
|
||||
)
|
||||
.order_by(CalendarMigrationBatch.id)
|
||||
)
|
||||
batch_ids = {row.id for row in batches}
|
||||
for batch in batches:
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"calendar_migration_batch",
|
||||
batch.id,
|
||||
"calendar_migration_evidence",
|
||||
f"Calendar migration {batch.id}",
|
||||
{
|
||||
"match_fields": _matching_fields(
|
||||
batch, subject_user_id, ("created_by_user_id",)
|
||||
),
|
||||
"migration_kind": batch.migration_kind,
|
||||
"status": batch.status,
|
||||
"phase": batch.phase,
|
||||
"total_resources": batch.total_resources,
|
||||
"total_events": batch.total_events,
|
||||
"completed_at": _iso(batch.completed_at),
|
||||
},
|
||||
observed_at=batch.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Remote-move authorization and outcome state is immutable migration evidence.",
|
||||
)
|
||||
)
|
||||
if not batch_ids:
|
||||
return
|
||||
for resource in _bounded_rows(
|
||||
db.query(CalendarMigrationResource)
|
||||
.filter(CalendarMigrationResource.batch_id.in_(batch_ids))
|
||||
.order_by(CalendarMigrationResource.id)
|
||||
):
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"calendar_migration_resource",
|
||||
resource.id,
|
||||
"calendar_migration_evidence",
|
||||
"Calendar migration resource",
|
||||
{
|
||||
"batch_id": resource.batch_id,
|
||||
"status": resource.status,
|
||||
"event_count": len(resource.event_ids or ()),
|
||||
},
|
||||
observed_at=resource.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Per-resource move state is immutable migration and recovery evidence.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _matching_events(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject_user_id: str | None,
|
||||
subject_email: str | None,
|
||||
event_id: str | None,
|
||||
) -> list[CalendarEvent]:
|
||||
conditions = []
|
||||
if event_id:
|
||||
conditions.append(CalendarEvent.id == event_id)
|
||||
if subject_user_id:
|
||||
conditions.extend(
|
||||
(
|
||||
CalendarEvent.created_by_user_id == subject_user_id,
|
||||
CalendarEvent.updated_by_user_id == subject_user_id,
|
||||
)
|
||||
)
|
||||
if subject_email:
|
||||
pattern = f"%{_escape_like(subject_email)}%"
|
||||
conditions.extend(
|
||||
(
|
||||
func.lower(cast(CalendarEvent.organizer, Text)).like(
|
||||
pattern, escape="\\"
|
||||
),
|
||||
func.lower(cast(CalendarEvent.attendees, Text)).like(
|
||||
pattern, escape="\\"
|
||||
),
|
||||
)
|
||||
)
|
||||
if not conditions:
|
||||
return []
|
||||
candidates = _bounded_rows(
|
||||
session.query(CalendarEvent)
|
||||
.filter(CalendarEvent.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(CalendarEvent.id)
|
||||
)
|
||||
return [
|
||||
row
|
||||
for row in candidates
|
||||
if row.id == event_id
|
||||
or (
|
||||
subject_user_id is not None
|
||||
and (
|
||||
row.created_by_user_id == subject_user_id
|
||||
or row.updated_by_user_id == subject_user_id
|
||||
)
|
||||
)
|
||||
or _matching_party(row.organizer, subject_email)
|
||||
or _matching_parties(row.attendees, subject_email)
|
||||
]
|
||||
|
||||
|
||||
def _matching_collections(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject_user_id: str | None,
|
||||
collection_ids: set[str],
|
||||
) -> list[CalendarCollection]:
|
||||
conditions = []
|
||||
if collection_ids:
|
||||
conditions.append(CalendarCollection.id.in_(collection_ids))
|
||||
if subject_user_id:
|
||||
conditions.extend(
|
||||
(
|
||||
(CalendarCollection.owner_type == "user")
|
||||
& (CalendarCollection.owner_id == subject_user_id),
|
||||
CalendarCollection.created_by_user_id == subject_user_id,
|
||||
)
|
||||
)
|
||||
if not conditions:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(CalendarCollection)
|
||||
.filter(CalendarCollection.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(CalendarCollection.id)
|
||||
)
|
||||
|
||||
|
||||
def _outbox_by_event(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
event_ids: set[str],
|
||||
) -> dict[str, list[CalendarOutboxOperation]]:
|
||||
if not event_ids:
|
||||
return {}
|
||||
result: dict[str, list[CalendarOutboxOperation]] = {}
|
||||
for row in _bounded_rows(
|
||||
session.query(CalendarOutboxOperation)
|
||||
.filter(
|
||||
CalendarOutboxOperation.tenant_id == tenant_id,
|
||||
CalendarOutboxOperation.event_id.in_(event_ids),
|
||||
)
|
||||
.order_by(CalendarOutboxOperation.id)
|
||||
):
|
||||
result.setdefault(str(row.event_id), []).append(row)
|
||||
return result
|
||||
|
||||
|
||||
def _matching_party(value: object, email: str | None) -> dict[str, object] | None:
|
||||
if email is None or not isinstance(value, Mapping):
|
||||
return None
|
||||
address = _party_email(value)
|
||||
if address != email:
|
||||
return None
|
||||
params = value.get("params")
|
||||
safe_params: dict[str, object] = {}
|
||||
if isinstance(params, Mapping):
|
||||
for key in ("CN", "CUTYPE", "PARTSTAT", "ROLE", "RSVP"):
|
||||
if key in params:
|
||||
safe_params[key] = _safe_value(params[key])
|
||||
return {
|
||||
"email": address,
|
||||
"name": value.get("name") or _first_value(safe_params.get("CN")),
|
||||
"participation_status": _first_value(safe_params.get("PARTSTAT")),
|
||||
"role": _first_value(safe_params.get("ROLE")),
|
||||
"rsvp": _first_value(safe_params.get("RSVP")),
|
||||
}
|
||||
|
||||
|
||||
def _matching_parties(
|
||||
values: object,
|
||||
email: str | None,
|
||||
) -> list[dict[str, object]]:
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
return [party for value in values[:512] if (party := _matching_party(value, email))]
|
||||
|
||||
|
||||
def _party_email(value: Mapping[object, object]) -> str | None:
|
||||
candidate = value.get("email") or value.get("address") or value.get("value")
|
||||
if not isinstance(candidate, str):
|
||||
return None
|
||||
if candidate.casefold().startswith("mailto:"):
|
||||
candidate = candidate[7:]
|
||||
return _normalized_email(candidate)
|
||||
|
||||
|
||||
def _safe_value(value: object, *, depth: int = 0) -> object:
|
||||
if depth >= 5:
|
||||
return "[depth-limited]"
|
||||
if isinstance(value, Mapping):
|
||||
result: dict[str, object] = {}
|
||||
for key, item in list(value.items())[:128]:
|
||||
normalized = str(key).casefold()
|
||||
if any(part in normalized for part in _SECRET_PARTS + _LOCATOR_PARTS):
|
||||
continue
|
||||
result[str(key)] = _safe_value(item, depth=depth + 1)
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [_safe_value(item, depth=depth + 1) for item in value[:256]]
|
||||
if isinstance(value, str):
|
||||
return value[:2_000]
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
return str(value)[:2_000]
|
||||
|
||||
|
||||
def _delete_view_preference(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject_user_id: str,
|
||||
action: DsarErasureActionRef,
|
||||
request_id: str,
|
||||
) -> DsarExecutionResultRef:
|
||||
row = (
|
||||
session.query(CalendarViewPreference)
|
||||
.filter(
|
||||
CalendarViewPreference.id == action.resource_id,
|
||||
CalendarViewPreference.tenant_id == tenant_id,
|
||||
)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
return _result(
|
||||
action,
|
||||
"unchanged",
|
||||
"The Calendar view preference was already absent.",
|
||||
{"request_id": request_id},
|
||||
)
|
||||
if row.user_id != subject_user_id:
|
||||
return _blocked(action, "The Calendar preference owner changed after planning.")
|
||||
session.delete(row)
|
||||
return _result(
|
||||
action,
|
||||
"executed",
|
||||
"The personal Calendar view preference was deleted.",
|
||||
{"request_id": request_id},
|
||||
)
|
||||
|
||||
|
||||
def _calendar_references(subject: DsarSubjectRef) -> dict[str, str]:
|
||||
aliases = {
|
||||
"calendar.collection": "collection",
|
||||
"calendar.event": "event",
|
||||
}
|
||||
return {
|
||||
target: value
|
||||
for key, target in aliases.items()
|
||||
if (value := str(subject.external_references.get(key) or "").strip())
|
||||
}
|
||||
|
||||
|
||||
def _subject_user_id(subject: DsarSubjectRef) -> str | None:
|
||||
candidates = [subject.membership_id] if subject.membership_id else []
|
||||
for key in (
|
||||
"calendar.user",
|
||||
"calendar.membership",
|
||||
"access.membership",
|
||||
"membership_id",
|
||||
):
|
||||
value = str(subject.external_references.get(key) or "").strip()
|
||||
if value:
|
||||
candidates.append(value)
|
||||
normalized = {value.strip() for value in candidates if value.strip()}
|
||||
return normalized.pop() if len(normalized) == 1 else None
|
||||
|
||||
|
||||
def _subject_email(subject: DsarSubjectRef) -> str | None:
|
||||
candidates = [subject.email] if subject.email else []
|
||||
for key in ("calendar.email", "calendar.attendee_email"):
|
||||
value = str(subject.external_references.get(key) or "").strip()
|
||||
if value:
|
||||
candidates.append(value)
|
||||
normalized = {
|
||||
email for value in candidates if (email := _normalized_email(value)) is not None
|
||||
}
|
||||
return normalized.pop() if len(normalized) == 1 else None
|
||||
|
||||
|
||||
def _normalized_email(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip().casefold()
|
||||
if normalized.startswith("mailto:"):
|
||||
normalized = normalized[7:]
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _first_value(value: object) -> object:
|
||||
if isinstance(value, list):
|
||||
return value[0] if value else None
|
||||
return value
|
||||
|
||||
|
||||
def _matching_fields(
|
||||
row: object,
|
||||
subject_user_id: str | None,
|
||||
fields: Sequence[str],
|
||||
) -> list[str]:
|
||||
if subject_user_id is None:
|
||||
return []
|
||||
return [field for field in fields if getattr(row, field) == subject_user_id]
|
||||
|
||||
|
||||
def _record(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
category: str,
|
||||
title: str,
|
||||
data: Mapping[str, object],
|
||||
*,
|
||||
observed_at: datetime | None = None,
|
||||
immutable: bool = False,
|
||||
retention_reason: str | None = None,
|
||||
source_path: str | None = None,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="calendar",
|
||||
module_id="calendar",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
category=category,
|
||||
title=title,
|
||||
data=data,
|
||||
observed_at=observed_at,
|
||||
immutable_evidence=immutable,
|
||||
retention_reason=retention_reason,
|
||||
source_path=source_path,
|
||||
)
|
||||
|
||||
|
||||
def _action(
|
||||
action_id: str,
|
||||
kind: str,
|
||||
record: DsarRecordRef,
|
||||
title: str,
|
||||
rationale: str,
|
||||
*,
|
||||
executable: bool,
|
||||
irreversible: bool = False,
|
||||
metadata: Mapping[str, object] | None = None,
|
||||
) -> DsarErasureActionRef:
|
||||
return DsarErasureActionRef(
|
||||
action_id=action_id,
|
||||
provider_id="calendar",
|
||||
module_id="calendar",
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=title,
|
||||
rationale=rationale,
|
||||
executable=executable,
|
||||
irreversible=irreversible,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
def _result(
|
||||
action: DsarErasureActionRef,
|
||||
status: str,
|
||||
summary: str,
|
||||
evidence: Mapping[str, object] | None = None,
|
||||
) -> DsarExecutionResultRef:
|
||||
return DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status=status, # type: ignore[arg-type]
|
||||
summary=summary,
|
||||
evidence=evidence or {},
|
||||
)
|
||||
|
||||
|
||||
def _blocked(action: DsarErasureActionRef, summary: str) -> DsarExecutionResultRef:
|
||||
return _result(action, "blocked", summary)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Calendar DSAR provider requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_rows(query: object) -> list[object]:
|
||||
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Calendar DSAR match limit exceeded; narrow the subject selectors."
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
__all__ = ["CALENDAR_DSAR_CAPABILITY", "CalendarDsarProvider"]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""German translations for public structured documentation metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'calendar.external-sources-and-sync': {'consequence_classes': {'change_collection': 'Änderung der '
|
||||
'Kalenderidentität, '
|
||||
'Quellkonfiguration, '
|
||||
'Anmeldeinformationen '
|
||||
'und '
|
||||
'Synchronisierungsrichtlinie',
|
||||
'delete_or_remove_collection': 'Löschen '
|
||||
'eines '
|
||||
'lokalen '
|
||||
'Kalenders '
|
||||
'oder '
|
||||
'Entfernen '
|
||||
'einer '
|
||||
'externen '
|
||||
'Quelle '
|
||||
'nach '
|
||||
'expliziter '
|
||||
'Ereignisbehandlung',
|
||||
'execute_remote_move': 'Kopieren '
|
||||
'aller '
|
||||
'Zielressourcen '
|
||||
'vor dem '
|
||||
'bedingten '
|
||||
'Löschen '
|
||||
'von '
|
||||
'Quellressourcen',
|
||||
'force_full_sync': 'Lesen Sie die '
|
||||
'vollständige '
|
||||
'Remote-Quelle '
|
||||
'erneut und '
|
||||
'versöhnen Sie '
|
||||
'sie mit dem '
|
||||
'lokalen Staat',
|
||||
'synchronize_source': 'Lesen und, '
|
||||
'wo '
|
||||
'konfiguriert, '
|
||||
'Schreiben '
|
||||
'des '
|
||||
'Remote-Zustands '
|
||||
'unter '
|
||||
'Verwendung '
|
||||
'von '
|
||||
'Begrenzten '
|
||||
'Synchronisationsnachweisen'}},
|
||||
'calendar.manage-calendars-and-events': {'consequence_classes': {'delete_event': 'Löschen des '
|
||||
'ausgewählten '
|
||||
'Ereignisses '
|
||||
'oder der '
|
||||
'ausgewählten '
|
||||
'Serie und '
|
||||
'Synchronisieren '
|
||||
'der '
|
||||
'Warteschlange '
|
||||
'bei Bedarf',
|
||||
'save_event': 'Erstellen oder '
|
||||
'Aktualisieren des '
|
||||
'maßgeblichen '
|
||||
'Ereignisses und '
|
||||
'Synchronisieren '
|
||||
'der Warteschlange '
|
||||
'bei Bedarf'}},
|
||||
'calendar.outbound-change-recovery': {'consequence_classes': {'reconcile_outbox': 'Vergleichen '
|
||||
'Sie die '
|
||||
'neueste '
|
||||
'gewünschte '
|
||||
'Generation mit '
|
||||
'dem '
|
||||
'Remote-Zustand, '
|
||||
'bevor Sie '
|
||||
'erneut '
|
||||
'versuchen oder '
|
||||
'verwerfen'}},
|
||||
'calendar.privacy.data-subject-requests': {'limitations': ['Rohe ICS und Steckverbinder-Locatoren '
|
||||
'sind nicht in den JSON-Export '
|
||||
'eingebettet; autorisierte '
|
||||
'Kalenderüberprüfung bleibt '
|
||||
'verbindlich.',
|
||||
'Event- und Teilnehmerlöschung ist '
|
||||
'manuell, da sich Wiederholung, '
|
||||
'gemeinsame Teilnahme, externe '
|
||||
'Synchronisierung und institutionelle '
|
||||
'Bindung überschneiden können.'],
|
||||
'prerequisites': ['Die Datenschutzanfrage und die '
|
||||
'Kalenderauswahl wurden unabhängig '
|
||||
'autorisiert und bestätigt.',
|
||||
'Der Rezensent versteht die '
|
||||
'effektiven Kalenderspeicherungs- '
|
||||
'und '
|
||||
'Synchronisationsnachweispflichten.'],
|
||||
'steps': ['Führen Sie die Kalenderanbietersuche aus '
|
||||
'und überprüfen Sie die isolierten '
|
||||
'Ereignis-, Teilnahme-, Präferenz-, '
|
||||
'Synchronisierungs-, Outbox- und '
|
||||
'Migrationsdatensätze.',
|
||||
'Bewahren Sie alle Gründe für die '
|
||||
'Aufbewahrung von Nachweisen mit der '
|
||||
'Fallentscheidung auf und koordinieren Sie '
|
||||
'jede Inhaltsänderung durch '
|
||||
'Kalender-Lebenszykluskontrollen.',
|
||||
'Führen Sie nur eine genehmigte Löschung der '
|
||||
'persönlichen Ansichtspräferenz aus.',
|
||||
'Überprüfen Sie die zugehörigen '
|
||||
'Terminplanungs- oder E-Mail-Datensätze über '
|
||||
'ihre eigenen DSAR-Anbieter, wenn diese '
|
||||
'Module installiert sind.']}}
|
||||
@@ -215,7 +215,82 @@ def add_event_raw_records(component: Event, event: Any) -> None:
|
||||
add_raw_property(component, "RELATED-TO", record)
|
||||
|
||||
|
||||
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]:
|
||||
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 10_000) -> list[dict[str, Any]]:
|
||||
"""Return every occurrence in range, or explicitly reject an unsafe expansion."""
|
||||
return expand_events_occurrences([event], range_start, range_end, limit=limit)[0]
|
||||
|
||||
|
||||
def expand_events_occurrences(
|
||||
events: list[Any], range_start: datetime, range_end: datetime,
|
||||
*, limit: int = 10_000, admission: Any = None,
|
||||
) -> list[list[dict[str, Any]]]:
|
||||
from govoplan_core.security.bounded_process import (
|
||||
ProcessBudgetError, bounded_operation_admission, run_bounded_operation,
|
||||
)
|
||||
from govoplan_core.security.worker_payload import (
|
||||
WorkerPayloadError, decode_worker_payload, encode_worker_payload,
|
||||
)
|
||||
from govoplan_calendar.backend.recurrence_worker import RECURRENCE_LIMITS, expand_recurrences_worker
|
||||
|
||||
if not events:
|
||||
return []
|
||||
if type(limit) is not int or not 1 <= limit <= 10_000 or len(events) > 2_000:
|
||||
raise ICalendarError("Recurrence expansion exceeds its limit; request a smaller range or fewer calendars.")
|
||||
try:
|
||||
if admission is None:
|
||||
with bounded_operation_admission() as reserved:
|
||||
return expand_events_occurrences(events, range_start, range_end, limit=limit, admission=reserved)
|
||||
projections = [
|
||||
{
|
||||
"uid": item.uid,
|
||||
"start_at": item.start_at,
|
||||
"end_at": item.end_at,
|
||||
"duration_seconds": getattr(item, "duration_seconds", None),
|
||||
"all_day": item.all_day,
|
||||
"timezone": getattr(item, "timezone", None),
|
||||
"rrule": item.rrule,
|
||||
"rdate": item.rdate or [],
|
||||
"exdate": item.exdate or [],
|
||||
}
|
||||
for item in events
|
||||
]
|
||||
payload = encode_worker_payload(
|
||||
{"events": projections, "start": range_start, "end": range_end, "limit": limit},
|
||||
max_bytes=RECURRENCE_LIMITS.input_bytes,
|
||||
)
|
||||
result = decode_worker_payload(
|
||||
run_bounded_operation(expand_recurrences_worker, payload, limits=RECURRENCE_LIMITS, admission=admission),
|
||||
max_bytes=RECURRENCE_LIMITS.output_bytes,
|
||||
)
|
||||
if not isinstance(result, dict) or result.get("error"):
|
||||
raise ICalendarError("Recurrence expansion is invalid or exceeds its limit; request a smaller range or fewer calendars.")
|
||||
batches = result.get("occurrences")
|
||||
if not isinstance(batches, list) or len(batches) != len(events):
|
||||
raise ICalendarError("Recurrence worker returned an invalid result.")
|
||||
count = 0
|
||||
for source, batch in zip(events, batches, strict=True):
|
||||
if not isinstance(batch, list):
|
||||
raise ICalendarError("Recurrence worker returned an invalid result.")
|
||||
count += len(batch)
|
||||
if count > limit:
|
||||
raise ICalendarError("Recurrence worker exceeded its result limit.")
|
||||
for item in batch:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or set(item) != {"uid", "recurrence_id", "start_at", "end_at", "all_day"}
|
||||
or item["uid"] != source.uid
|
||||
or not isinstance(item["recurrence_id"], str)
|
||||
or not isinstance(item["start_at"], datetime)
|
||||
or (item["end_at"] is not None and not isinstance(item["end_at"], datetime))
|
||||
or type(item["all_day"]) is not bool
|
||||
):
|
||||
raise ICalendarError("Recurrence worker returned an invalid result.")
|
||||
return batches
|
||||
except (ProcessBudgetError, WorkerPayloadError) as exc:
|
||||
raise ICalendarError("Recurrence expansion could not complete within its resource limits; narrow the request or retry later.") from exc
|
||||
|
||||
|
||||
def _expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 10_000) -> list[dict[str, Any]]:
|
||||
"""Expand an event's recurrence primitives within a range.
|
||||
|
||||
This is a backend primitive for later API/UI recurrence handling. It expands
|
||||
@@ -240,14 +315,20 @@ def expand_event_occurrences(event: Any, range_start: datetime, range_end: datet
|
||||
for exdate in temporal_values_from_records(event.exdate or []):
|
||||
rules.exdate(exdate)
|
||||
|
||||
starts = rules.between(range_start - duration, range_end, inc=True)
|
||||
occurrences: list[dict[str, Any]] = []
|
||||
for occurrence_start in starts:
|
||||
# Never materialize rules.between(): dense rules allocate the whole range
|
||||
# before a result limit can run. Sparse rules and pre-range scans are also
|
||||
# covered by the disposable worker's CPU/wall/memory limits.
|
||||
for scanned, occurrence_start in enumerate(rules, start=1):
|
||||
if scanned > 100_000:
|
||||
raise ICalendarError("Recurrence scan limit exceeded.")
|
||||
occurrence_start = normalize_datetime(occurrence_start)
|
||||
if occurrence_start > range_end:
|
||||
break
|
||||
if occurrence_overlaps(occurrence_start, duration, range_start, range_end):
|
||||
occurrences.append(occurrence_payload(occurrence_start, duration, event))
|
||||
if len(occurrences) >= limit:
|
||||
break
|
||||
raise ICalendarError("Recurrence result limit exceeded.")
|
||||
occurrences.append(occurrence_payload(occurrence_start, duration, event))
|
||||
return occurrences
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_calendar.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.calendar import (
|
||||
CALENDAR_AVAILABILITY_READ_SCOPE,
|
||||
CALENDAR_EVENT_WRITE_SCOPE,
|
||||
@@ -15,9 +21,14 @@ from govoplan_core.core.calendar import (
|
||||
CAPABILITY_CALENDAR_OUTBOX,
|
||||
CAPABILITY_CALENDAR_SCHEDULING,
|
||||
)
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
@@ -28,6 +39,10 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
QuickAccessTool,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
@@ -42,6 +57,7 @@ from govoplan_core.core.provider_governance import (
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_calendar.backend.dsar_provider import CALENDAR_DSAR_CAPABILITY
|
||||
from govoplan_calendar.backend.search_source import create_calendar_search_source
|
||||
|
||||
|
||||
@@ -105,6 +121,7 @@ CALENDAR_ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _read_only_calendar_provider(
|
||||
*,
|
||||
provider_id: str,
|
||||
@@ -244,13 +261,27 @@ CALENDAR_EXTERNAL_PROVIDERS = (
|
||||
ProviderObjectDeclaration(
|
||||
object_type="calendar_collection",
|
||||
field_groups=("identity", "display", "sync_state", "resource_mapping"),
|
||||
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
|
||||
authority_modes=(
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
),
|
||||
default_authority_mode="governed_sync",
|
||||
),
|
||||
ProviderObjectDeclaration(
|
||||
object_type="calendar_event",
|
||||
field_groups=("identity", "schedule", "recurrence", "participants", "content"),
|
||||
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
|
||||
field_groups=(
|
||||
"identity",
|
||||
"schedule",
|
||||
"recurrence",
|
||||
"participants",
|
||||
"content",
|
||||
),
|
||||
authority_modes=(
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
),
|
||||
default_authority_mode="governed_sync",
|
||||
),
|
||||
),
|
||||
@@ -267,19 +298,37 @@ CALENDAR_EXTERNAL_PROVIDERS = (
|
||||
outcome_unknown="A timed-out remote write is outcome-unknown and is reconciled before retry.",
|
||||
outcome_unknown_supported=True,
|
||||
evidence="The Open-Xchange profile reference, mapping references, operation intent, ETag, and reconciliation result are retained.",
|
||||
audit_event_types=("calendar.sync.requested", "calendar.sync.completed", "calendar.sync.conflict", "calendar.sync.reconciled"),
|
||||
audit_event_types=(
|
||||
"calendar.sync.requested",
|
||||
"calendar.sync.completed",
|
||||
"calendar.sync.conflict",
|
||||
"calendar.sync.reconciled",
|
||||
),
|
||||
correction="A later conditional update or tombstone corrects external state after reconciliation.",
|
||||
rollback="Remote effects are not treated as transactionally rollback-safe.",
|
||||
compensation="A compensating event update or delete may be queued after remote state is known.",
|
||||
reconciliation="Read by CalDAV href and VEVENT UID, compare ETag and content, then classify the result.",
|
||||
outage="The local projection remains available with stale markers and durable pending writes.",
|
||||
classifications=("internal", "confidential", "restricted"),
|
||||
purposes=("calendar collaboration", "availability", "resource booking", "meeting coordination"),
|
||||
purposes=(
|
||||
"calendar collaboration",
|
||||
"availability",
|
||||
"resource booking",
|
||||
"meeting coordination",
|
||||
),
|
||||
retention="Calendar event, outbox, audit, profile-binding, and credential retention remain independent policies.",
|
||||
secret_handling="Calendar stores only scoped credential references or encrypted Calendar-owned credentials.",
|
||||
),
|
||||
capability_names=(CAPABILITY_CALENDAR_EXTERNAL_PROFILES, CAPABILITY_CALENDAR_OUTBOX, CAPABILITY_CALENDAR_SCHEDULING),
|
||||
interface_names=("calendar.external_profiles", "calendar.outbox", "calendar.scheduling"),
|
||||
capability_names=(
|
||||
CAPABILITY_CALENDAR_EXTERNAL_PROFILES,
|
||||
CAPABILITY_CALENDAR_OUTBOX,
|
||||
CAPABILITY_CALENDAR_SCHEDULING,
|
||||
),
|
||||
interface_names=(
|
||||
"calendar.external_profiles",
|
||||
"calendar.outbox",
|
||||
"calendar.scheduling",
|
||||
),
|
||||
documentation_topic_ids=("calendar.external-sources-and-sync",),
|
||||
),
|
||||
_read_only_calendar_provider(
|
||||
@@ -320,10 +369,18 @@ def _calendar_retirement_provider(session: object | None, module_id: str):
|
||||
return plan
|
||||
|
||||
def executor(execute_session: object, execute_module_id: str) -> None:
|
||||
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
|
||||
raise RuntimeError("No database session is available for Calendar credential retirement.")
|
||||
if inspect(execute_session.get_bind()).has_table(calendar_models.CalendarSyncCredential.__tablename__):
|
||||
from govoplan_calendar.backend.service import delete_calendar_credentials_for_retirement
|
||||
if not hasattr(execute_session, "get_bind") or not hasattr(
|
||||
execute_session, "query"
|
||||
):
|
||||
raise RuntimeError(
|
||||
"No database session is available for Calendar credential retirement."
|
||||
)
|
||||
if inspect(execute_session.get_bind()).has_table(
|
||||
calendar_models.CalendarSyncCredential.__tablename__
|
||||
):
|
||||
from govoplan_calendar.backend.service import (
|
||||
delete_calendar_credentials_for_retirement,
|
||||
)
|
||||
|
||||
delete_calendar_credentials_for_retirement(execute_session)
|
||||
base_executor(execute_session, execute_module_id)
|
||||
@@ -353,15 +410,51 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission("calendar:calendar:read", "View calendars", "List tenant calendar collections and metadata."),
|
||||
_permission("calendar:calendar:write", "Manage calendars", "Create and edit tenant calendar collections."),
|
||||
_permission("calendar:calendar:admin", "Administer calendars", "Delete calendars and manage tenant-level calendar settings."),
|
||||
_permission("calendar:event:read", "View calendar events", "List and inspect calendar events."),
|
||||
_permission(CALENDAR_EVENT_WRITE_SCOPE, "Manage calendar events", "Create and edit calendar events."),
|
||||
_permission("calendar:event:delete", "Delete calendar events", "Delete or cancel calendar events where policy allows it."),
|
||||
_permission("calendar:event:import", "Import iCalendar events", "Import VEVENT data from iCalendar sources."),
|
||||
_permission("calendar:event:export", "Export iCalendar events", "Export events as text/calendar VEVENT data."),
|
||||
_permission(CALENDAR_AVAILABILITY_READ_SCOPE, "Read availability", "Read free/busy and availability data for integrations."),
|
||||
_permission(
|
||||
"calendar:calendar:read",
|
||||
"View calendars",
|
||||
"List tenant calendar collections and metadata.",
|
||||
),
|
||||
_permission(
|
||||
"calendar:calendar:write",
|
||||
"Manage calendars",
|
||||
"Create and edit tenant calendar collections.",
|
||||
),
|
||||
_permission(
|
||||
"calendar:calendar:admin",
|
||||
"Administer calendars",
|
||||
"Delete calendars and manage tenant-level calendar settings.",
|
||||
),
|
||||
_permission(
|
||||
"calendar:event:read",
|
||||
"View calendar events",
|
||||
"List and inspect calendar events.",
|
||||
),
|
||||
_permission(
|
||||
CALENDAR_EVENT_WRITE_SCOPE,
|
||||
"Manage calendar events",
|
||||
"Create and edit calendar events.",
|
||||
),
|
||||
_permission(
|
||||
"calendar:event:delete",
|
||||
"Delete calendar events",
|
||||
"Delete or cancel calendar events where policy allows it.",
|
||||
),
|
||||
_permission(
|
||||
"calendar:event:import",
|
||||
"Import iCalendar events",
|
||||
"Import VEVENT data from iCalendar sources.",
|
||||
),
|
||||
_permission(
|
||||
"calendar:event:export",
|
||||
"Export iCalendar events",
|
||||
"Export events as text/calendar VEVENT data.",
|
||||
),
|
||||
_permission(
|
||||
CALENDAR_AVAILABILITY_READ_SCOPE,
|
||||
"Read availability",
|
||||
"Read free/busy and availability data for integrations.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
@@ -405,11 +498,32 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
)
|
||||
|
||||
return {
|
||||
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
|
||||
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
|
||||
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
|
||||
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
|
||||
"calendar_view_preferences": session.query(CalendarViewPreference).filter(CalendarViewPreference.tenant_id == tenant_id).count(),
|
||||
"calendars": session.query(CalendarCollection)
|
||||
.filter(
|
||||
CalendarCollection.tenant_id == tenant_id,
|
||||
CalendarCollection.deleted_at.is_(None),
|
||||
)
|
||||
.count(),
|
||||
"calendar_events": session.query(CalendarEvent)
|
||||
.filter(
|
||||
CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)
|
||||
)
|
||||
.count(),
|
||||
"calendar_sync_sources": session.query(CalendarSyncSource)
|
||||
.filter(
|
||||
CalendarSyncSource.tenant_id == tenant_id,
|
||||
CalendarSyncSource.deleted_at.is_(None),
|
||||
)
|
||||
.count(),
|
||||
"calendar_sync_credentials": session.query(CalendarSyncCredential)
|
||||
.filter(
|
||||
CalendarSyncCredential.tenant_id == tenant_id,
|
||||
CalendarSyncCredential.deleted_at.is_(None),
|
||||
)
|
||||
.count(),
|
||||
"calendar_view_preferences": session.query(CalendarViewPreference)
|
||||
.filter(CalendarViewPreference.tenant_id == tenant_id)
|
||||
.count(),
|
||||
"calendar_outbox_pending": session.query(CalendarOutboxOperation)
|
||||
.filter(
|
||||
CalendarOutboxOperation.tenant_id == tenant_id,
|
||||
@@ -459,11 +573,20 @@ def _calendar_outbox_provider(context: ModuleContext) -> object:
|
||||
|
||||
def _calendar_external_profile_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_calendar.backend.capabilities import SqlCalendarExternalProfileProvider
|
||||
from govoplan_calendar.backend.capabilities import (
|
||||
SqlCalendarExternalProfileProvider,
|
||||
)
|
||||
|
||||
return SqlCalendarExternalProfileProvider()
|
||||
|
||||
|
||||
def _calendar_dsar_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_calendar.backend.dsar_provider import CalendarDsarProvider
|
||||
|
||||
return CalendarDsarProvider()
|
||||
|
||||
|
||||
def _caldav_provider_states(context):
|
||||
from govoplan_calendar.backend.provider_state import caldav_provider_states
|
||||
|
||||
@@ -497,14 +620,28 @@ def _open_xchange_provider_states(context):
|
||||
manifest = ModuleManifest(
|
||||
id="calendar",
|
||||
name="Calendar",
|
||||
version="0.1.15",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow_engine", "notifications", "dms", "connectors", "search"),
|
||||
version="0.1.24",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
optional_dependencies=(
|
||||
"mail",
|
||||
"tasks",
|
||||
"scheduling",
|
||||
"appointments",
|
||||
"workflow_engine",
|
||||
"notifications",
|
||||
"dms",
|
||||
"connectors",
|
||||
"search",
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"),
|
||||
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"),
|
||||
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.9"),
|
||||
ModuleInterfaceProvider(name="calendar.invitations", version="0.2.0"),
|
||||
ModuleInterfaceProvider(name="calendar.external_profiles", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name=CALENDAR_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -554,6 +691,176 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="calendar.complete-bounded-availability",
|
||||
title="Complete availability and bounded recurrence expansion",
|
||||
summary="Availability either includes every busy occurrence or reports that the request could not complete.",
|
||||
body=(
|
||||
"Expanded ranges are limited to 400 days. A request admits at most 2,000 calendars and loads at most 2,000 "
|
||||
"series, direct events, or override candidates per category. Recurrences run together in one disposable, "
|
||||
"data-only worker with shared non-queuing admission, 5 seconds wall time, 3 seconds CPU, 256 MiB memory, "
|
||||
"and 4 MiB input/output budgets. Results are limited to 10,000 occurrences in total; each series scans at "
|
||||
"most 100,000 recurrence candidates. These are resource boundaries, not an arbitrary-code sandbox. "
|
||||
"On SQLite and PostgreSQL, candidate identity/size projections enforce a shared 4 MiB data budget in SQL "
|
||||
"before text/JSON fields reach the application; raw iCalendar source is never loaded for expansion. The full "
|
||||
"expanded response has a separate 4 MiB JSON byte limit, including repeated descriptions and metadata. "
|
||||
"Free/busy loads only scheduling fields, so large descriptions or attachments do not prevent an otherwise "
|
||||
"bounded, complete availability answer. Unsupported database dialects fail explicitly. "
|
||||
"An exceeded limit or unavailable worker causes an explicit error, never a partial free/busy answer. "
|
||||
"Narrow the range or calendars, or retry if capacity is busy. A UI response limit only slices a successfully "
|
||||
"completed result; it does not hide failed expansion. Existing tenant/calendar visibility, overrides, "
|
||||
"cancellations, exclusions, timezone handling, and stored iCalendar properties remain unchanged. "
|
||||
"Verifying an individual recurrence uses the same worker boundary; failure does not edit the occurrence."
|
||||
),
|
||||
layer="always", documentation_types=("user", "admin"),
|
||||
audience=("user", "calendar_manager", "administrator"), order=19,
|
||||
translations={"de": {
|
||||
"title": "Vollständige Verfügbarkeit und begrenzte Serienberechnung",
|
||||
"summary": "Die Verfügbarkeit enthält alle belegten Termine oder meldet ausdrücklich eine unvollendete Anfrage.",
|
||||
"body": (
|
||||
"Erweiterte Zeiträume sind auf 400 Tage begrenzt. Eine Anfrage umfasst höchstens 2.000 Kalender und lädt "
|
||||
"jeweils höchstens 2.000 Serien-, Direkttermin- oder Ausnahmekandidaten. Serien werden gemeinsam in einem "
|
||||
"kurzlebigen Worker ohne Datenbankzugriff berechnet: gemeinsame Zulassung ohne Warteschlange, 5 Sekunden "
|
||||
"Laufzeit, 3 Sekunden CPU, 256 MiB Speicher und je 4 MiB Ein-/Ausgabe. Insgesamt sind höchstens 10.000 "
|
||||
"Vorkommen erlaubt; pro Serie werden maximal 100.000 Kandidaten durchlaufen. Dies ist eine Ressourcengrenze, "
|
||||
"keine Sandbox für beliebigen Code. Unter SQLite und PostgreSQL begrenzen Kennungs-/Größenprojektionen "
|
||||
"bereits in SQL die gemeinsam geladenen Kandidatendaten auf 4 MiB, bevor Text-/JSON-Felder die Anwendung "
|
||||
"erreichen; die rohe iCalendar-Quelle wird für die Erweiterung nicht geladen. Die vollständige erweiterte "
|
||||
"Antwort hat ein separates JSON-Bytelimit von 4 MiB einschließlich wiederholter Beschreibungen und Metadaten. "
|
||||
"Frei/Belegt lädt nur Planungsfelder, sodass große Beschreibungen oder Anhänge eine ansonsten begrenzte, "
|
||||
"vollständige Verfügbarkeitsantwort nicht verhindern. Nicht unterstützte Datenbankdialekte melden einen "
|
||||
"ausdrücklichen Fehler. Überschrittene Grenzen oder nicht verfügbare Worker erzeugen einen "
|
||||
"ausdrücklichen Fehler, niemals eine unvollständige Frei-/Belegt-Antwort. Zeitraum oder Kalenderauswahl "
|
||||
"verkleinern beziehungsweise bei ausgelasteter Kapazität später erneut versuchen. Ein UI-Ausgabelimit kürzt "
|
||||
"nur ein vollständig berechnetes Ergebnis und verdeckt keine fehlgeschlagene Berechnung. Bestehende "
|
||||
"Mandanten-/Kalendersichtbarkeit, Ausnahmen, Absagen, Ausschlüsse, Zeitzonenbehandlung und gespeicherte "
|
||||
"iCalendar-Eigenschaften bleiben erhalten. Einzelne Serienvorkommen werden unter derselben Worker-Grenze "
|
||||
"geprüft; ein Fehlschlag ändert das Vorkommen nicht."
|
||||
),
|
||||
}},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="calendar.privacy.data-subject-requests",
|
||||
title="Review Calendar data in a data-subject request",
|
||||
summary="Collect tenant-scoped event, participation, preference, synchronization, and migration metadata without disclosing connector secrets.",
|
||||
body=(
|
||||
"Calendar's DSAR provider searches the effective tenant by normalized organizer or attendee email, direct membership references, and namespaced Calendar collection or event references. "
|
||||
"It returns only the matching organizer or attendee fragment alongside authorized event and collection context, personal view preferences, owned synchronization configuration, durable outbox outcomes, and migration evidence. It excludes raw ICS, complete iCalendar payloads, collection URLs, remote hrefs and ETags, sync tokens, credentials, encrypted secrets, worker leases, idempotency material, provider error text, and unrelated participants or events. "
|
||||
"Synchronized, correlated, deleted, queued, and migrated state remains retained with a reason because it is institutional delivery, reconciliation, or recovery evidence. Local event, collection, and active credential metadata requires coordinated manual review. The provider can idempotently delete the subject's personal Calendar view preference after revalidating tenant and ownership; it never mutates an event, attendee list, synchronized resource, credential, or migration record directly."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=(
|
||||
"privacy_officer",
|
||||
"calendar_manager",
|
||||
"records_manager",
|
||||
"operator",
|
||||
),
|
||||
order=17,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("calendar", "access"),
|
||||
any_scopes=(
|
||||
"access:privacy:read",
|
||||
"access:privacy:manage",
|
||||
"access:privacy:erase",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Data-subject requests",
|
||||
href="/admin?section=tenant-data-subject-requests",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Calendar integration concept",
|
||||
href="govoplan-calendar/docs/CALENDAR_INTEGRATION_CONCEPT.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
related_modules=("access", "audit", "ops", "scheduling"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Calendar-Daten in einer Betroffenenanfrage prüfen",
|
||||
"summary": (
|
||||
"Mandantenbezogene Ereignis-, Teilnahme-, Präferenz-, Synchronisations- und Migrationsmetadaten erfassen, ohne "
|
||||
"Connector-Geheimnisse offenzulegen."
|
||||
),
|
||||
"body": (
|
||||
"Der DSAR-Provider von Calendar durchsucht den wirksamen Mandanten anhand normalisierter E-Mail-Adressen von "
|
||||
"Organisierenden oder Teilnehmenden, direkter Mitgliedschaftsverweise und namensraumgebundener Calendar-Sammlungs- oder "
|
||||
"Ereignisverweise. Er liefert nur den passenden Organisierenden- oder Teilnehmendenausschnitt zusammen mit berechtigtem "
|
||||
"Ereignis- und Sammlungskontext, persönlichen Ansichtspräferenzen, eigener Synchronisationskonfiguration, dauerhaften "
|
||||
"Outbox-Ergebnissen und Migrationsnachweisen. Ausgeschlossen sind rohe ICS- und vollständige iCalendar-Daten, "
|
||||
"Sammlungs-URLs, entfernte hrefs und ETags, Synchronisationstoken, Zugangsdaten, verschlüsselte Geheimnisse, Worker-Leases, "
|
||||
"Idempotenzmaterial, Provider-Fehlertexte sowie unbeteiligte Personen oder Ereignisse. Synchronisierte, korrelierte, "
|
||||
"gelöschte, eingereihte und migrierte Zustände bleiben mit Begründung erhalten, weil sie institutionelle Liefer-, Abgleich- "
|
||||
"oder Wiederherstellungsnachweise sind. Lokale Ereignis-, Sammlungs- und aktive Zugangsdatenmetadaten erfordern eine "
|
||||
"koordinierte manuelle Prüfung. Nach erneuter Prüfung von Mandant und Eigentum darf der Provider die persönliche "
|
||||
"Calendar-Ansichtspräferenz idempotent löschen; Ereignisse, Teilnehmerlisten, synchronisierte Ressourcen, Zugangsdaten "
|
||||
"oder Migrationsdatensätze verändert er niemals direkt."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=tenant-data-subject-requests",
|
||||
"screen": "Data-subject requests",
|
||||
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||
"prerequisites": [
|
||||
"The privacy request and Calendar selectors have been independently authorized and corroborated.",
|
||||
"The reviewer understands the effective Calendar retention and synchronization-evidence obligations.",
|
||||
],
|
||||
"steps": [
|
||||
"Run the Calendar provider search and review the isolated event, participation, preference, sync, outbox, and migration records.",
|
||||
"Keep every evidence retention reason with the case decision and coordinate any content change through Calendar lifecycle controls.",
|
||||
"Execute only an approved personal view-preference deletion.",
|
||||
"Review related Scheduling or Mail records through their owning DSAR providers when those modules are installed.",
|
||||
],
|
||||
"limitations": [
|
||||
"Raw ICS and connector locators are not embedded in the JSON export; authorized Calendar review remains authoritative.",
|
||||
"Event and attendee erasure is manual because recurrence, shared participation, external synchronization, and institutional retention can overlap.",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="calendar.quick-access-and-product-area",
|
||||
title="Calendar in product navigation and Quick Access",
|
||||
summary="Use Calendar in Meetings and decisions and keep an optional compact agenda beside current work.",
|
||||
body=(
|
||||
"Calendar contributes its workspace to the stable Calendar destination at /agenda in Meetings and decisions. "
|
||||
"The owner route /calendar remains available through All available tools and as a compatible deep link. When Quick Access is enabled, "
|
||||
"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"),
|
||||
audience=("user", "calendar_manager", "administrator"),
|
||||
related_modules=("quick_access", "views"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kalender in Produktnavigation und Schnellzugriff",
|
||||
"summary": "Den Kalender unter Termine und Entscheidungen sowie optional als kompakte Agenda neben der aktuellen Arbeit verwenden.",
|
||||
"body": (
|
||||
"Calendar ordnet seinen Arbeitsbereich dem stabilen Produktziel Kalender unter /agenda in Termine und Entscheidungen zu. "
|
||||
"Der Eigentümerpfad /calendar bleibt unter Alle verfügbaren Werkzeuge und als kompatibler Direktlink erreichbar. Ist der Schnellzugriff aktiviert, "
|
||||
"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."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": ["calendar.quick_access.agenda"],
|
||||
},
|
||||
order=18,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="calendar.search.events",
|
||||
title="Search authorized calendar events",
|
||||
@@ -568,16 +875,53 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "calendar_manager", "administrator"),
|
||||
related_modules=("search",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Berechtigte Kalenderereignisse durchsuchen",
|
||||
"summary": (
|
||||
"Ereignistitel, Zeitpläne, Orte und Beschreibungen für die berechtigungsbewusste Plattform-Suche bereitstellen."
|
||||
),
|
||||
"body": (
|
||||
"Ist Search installiert, liefert Calendar nicht gelöschte Ereignisse aus sichtbaren Kalendern. Jedes Ergebnis bleibt "
|
||||
"mandantengebunden und prüft vor der Ausgabe erneut die aktuelle Leseberechtigung für Ereignisse sowie bei privaten "
|
||||
"Kalendern Eigentum oder Gruppenmitgliedschaft. Festgeschriebene Ereignisänderungen aktualisieren den abgeleiteten Index "
|
||||
"über die Plattform-Event-Outbox; ein Neuaufbau von Search verändert niemals Calendar-Daten."
|
||||
),
|
||||
}
|
||||
},
|
||||
order=19,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="calendar.manage-calendars-and-events",
|
||||
title="Use calendars and events",
|
||||
summary="Create calendar collections and work with all-day or timed events in continuous, month, week, workweek, and day views.",
|
||||
body="Calendar remembers the selected view and preferences. Events can be created, edited, moved, resized, repeated, imported, exported, or deleted when the current account has the corresponding permission. All-day events use dates rather than local clock times; timed events retain their timezone-aware start and end values.",
|
||||
body="Documentation books sit beside Calendars, Calendar display, and the relevant event, source, "
|
||||
"move, or recovery dialog title. Field help remains beside its label. "
|
||||
"Calendar remembers the selected view and preferences. Events can be created, edited, moved, resized, repeated, imported, exported, or deleted when the current account has the corresponding permission. All-day events use dates rather than local clock times; timed events retain their timezone-aware start and end values. Scheduling may promote a selected tentative hold in place and submit unused holds for idempotent release through the neutral Calendar capability. Calendar owns the resulting local tombstone, synchronized outbox operation, retry and reconciliation evidence; an already released or absent event is an accepted replay rather than a duplicate failure.",
|
||||
documentation_types=("user",),
|
||||
audience=("user", "calendar_manager"),
|
||||
related_modules=("scheduling", "notifications"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kalender und Ereignisse verwenden",
|
||||
"summary": (
|
||||
"Kalendersammlungen anlegen und ganztägige oder zeitgebundene Ereignisse in fortlaufender, Monats-, Wochen-, "
|
||||
"Arbeitswochen- und Tagesansicht bearbeiten."
|
||||
),
|
||||
"body": (
|
||||
"Dokumentationsbücher stehen neben Kalender, Kalenderanzeige und dem jeweiligen Dialogtitel "
|
||||
"für Ereignisse, Quellen, Verschiebungen oder Wiederherstellung. Feldhilfe bleibt neben der "
|
||||
"Feldbezeichnung. "
|
||||
"Calendar merkt sich gewählte Ansicht und Präferenzen. Ereignisse können mit der entsprechenden Berechtigung angelegt, "
|
||||
"bearbeitet, verschoben, in ihrer Dauer geändert, wiederholt, importiert, exportiert oder gelöscht werden. Ganztägige "
|
||||
"Ereignisse verwenden Datumswerte statt lokaler Uhrzeiten; zeitgebundene Ereignisse bewahren zeitzonenbezogene Start- und "
|
||||
"Endwerte. Scheduling darf eine ausgewählte vorläufige Vormerkung direkt bestätigen und nicht verwendete Vormerkungen "
|
||||
"über die neutrale Calendar-Fähigkeit idempotent freigeben. Calendar besitzt den entstehenden lokalen Löschmarker, die "
|
||||
"synchronisierte Outbox-Operation sowie Wiederholungs- und Abgleichsnachweise; ein bereits freigegebenes oder fehlendes "
|
||||
"Ereignis gilt bei Wiederholung als akzeptiert und nicht als doppelter Fehler."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
@@ -600,10 +944,38 @@ manifest = ModuleManifest(
|
||||
id="calendar.external-sources-and-sync",
|
||||
title="Connect and synchronize external calendars",
|
||||
summary="Calendar supports local collections, two-way CalDAV and Open-Xchange profiles, and read-only ICS/webcal, Microsoft Graph, and Exchange Web Services sources.",
|
||||
body="Each external source keeps its URL, synchronization direction, status, and credential reference with the Calendar collection. Open-Xchange uses the proven CalDAV transport while retaining connector-profile, identity/group-mapping, and resource-calendar references. Manual or scheduled synchronization records bounded outcomes. Scheduled source and outbox workers partition work by tenant entitlement; disabling Calendar preserves accepted operations and reports operator action instead of contacting a remote provider. CalDAV writes use conditional requests and durable outbox state; conflicts and unknown outcomes require synchronization or explicit reconciliation instead of blind repetition. Moving between two-way CalDAV calendars is an administrator-authorized migration batch: all destination resources must be copied before any source resource is conditionally deleted with its recorded ETag. Calendar and event changes remain locked while progress, conflicts, cancellation eligibility, and evidence are visible. Removing an external source removes the connection, while deleting a local calendar deletes its owned events after confirmation or transfer.",
|
||||
body="Documentation books sit beside Calendars, Calendar display, and the relevant event, source, "
|
||||
"move, or recovery dialog title. Field help remains beside its label. "
|
||||
"Each external source keeps its URL, synchronization direction, status, and credential reference with the Calendar collection. Open-Xchange uses the proven CalDAV transport while retaining connector-profile, identity/group-mapping, and resource-calendar references. Manual or scheduled synchronization records bounded outcomes. Scheduled source and outbox workers partition work by tenant entitlement; disabling Calendar preserves accepted operations and reports operator action instead of contacting a remote provider. CalDAV writes use conditional requests and durable outbox state; conflicts and unknown outcomes require synchronization or explicit reconciliation instead of blind repetition. Moving between two-way CalDAV calendars is an administrator-authorized migration batch: all destination resources must be copied before any source resource is conditionally deleted with its recorded ETag. Calendar and event changes remain locked while progress, conflicts, cancellation eligibility, and evidence are visible. Removing an external source removes the connection, while deleting a local calendar deletes its owned events after confirmation or transfer.",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "calendar_manager", "operator"),
|
||||
related_modules=("connectors", "audit", "ops"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Externe Kalender verbinden und synchronisieren",
|
||||
"summary": (
|
||||
"Calendar unterstützt lokale Sammlungen, bidirektionale CalDAV- und Open-Xchange-Profile sowie schreibgeschützte "
|
||||
"ICS-/webcal-, Microsoft-Graph- und Exchange-Web-Services-Quellen."
|
||||
),
|
||||
"body": (
|
||||
"Dokumentationsbücher stehen neben Kalender, Kalenderanzeige und dem jeweiligen Dialogtitel "
|
||||
"für Ereignisse, Quellen, Verschiebungen oder Wiederherstellung. Feldhilfe bleibt neben der "
|
||||
"Feldbezeichnung. "
|
||||
"Jede externe Quelle hält URL, Synchronisationsrichtung, Status und Zugangsdatenverweis gemeinsam mit der "
|
||||
"Calendar-Sammlung. Open-Xchange verwendet den bewährten CalDAV-Transport und bewahrt dabei Connector-Profil-, "
|
||||
"Identitäts-/Gruppenzuordnungs- und Ressourcenkalenderverweise. Manuelle und geplante Synchronisation zeichnet begrenzte "
|
||||
"Ergebnisse auf. Geplante Quellen- und Outbox-Worker teilen Arbeit nach Mandantenberechtigung; wird Calendar deaktiviert, "
|
||||
"bleiben angenommene Vorgänge erhalten und verlangen Betriebshandeln, statt einen entfernten Provider anzusprechen. "
|
||||
"CalDAV-Schreibvorgänge verwenden bedingte Anfragen und dauerhaften Outbox-Zustand. Konflikte und unbekannte Ergebnisse "
|
||||
"erfordern Synchronisation oder ausdrücklichen Abgleich statt blinder Wiederholung. Das Verschieben zwischen zwei "
|
||||
"bidirektionalen CalDAV-Kalendern ist ein administrativ genehmigter Migrationsstapel: Alle Zielressourcen müssen kopiert "
|
||||
"sein, bevor eine Quellressource bedingt mit ihrem aufgezeichneten ETag gelöscht wird. Änderungen an Kalender und "
|
||||
"Ereignissen bleiben gesperrt, während Fortschritt, Konflikte, Abbruchmöglichkeit und Nachweise sichtbar sind. Das "
|
||||
"Entfernen einer externen Quelle entfernt die Verbindung; das Löschen eines lokalen Kalenders löscht dessen Ereignisse "
|
||||
"nach Bestätigung oder Übertragung."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
@@ -640,6 +1012,23 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
related_modules=("campaigns", "mail", "audit"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Campaign-Einladungen und Antworten nachverfolgen",
|
||||
"summary": (
|
||||
"Akzeptierte Campaign-Einladungslieferungen als korrelierte VEVENTs spiegeln und den Teilnahmestatus in Calendar "
|
||||
"maßgeblich halten."
|
||||
),
|
||||
"body": (
|
||||
"Campaign kann vor der Zustellung einen METHOD:REQUEST-Anhang erzeugen und das korrelierte Calendar-Ereignis erst nach "
|
||||
"Annahme der Lieferung anlegen oder aktualisieren. Calendar speichert Korrelation, PARTSTAT der Teilnehmenden, "
|
||||
"Antwortzeit, begrenzte Nachweise, Synchronisationszustand und etwaiges eingeschränktes Verhalten. Beobachtet Mail einen "
|
||||
"berechtigten IMAP-Ordner, werden METHOD:REPLY-Teile idempotent an Calendar weitergeleitet. Campaign-Berichte fragen den "
|
||||
"aktuellen Zustand gesammelt ab, statt ihn in Campaign-Datensätze zu kopieren. Wiederkehrende Campaign-Einladungsserien "
|
||||
"bleiben ein eigener Ablauf."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={"kind": "workflow"},
|
||||
),
|
||||
DocumentationTopic(
|
||||
@@ -657,6 +1046,22 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("calendar_manager", "operator", "tenant_admin"),
|
||||
related_modules=("ops", "audit"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Synchronisierte Kalenderschreibvorgänge wiederherstellen",
|
||||
"summary": (
|
||||
"Ungelöste CalDAV-Schreibvorgänge prüfen und ausschließlich die neueste sichere Ressourcengeneration wiederherstellen."
|
||||
),
|
||||
"body": (
|
||||
"Calendar-Administrierende öffnen Ausgehende Änderungen in den Einstellungen eines synchronisierten Kalenders. Die "
|
||||
"begrenzte Historie erläutert Versuche, Konflikte, endgültig fehlgeschlagene Arbeit, veraltete Generationen, deaktivierte "
|
||||
"Quellen und aktive Worker-Leases. Wiederholen reiht einen fehlgeschlagenen Sollzustand erneut ein, Abgleichen vergleicht "
|
||||
"ihn mit der entfernten Ressource und Verwerfen gibt den lokalen Sollzustand nach einer getrennten Warnung auf, sodass die "
|
||||
"nächste vollständige Synchronisation den entfernten Zustand übernehmen kann. Automatische Auslieferung und fällige "
|
||||
"Quellenausführung bleiben reine Worker-Operationen für Dienstkonten und werden nicht als interaktive Steuerungen angeboten."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "runbook",
|
||||
"help_contexts": [
|
||||
@@ -674,8 +1079,26 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider,
|
||||
CAPABILITY_CALENDAR_INVITATIONS: _calendar_invitation_provider,
|
||||
CAPABILITY_CALENDAR_EXTERNAL_PROFILES: _calendar_external_profile_provider,
|
||||
CALENDAR_DSAR_CAPABILITY: _calendar_dsar_provider,
|
||||
},
|
||||
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
|
||||
capability_documentation={
|
||||
CALENDAR_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Calendar data-subject request provider",
|
||||
summary="Finds isolated Calendar participation and lifecycle metadata and classifies governed erasure actions.",
|
||||
contract_version="0.1.0",
|
||||
documentation_types=("admin",),
|
||||
audience=("privacy_officer", "calendar_manager", "records_manager"),
|
||||
),
|
||||
},
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/calendar",
|
||||
label="Calendar",
|
||||
icon="calendar",
|
||||
required_any=("calendar:event:read",),
|
||||
order=55,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id="calendar",
|
||||
package_name="@govoplan/calendar-webui",
|
||||
@@ -687,7 +1110,15 @@ manifest = ModuleManifest(
|
||||
order=55,
|
||||
),
|
||||
),
|
||||
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/calendar",
|
||||
label="Calendar",
|
||||
icon="calendar",
|
||||
required_any=("calendar:event:read",),
|
||||
order=55,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="calendar.navigation",
|
||||
@@ -781,6 +1212,66 @@ manifest = ModuleManifest(
|
||||
label="Calendar preferences",
|
||||
order=45,
|
||||
),
|
||||
ViewSurface(
|
||||
id="calendar.quick_access.agenda",
|
||||
module_id="calendar",
|
||||
kind="quick_access",
|
||||
label="Calendar Quick Access",
|
||||
order=50,
|
||||
),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="meetings-decisions",
|
||||
module_id="calendar",
|
||||
label="i18n:govoplan-core.product_area.meetings_decisions",
|
||||
icon="calendar",
|
||||
description="i18n:govoplan-core.product_area.meetings_decisions_description",
|
||||
surface_ids=("calendar.nav.calendar", "calendar.route.calendar"),
|
||||
order=50,
|
||||
),
|
||||
),
|
||||
product_surfaces=(
|
||||
ProductSurfaceContribution(
|
||||
id="meetings.calendar",
|
||||
module_id="calendar",
|
||||
label="i18n:govoplan-core.product_surface.calendar",
|
||||
description="i18n:govoplan-core.product_surface.calendar_description",
|
||||
icon="calendar",
|
||||
entry_path="/agenda",
|
||||
route_path="/calendar",
|
||||
surface_ids=("calendar.nav.calendar", "calendar.route.calendar"),
|
||||
presentations=("task", "reader"),
|
||||
search_source_ids=("calendar.events",),
|
||||
help_context_ids=("calendar.page",),
|
||||
documentation_topic_ids=("calendar.quick-access-and-product-area",),
|
||||
required_any=("calendar:event:read",),
|
||||
order=10,
|
||||
unavailable=ProductAvailabilityExplanation(
|
||||
reason="authorization",
|
||||
title="i18n:govoplan-core.product_surface.unavailable",
|
||||
description="i18n:govoplan-core.product_surface.unavailable_description",
|
||||
resolution="i18n:govoplan-core.product_surface.unavailable_resolution",
|
||||
responsible_role="i18n:govoplan-core.access_administrator",
|
||||
),
|
||||
),
|
||||
),
|
||||
quick_access_tools=(
|
||||
QuickAccessTool(
|
||||
id="calendar.agenda",
|
||||
module_id="calendar",
|
||||
category_id="calendar",
|
||||
label="i18n:govoplan-calendar.calendar.adab5090",
|
||||
description="i18n:govoplan-calendar.quick_access_description",
|
||||
surface_id="calendar.quick_access.agenda",
|
||||
icon="calendar",
|
||||
full_page_path="/calendar",
|
||||
required_any=("calendar:event:read",),
|
||||
order=10,
|
||||
modes=("browse", "create"),
|
||||
returned_reference_kinds=("calendar.event",),
|
||||
help_context_id="calendar.quick_access.agenda",
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
@@ -807,5 +1298,10 @@ manifest = ModuleManifest(
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
"""Data-only recurrence work; resource boundary, not an arbitrary-code sandbox."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from govoplan_core.security.bounded_process import ProcessLimits
|
||||
from govoplan_core.security.worker_payload import decode_worker_payload, encode_worker_payload
|
||||
|
||||
RECURRENCE_LIMITS = ProcessLimits(
|
||||
wall_seconds=5, cpu_seconds=3, memory_bytes=256 * 1024 * 1024,
|
||||
input_bytes=4 * 1024 * 1024, output_bytes=4 * 1024 * 1024,
|
||||
)
|
||||
|
||||
|
||||
def expand_recurrences_worker(payload: bytes) -> bytes:
|
||||
from govoplan_calendar.backend.ical import _expand_event_occurrences
|
||||
|
||||
value = decode_worker_payload(payload, max_bytes=RECURRENCE_LIMITS.input_bytes)
|
||||
try:
|
||||
if not isinstance(value, dict) or set(value) != {"events", "start", "end", "limit"}:
|
||||
raise ValueError("Invalid request")
|
||||
if not isinstance(value["events"], list) or len(value["events"]) > 2_000:
|
||||
raise ValueError("Invalid event count")
|
||||
if type(value["limit"]) is not int or not 1 <= value["limit"] <= 10_000:
|
||||
raise ValueError("Invalid result limit")
|
||||
if not isinstance(value["start"], datetime) or not isinstance(value["end"], datetime):
|
||||
raise ValueError("Invalid range")
|
||||
remaining = value["limit"]
|
||||
batches = []
|
||||
for event in value["events"]:
|
||||
if not isinstance(event, dict) or set(event) != {
|
||||
"uid", "start_at", "end_at", "duration_seconds", "all_day", "timezone", "rrule", "rdate", "exdate",
|
||||
}:
|
||||
raise ValueError("Invalid event")
|
||||
batch = _expand_event_occurrences(
|
||||
SimpleNamespace(**event), value["start"], value["end"], limit=remaining,
|
||||
)
|
||||
remaining -= len(batch)
|
||||
batches.append(batch)
|
||||
result = {"occurrences": batches}
|
||||
except (ValueError, TypeError, OverflowError, KeyError, AttributeError):
|
||||
result = {"error": "invalid_or_excessive_recurrence"}
|
||||
return encode_worker_payload(result, max_bytes=RECURRENCE_LIMITS.output_bytes)
|
||||
@@ -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)
|
||||
|
||||
@@ -11,9 +11,10 @@ import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy import LargeBinary, Text, case, cast, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.security.outbound_http import (
|
||||
@@ -58,7 +59,9 @@ from govoplan_calendar.backend.ews import (
|
||||
)
|
||||
from govoplan_calendar.backend.graph import graph_event_payload
|
||||
from govoplan_calendar.backend.ical import (
|
||||
ICalendarError,
|
||||
expand_event_occurrences,
|
||||
expand_events_occurrences,
|
||||
normalized_recurrence_id,
|
||||
parse_vevent,
|
||||
parse_vevents,
|
||||
@@ -102,6 +105,19 @@ CALENDAR_EVENT_RESOURCE = "calendar_event"
|
||||
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
||||
REMOTE_SYNC_MAX_ITEMS = 10_000
|
||||
MAX_OCCURRENCE_RANGE_DAYS = 400
|
||||
MAX_OCCURRENCE_CANDIDATES = 2_000
|
||||
MAX_OCCURRENCE_RESULTS = 10_000
|
||||
MAX_OCCURRENCE_PROJECTION_BYTES = 4 * 1024 * 1024
|
||||
MAX_OCCURRENCE_RESPONSE_BYTES = 4 * 1024 * 1024
|
||||
OCCURRENCE_AVAILABILITY_FIELDS = (
|
||||
"id", "calendar_id", "uid", "recurrence_id", "status", "transparency",
|
||||
"start_at", "end_at", "duration_seconds", "all_day", "timezone", "rrule", "rdate", "exdate",
|
||||
)
|
||||
OCCURRENCE_DETAIL_FIELDS = OCCURRENCE_AVAILABILITY_FIELDS + (
|
||||
"tenant_id", "sequence", "summary", "description", "location", "classification",
|
||||
"organizer", "attendees", "categories", "reminders", "attachments", "related_to",
|
||||
"source_kind", "source_href", "etag", "icalendar", "created_at", "updated_at", "metadata_",
|
||||
)
|
||||
DEFAULT_CALENDAR_VIEW_PREFERENCES: dict[str, bool | int] = {
|
||||
"dim_weekends": True,
|
||||
"dim_off_hours": True,
|
||||
@@ -3829,15 +3845,79 @@ 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 _bounded_occurrence_candidates(query, fields: tuple[str, ...], remaining: list[int]):
|
||||
"""Project only needed columns, hiding oversized values inside the SQL read.
|
||||
|
||||
A separate size-check query followed by ORM loading would race an update and
|
||||
would still decode all JSON/Text in the driver before checking the budget.
|
||||
CASE and the measured values here share one statement snapshot; a cumulative
|
||||
SQL budget bounds all payload columns before the driver can decode them.
|
||||
"""
|
||||
dialect = query.session.get_bind().dialect.name
|
||||
if dialect not in {"sqlite", "postgresql"}:
|
||||
raise CalendarError("Bounded calendar projections require SQLite or PostgreSQL.")
|
||||
columns = [getattr(CalendarEvent, name) for name in fields]
|
||||
sizes = []
|
||||
for column in columns:
|
||||
text_value = cast(column, Text)
|
||||
byte_length = func.length(cast(text_value, LargeBinary)) if dialect == "sqlite" else func.octet_length(text_value)
|
||||
sizes.append(func.coalesce(byte_length, 0))
|
||||
size = sum(sizes)
|
||||
sizes_query = query.with_entities(
|
||||
CalendarEvent.id.label("candidate_id"), size.label("projection_bytes"),
|
||||
).subquery()
|
||||
budgeted = select(
|
||||
sizes_query,
|
||||
func.sum(sizes_query.c.projection_bytes).over(order_by=sizes_query.c.candidate_id).label("cumulative_bytes"),
|
||||
).subquery()
|
||||
# The window is evaluated on small identity/size rows before selecting any
|
||||
# payload. Even a buffering driver/sort can receive at most the remaining
|
||||
# aggregate bytes, rather than thousands of individually allowed big events.
|
||||
projected = query.session.query(
|
||||
budgeted.c.projection_bytes,
|
||||
*(case((budgeted.c.cumulative_bytes <= remaining[0], column), else_=None).label(name)
|
||||
for name, column in zip(fields, columns, strict=True)),
|
||||
).join(CalendarEvent, CalendarEvent.id == budgeted.c.candidate_id).order_by(budgeted.c.candidate_id)
|
||||
rows = []
|
||||
iterator = iter(projected.yield_per(1))
|
||||
try:
|
||||
for row in iterator:
|
||||
remaining[0] -= row.projection_bytes
|
||||
if remaining[0] < 0:
|
||||
raise CalendarError("Calendar candidate projection exceeds its byte limit; request fewer calendars or a smaller range.")
|
||||
rows.append(SimpleNamespace(**{name: getattr(row, name) for name in fields}))
|
||||
finally:
|
||||
close = getattr(iterator, "close", None)
|
||||
if close is not None:
|
||||
close()
|
||||
return rows
|
||||
|
||||
|
||||
def _occurrence_json_default(value: object) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
raise CalendarError("Calendar occurrence data cannot be encoded safely.")
|
||||
|
||||
|
||||
def list_event_occurrences(
|
||||
@@ -3847,6 +3927,27 @@ 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,
|
||||
_availability_only: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
from govoplan_core.security.bounded_process import ProcessBudgetError, bounded_operation_admission
|
||||
|
||||
try:
|
||||
with bounded_operation_admission() as admission:
|
||||
return _list_event_occurrences(
|
||||
session, tenant_id=tenant_id, start_at=start_at, end_at=end_at,
|
||||
calendar_id=calendar_id, visible_calendar_ids=visible_calendar_ids,
|
||||
limit=limit, admission=admission, availability_only=_availability_only,
|
||||
)
|
||||
except (ProcessBudgetError, ICalendarError) as exc:
|
||||
raise CalendarError("Calendar expansion could not complete within its limits; request a smaller range or fewer calendars, or retry later.") from exc
|
||||
|
||||
|
||||
def _list_event_occurrences(
|
||||
session: Session, *, tenant_id: str, start_at: datetime, end_at: datetime,
|
||||
calendar_id: str | None, visible_calendar_ids: Iterable[str] | None,
|
||||
limit: int | None, admission: Any, availability_only: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return range-bounded events with recurring series fully reconciled."""
|
||||
|
||||
@@ -3863,10 +3964,21 @@ 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 []
|
||||
if len(normalized_calendar_ids) > MAX_OCCURRENCE_CANDIDATES:
|
||||
raise CalendarError("Too many calendars for complete expansion; request fewer calendars.")
|
||||
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)
|
||||
|
||||
recurring_masters = (
|
||||
fields = OCCURRENCE_AVAILABILITY_FIELDS if availability_only else OCCURRENCE_DETAIL_FIELDS
|
||||
projection_budget = [MAX_OCCURRENCE_PROJECTION_BYTES]
|
||||
recurring_masters = _bounded_occurrence_candidates(
|
||||
base_query.filter(
|
||||
CalendarEvent.recurrence_id.is_(None),
|
||||
or_(
|
||||
@@ -3875,9 +3987,11 @@ def list_event_occurrences(
|
||||
),
|
||||
)
|
||||
.order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc())
|
||||
.all()
|
||||
.limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget,
|
||||
)
|
||||
direct_events = (
|
||||
if len(recurring_masters) > MAX_OCCURRENCE_CANDIDATES:
|
||||
raise CalendarError("Too many recurring series for complete expansion; request fewer calendars.")
|
||||
direct_events = _bounded_occurrence_candidates(
|
||||
base_query.filter(
|
||||
or_(
|
||||
CalendarEvent.end_at.is_(None),
|
||||
@@ -3886,8 +4000,10 @@ def list_event_occurrences(
|
||||
CalendarEvent.start_at <= range_end,
|
||||
)
|
||||
.order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc())
|
||||
.all()
|
||||
.limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget,
|
||||
)
|
||||
if len(direct_events) > MAX_OCCURRENCE_CANDIDATES:
|
||||
raise CalendarError("Too many events for complete expansion; request a smaller range or fewer calendars.")
|
||||
|
||||
series_keys = {
|
||||
(event.calendar_id, event.uid) for event in recurring_masters
|
||||
@@ -3895,12 +4011,16 @@ def list_event_occurrences(
|
||||
overrides: list[CalendarEvent] = []
|
||||
if series_keys:
|
||||
series_uids = {uid for _calendar_id, uid in series_keys}
|
||||
overrides = [
|
||||
event
|
||||
for event in base_query.filter(
|
||||
override_candidates = _bounded_occurrence_candidates(
|
||||
base_query.filter(
|
||||
CalendarEvent.recurrence_id.is_not(None),
|
||||
CalendarEvent.uid.in_(series_uids),
|
||||
).all()
|
||||
).limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget,
|
||||
)
|
||||
if len(override_candidates) > MAX_OCCURRENCE_CANDIDATES:
|
||||
raise CalendarError("Too many overrides for complete expansion; request fewer calendars.")
|
||||
overrides = [
|
||||
event for event in override_candidates
|
||||
if (event.calendar_id, event.uid) in series_keys
|
||||
]
|
||||
|
||||
@@ -3915,19 +4035,31 @@ def list_event_occurrences(
|
||||
)[recurrence_key] = override
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
response_bytes = 2 # JSON list brackets; charge the complete unsliced result.
|
||||
|
||||
def append_occurrence(event, **kwargs) -> None:
|
||||
nonlocal response_bytes
|
||||
if len(results) >= MAX_OCCURRENCE_RESULTS:
|
||||
raise CalendarError("Too many occurrences for a complete result; request a smaller range or fewer calendars.")
|
||||
payload = expanded_event_response(event, availability_only=availability_only, **kwargs)
|
||||
response_bytes += len(json.dumps(payload, default=_occurrence_json_default, ensure_ascii=True).encode("utf-8")) + 2
|
||||
if response_bytes > MAX_OCCURRENCE_RESPONSE_BYTES:
|
||||
raise CalendarError("Expanded calendar response exceeds its byte limit; request a smaller range or fewer calendars.")
|
||||
results.append(payload)
|
||||
|
||||
consumed_event_ids: set[str] = set()
|
||||
recurring_master_ids = {event.id for event in recurring_masters}
|
||||
recurring_master_by_series = {
|
||||
(event.calendar_id, event.uid): event for event in recurring_masters
|
||||
}
|
||||
for master in recurring_masters:
|
||||
expanded_batches = expand_events_occurrences(
|
||||
recurring_masters, range_start, range_end,
|
||||
limit=MAX_OCCURRENCE_RESULTS, admission=admission,
|
||||
)
|
||||
for master, expanded in zip(recurring_masters, expanded_batches, strict=True):
|
||||
series_key = (master.calendar_id, master.uid)
|
||||
series_overrides = overrides_by_series.get(series_key, {})
|
||||
for occurrence in expand_event_occurrences(
|
||||
master,
|
||||
range_start,
|
||||
range_end,
|
||||
):
|
||||
for occurrence in expanded:
|
||||
occurrence_recurrence_id = str(
|
||||
occurrence.get("recurrence_id") or ""
|
||||
)
|
||||
@@ -3948,25 +4080,18 @@ def list_event_occurrences(
|
||||
range_start=range_start,
|
||||
range_end=range_end,
|
||||
):
|
||||
results.append(
|
||||
expanded_event_response(
|
||||
override,
|
||||
series_event_id=master.id,
|
||||
recurrence_id=(
|
||||
override.recurrence_id
|
||||
or occurrence_recurrence_id
|
||||
),
|
||||
is_override=True,
|
||||
)
|
||||
append_occurrence(
|
||||
override,
|
||||
series_event_id=master.id,
|
||||
recurrence_id=(override.recurrence_id or occurrence_recurrence_id),
|
||||
is_override=True,
|
||||
)
|
||||
continue
|
||||
results.append(
|
||||
expanded_event_response(
|
||||
master,
|
||||
series_event_id=master.id,
|
||||
recurrence_id=occurrence_recurrence_id,
|
||||
occurrence=occurrence,
|
||||
)
|
||||
append_occurrence(
|
||||
master,
|
||||
series_event_id=master.id,
|
||||
recurrence_id=occurrence_recurrence_id,
|
||||
occurrence=occurrence,
|
||||
)
|
||||
|
||||
for event in direct_events:
|
||||
@@ -3983,23 +4108,22 @@ def list_event_occurrences(
|
||||
is_override = True
|
||||
if event.status.upper() == "CANCELLED":
|
||||
continue
|
||||
results.append(
|
||||
expanded_event_response(
|
||||
event,
|
||||
series_event_id=series_event_id,
|
||||
recurrence_id=normalized_recurrence_id(event.recurrence_id),
|
||||
is_override=is_override,
|
||||
)
|
||||
append_occurrence(
|
||||
event,
|
||||
series_event_id=series_event_id,
|
||||
recurrence_id=normalized_recurrence_id(event.recurrence_id),
|
||||
is_override=is_override,
|
||||
)
|
||||
return sorted(
|
||||
sorted_results = sorted(
|
||||
results,
|
||||
key=lambda item: (
|
||||
item["start_at"],
|
||||
item["calendar_id"],
|
||||
item["summary"],
|
||||
item.get("summary", ""),
|
||||
item["instance_id"],
|
||||
),
|
||||
)
|
||||
return sorted_results[: max(1, limit)] if limit is not None else sorted_results
|
||||
|
||||
|
||||
def event_overlaps_range(
|
||||
@@ -4024,8 +4148,16 @@ def expanded_event_response(
|
||||
recurrence_id: str | None,
|
||||
occurrence: dict[str, Any] | None = None,
|
||||
is_override: bool = False,
|
||||
availability_only: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
payload = event_response(event)
|
||||
payload = (
|
||||
{
|
||||
"id": event.id, "calendar_id": event.calendar_id, "uid": event.uid,
|
||||
"start_at": response_datetime(event.start_at), "end_at": response_datetime(event.end_at),
|
||||
"all_day": event.all_day, "status": event.status, "transparency": event.transparency,
|
||||
}
|
||||
if availability_only else event_response(event)
|
||||
)
|
||||
if occurrence is not None:
|
||||
payload["start_at"] = response_datetime(occurrence["start_at"])
|
||||
payload["end_at"] = response_datetime(occurrence.get("end_at"))
|
||||
@@ -4054,25 +4186,13 @@ def list_freebusy(
|
||||
range_end = normalize_datetime(end_at)
|
||||
if range_end < range_start:
|
||||
raise CalendarError("Free/busy end must be after start")
|
||||
events: list[dict[str, Any]] = []
|
||||
if calendar_ids:
|
||||
for calendar_id in dict.fromkeys(calendar_ids):
|
||||
events.extend(
|
||||
list_event_occurrences(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=calendar_id,
|
||||
start_at=range_start,
|
||||
end_at=range_end,
|
||||
)
|
||||
)
|
||||
else:
|
||||
events = list_event_occurrences(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
start_at=range_start,
|
||||
end_at=range_end,
|
||||
)
|
||||
# One admission and one worker budget for the entire requested collection,
|
||||
# not one independently reset expansion allowance per calendar.
|
||||
events = list_event_occurrences(
|
||||
session, tenant_id=tenant_id, start_at=range_start, end_at=range_end,
|
||||
visible_calendar_ids=calendar_ids or None,
|
||||
_availability_only=True,
|
||||
)
|
||||
busy = [
|
||||
{
|
||||
"calendar_id": event["calendar_id"],
|
||||
@@ -4580,11 +4700,14 @@ def recurrence_occurrence(
|
||||
if recurrence_start is None:
|
||||
raise CalendarError("Invalid recurrence_id")
|
||||
recurrence_key = normalized_recurrence_id(recurrence_id)
|
||||
candidates = expand_event_occurrences(
|
||||
master,
|
||||
recurrence_start - timedelta(seconds=1),
|
||||
recurrence_start + timedelta(seconds=1),
|
||||
)
|
||||
try:
|
||||
candidates = expand_event_occurrences(
|
||||
master,
|
||||
recurrence_start - timedelta(seconds=1),
|
||||
recurrence_start + timedelta(seconds=1),
|
||||
)
|
||||
except ICalendarError as exc:
|
||||
raise CalendarError("The recurrence could not be verified within its limits; the occurrence was not changed.") from exc
|
||||
occurrence = next(
|
||||
(
|
||||
item
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -28,11 +28,29 @@ from govoplan_tenancy.backend.db.models import Tenant
|
||||
|
||||
|
||||
class CalendarSchedulingCapabilityTests(unittest.TestCase):
|
||||
def test_event_request_validation_is_exposed_as_capability_error(self) -> None:
|
||||
provider = SqlCalendarSchedulingProvider()
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
self.calendar = create_calendar(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarCollectionCreateRequest(name="Scheduling"),
|
||||
)
|
||||
self.provider = SqlCalendarSchedulingProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_event_request_validation_is_exposed_as_capability_error(self) -> None:
|
||||
with self.assertRaises(CalendarCapabilityError):
|
||||
provider.create_event(
|
||||
self.provider.create_event(
|
||||
object(),
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
@@ -42,6 +60,60 @@ class CalendarSchedulingCapabilityTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_hold_promotion_and_release_are_idempotent(self) -> None:
|
||||
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
|
||||
hold = self.provider.create_event(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
request=CalendarEventRequest(
|
||||
calendar_id=self.calendar.id,
|
||||
summary="Tentative hold",
|
||||
status="TENTATIVE",
|
||||
start_at=start,
|
||||
end_at=start + timedelta(hours=1),
|
||||
metadata={"scheduling_request_id": "request-1"},
|
||||
),
|
||||
)
|
||||
promoted = self.provider.promote_event(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
event_id=hold.id,
|
||||
request=CalendarEventRequest(
|
||||
calendar_id=self.calendar.id,
|
||||
summary="Confirmed meeting",
|
||||
status="CONFIRMED",
|
||||
start_at=start,
|
||||
end_at=start + timedelta(hours=1),
|
||||
metadata={"scheduling_request_id": "request-1"},
|
||||
),
|
||||
)
|
||||
released = self.provider.release_event(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
event_id=promoted.id,
|
||||
)
|
||||
replayed = self.provider.release_event(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
event_id=promoted.id,
|
||||
)
|
||||
absent = self.provider.release_event(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
event_id="missing-event",
|
||||
)
|
||||
|
||||
self.assertEqual(promoted.id, hold.id)
|
||||
self.assertEqual("CONFIRMED", self.session.get(CalendarEvent, hold.id).status)
|
||||
self.assertFalse(released.already_released)
|
||||
self.assertTrue(replayed.already_released)
|
||||
self.assertEqual("not_found", absent.external_state)
|
||||
|
||||
|
||||
class CalendarExternalProfileCapabilityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_calendar.backend.db.models import (
|
||||
CalendarCollection,
|
||||
CalendarEvent,
|
||||
CalendarMigrationBatch,
|
||||
CalendarMigrationResource,
|
||||
CalendarOutboxOperation,
|
||||
CalendarSyncCredential,
|
||||
CalendarSyncSource,
|
||||
CalendarViewPreference,
|
||||
)
|
||||
from govoplan_calendar.backend.dsar_provider import (
|
||||
CALENDAR_DSAR_CAPABILITY,
|
||||
CalendarDsarProvider,
|
||||
)
|
||||
from govoplan_calendar.backend.manifest import manifest
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
DataSubjectRequest,
|
||||
create_data_subject_request,
|
||||
execute_data_subject_erasure,
|
||||
plan_data_subject_erasure,
|
||||
search_data_subject_request,
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(
|
||||
self,
|
||||
provider: CalendarDsarProvider,
|
||||
*,
|
||||
calendar_active: bool = True,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.calendar_active = calendar_active
|
||||
|
||||
def capability_names(self):
|
||||
return (CALENDAR_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
self._assert_capability(name)
|
||||
return "calendar"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
calendar_active = self.calendar_active
|
||||
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type(
|
||||
"State",
|
||||
(),
|
||||
{"effective_modules": ("calendar",) if calendar_active else ()},
|
||||
)()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
self._assert_capability(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "calendar"})(),)
|
||||
|
||||
@staticmethod
|
||||
def _assert_capability(name: str) -> None:
|
||||
if name != CALENDAR_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
class CalendarDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(
|
||||
bind=self.engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
DataSubjectRequest.__table__,
|
||||
CalendarCollection.__table__,
|
||||
CalendarEvent.__table__,
|
||||
CalendarViewPreference.__table__,
|
||||
CalendarSyncSource.__table__,
|
||||
CalendarSyncCredential.__table__,
|
||||
CalendarOutboxOperation.__table__,
|
||||
CalendarMigrationBatch.__table__,
|
||||
CalendarMigrationResource.__table__,
|
||||
],
|
||||
)
|
||||
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||
now = datetime.now(timezone.utc)
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="subject@example.test",
|
||||
normalized_email="subject@example.test",
|
||||
display_name="Subject",
|
||||
)
|
||||
other_account = Account(
|
||||
id="account-2",
|
||||
email="other@example.test",
|
||||
normalized_email="other@example.test",
|
||||
display_name="Other",
|
||||
)
|
||||
self.user = User(
|
||||
id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=account.id,
|
||||
email="subject@example.test",
|
||||
display_name="Subject",
|
||||
)
|
||||
other_user = User(
|
||||
id="membership-2",
|
||||
tenant_id="tenant-1",
|
||||
account_id=other_account.id,
|
||||
email="other@example.test",
|
||||
display_name="Other",
|
||||
)
|
||||
self.collection = CalendarCollection(
|
||||
id="calendar-subject",
|
||||
tenant_id="tenant-1",
|
||||
slug="subject-calendar",
|
||||
name="Subject calendar",
|
||||
description="Subject-owned calendar",
|
||||
owner_type="user",
|
||||
owner_id=self.user.id,
|
||||
visibility="private",
|
||||
created_by_user_id=self.user.id,
|
||||
metadata_={"secret": "collection-secret-do-not-export"},
|
||||
)
|
||||
target_collection = CalendarCollection(
|
||||
id="calendar-target",
|
||||
tenant_id="tenant-1",
|
||||
slug="target-calendar",
|
||||
name="Target calendar",
|
||||
owner_type="user",
|
||||
owner_id=self.user.id,
|
||||
visibility="private",
|
||||
created_by_user_id=self.user.id,
|
||||
)
|
||||
other_collection = CalendarCollection(
|
||||
id="calendar-other",
|
||||
tenant_id="tenant-1",
|
||||
slug="other-calendar",
|
||||
name="Other calendar",
|
||||
owner_type="user",
|
||||
owner_id=other_user.id,
|
||||
visibility="private",
|
||||
created_by_user_id=other_user.id,
|
||||
)
|
||||
tenant_two_collection = CalendarCollection(
|
||||
id="calendar-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
slug="tenant-two",
|
||||
name="Tenant two secret calendar",
|
||||
owner_type="tenant",
|
||||
visibility="tenant",
|
||||
)
|
||||
self.event = CalendarEvent(
|
||||
id="event-subject",
|
||||
tenant_id="tenant-1",
|
||||
calendar_id=self.collection.id,
|
||||
uid="subject-event@example.test",
|
||||
summary="Subject appointment",
|
||||
description="Information visible to the subject",
|
||||
location="Town hall",
|
||||
start_at=now,
|
||||
end_at=now,
|
||||
organizer={
|
||||
"value": "mailto:organizer@example.test",
|
||||
"params": {"CN": ["Organizer"]},
|
||||
},
|
||||
attendees=[
|
||||
{
|
||||
"value": "mailto:Subject@Example.Test",
|
||||
"params": {"CN": ["Subject"], "PARTSTAT": ["ACCEPTED"]},
|
||||
},
|
||||
{
|
||||
"value": "mailto:other@example.test",
|
||||
"params": {"CN": ["Unrelated Person"]},
|
||||
},
|
||||
],
|
||||
categories=["citizen-service"],
|
||||
reminders=[{"minutes": 15, "token": "reminder-secret-do-not-export"}],
|
||||
attachments=[{"href": "/private/attachment-do-not-export"}],
|
||||
source_kind="caldav",
|
||||
source_href="/remote/event-do-not-export.ics",
|
||||
etag="event-etag-do-not-export",
|
||||
raw_ics="raw-ics-do-not-export",
|
||||
icalendar={"private": "icalendar-object-do-not-export"},
|
||||
metadata_={"secret": "event-secret-do-not-export"},
|
||||
)
|
||||
unrelated_event = CalendarEvent(
|
||||
id="event-other",
|
||||
tenant_id="tenant-1",
|
||||
calendar_id=self.collection.id,
|
||||
uid="unrelated@example.test",
|
||||
summary="Unrelated event do not export",
|
||||
description="Unrelated event body do not export",
|
||||
start_at=now,
|
||||
end_at=now,
|
||||
organizer={"value": "mailto:other@example.test"},
|
||||
attendees=[],
|
||||
)
|
||||
tenant_two_event = CalendarEvent(
|
||||
id="event-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
calendar_id=tenant_two_collection.id,
|
||||
uid="tenant-two@example.test",
|
||||
summary="Tenant two event do not export",
|
||||
start_at=now,
|
||||
end_at=now,
|
||||
organizer={"value": "mailto:subject@example.test"},
|
||||
attendees=[],
|
||||
)
|
||||
self.source = CalendarSyncSource(
|
||||
id="source-subject",
|
||||
tenant_id="tenant-1",
|
||||
calendar_id=self.collection.id,
|
||||
source_kind="caldav",
|
||||
collection_url="https://private.example.test/calendar-do-not-export",
|
||||
display_name="Subject CalDAV",
|
||||
auth_type="basic",
|
||||
username="connector-user-do-not-export",
|
||||
credential_ref="credential-ref-do-not-export",
|
||||
sync_token="sync-token-do-not-export",
|
||||
ctag="ctag-do-not-export",
|
||||
last_status="failed",
|
||||
last_error="provider-error-do-not-export",
|
||||
metadata_={"secret": "source-secret-do-not-export"},
|
||||
)
|
||||
target_source = CalendarSyncSource(
|
||||
id="source-target",
|
||||
tenant_id="tenant-1",
|
||||
calendar_id=target_collection.id,
|
||||
source_kind="caldav",
|
||||
collection_url="https://private.example.test/target-do-not-export",
|
||||
display_name="Target CalDAV",
|
||||
auth_type="none",
|
||||
)
|
||||
credential = CalendarSyncCredential(
|
||||
id="credential-subject",
|
||||
tenant_id="tenant-1",
|
||||
credential_kind="basic",
|
||||
label="Subject credential",
|
||||
secret_encrypted="ciphertext-do-not-export",
|
||||
created_by_user_id=self.user.id,
|
||||
metadata_={"password": "credential-password-do-not-export"},
|
||||
)
|
||||
self.preference = CalendarViewPreference(
|
||||
id="preference-subject",
|
||||
tenant_id="tenant-1",
|
||||
user_id=self.user.id,
|
||||
dim_weekends=True,
|
||||
workday_start_hour=8,
|
||||
workday_end_hour=17,
|
||||
)
|
||||
operation = CalendarOutboxOperation(
|
||||
id="operation-subject",
|
||||
tenant_id="tenant-1",
|
||||
source_id=self.source.id,
|
||||
event_id=self.event.id,
|
||||
operation_kind="put",
|
||||
resource_href="/private/outbox-href-do-not-export",
|
||||
payload_ics="outbox-payload-do-not-export",
|
||||
payload_fingerprint="a" * 64,
|
||||
expected_etag="expected-etag-do-not-export",
|
||||
idempotency_key="idempotency-do-not-export",
|
||||
status="succeeded",
|
||||
available_at=now,
|
||||
lease_token="lease-do-not-export",
|
||||
remote_etag="remote-etag-do-not-export",
|
||||
last_error="outbox-error-do-not-export",
|
||||
metadata_={"secret": "outbox-secret-do-not-export"},
|
||||
)
|
||||
migration = CalendarMigrationBatch(
|
||||
id="migration-subject",
|
||||
tenant_id="tenant-1",
|
||||
status="active",
|
||||
phase="copying_destination",
|
||||
source_calendar_id=self.collection.id,
|
||||
target_calendar_id=target_collection.id,
|
||||
source_sync_source_id=self.source.id,
|
||||
target_sync_source_id=target_source.id,
|
||||
total_resources=1,
|
||||
total_events=1,
|
||||
created_by_user_id=self.user.id,
|
||||
authorization_evidence={"secret": "authorization-do-not-export"},
|
||||
last_error="migration-error-do-not-export",
|
||||
)
|
||||
migration_resource = CalendarMigrationResource(
|
||||
id="migration-resource-subject",
|
||||
tenant_id="tenant-1",
|
||||
batch_id=migration.id,
|
||||
source_href="/source/private-do-not-export",
|
||||
source_expected_etag="source-etag-do-not-export",
|
||||
destination_href="/destination/private-do-not-export",
|
||||
event_ids=[self.event.id],
|
||||
status="copy_pending",
|
||||
last_error="resource-error-do-not-export",
|
||||
)
|
||||
self.session.add_all(
|
||||
[
|
||||
account,
|
||||
other_account,
|
||||
self.user,
|
||||
other_user,
|
||||
self.collection,
|
||||
target_collection,
|
||||
other_collection,
|
||||
tenant_two_collection,
|
||||
self.event,
|
||||
unrelated_event,
|
||||
tenant_two_event,
|
||||
self.source,
|
||||
target_source,
|
||||
credential,
|
||||
self.preference,
|
||||
operation,
|
||||
migration,
|
||||
migration_resource,
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
self.provider = CalendarDsarProvider()
|
||||
self.subject = DsarSubjectRef(
|
||||
membership_id=self.user.id,
|
||||
email="subject@example.test",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
|
||||
provided_names = {item.name for item in manifest.provides_interfaces}
|
||||
self.assertIn(CALENDAR_DSAR_CAPABILITY, provided_names)
|
||||
provider = manifest.capability_factories[CALENDAR_DSAR_CAPABILITY](None)
|
||||
self.assertIsInstance(provider, DsarProvider)
|
||||
self.assertIn(
|
||||
"calendar.privacy.data-subject-requests",
|
||||
{topic.id for topic in manifest.documentation},
|
||||
)
|
||||
|
||||
def test_search_is_tenant_scoped_minimized_and_party_specific(self) -> None:
|
||||
records = self._records()
|
||||
resource_types = {record.resource_type for record in records}
|
||||
self.assertTrue(
|
||||
{
|
||||
"calendar_collection",
|
||||
"calendar_event",
|
||||
"calendar_view_preference",
|
||||
"calendar_sync_credential",
|
||||
"calendar_sync_source",
|
||||
"calendar_outbox_operation",
|
||||
"calendar_migration_batch",
|
||||
"calendar_migration_resource",
|
||||
}.issubset(resource_types)
|
||||
)
|
||||
event = next(
|
||||
record for record in records if record.resource_type == "calendar_event"
|
||||
)
|
||||
self.assertEqual(
|
||||
"subject@example.test",
|
||||
event.data["matching_attendees"][0]["email"],
|
||||
)
|
||||
self.assertEqual([], event.data["organizer"] or [])
|
||||
|
||||
serialized = repr([record.to_dict() for record in records])
|
||||
for hidden in (
|
||||
"event-other",
|
||||
"Unrelated event do not export",
|
||||
"Unrelated event body do not export",
|
||||
"other@example.test",
|
||||
"Unrelated Person",
|
||||
"event-tenant-2",
|
||||
"Tenant two event do not export",
|
||||
"raw-ics-do-not-export",
|
||||
"icalendar-object-do-not-export",
|
||||
"/remote/event-do-not-export.ics",
|
||||
"event-etag-do-not-export",
|
||||
"/private/attachment-do-not-export",
|
||||
"collection-secret-do-not-export",
|
||||
"event-secret-do-not-export",
|
||||
"reminder-secret-do-not-export",
|
||||
"calendar-do-not-export",
|
||||
"connector-user-do-not-export",
|
||||
"credential-ref-do-not-export",
|
||||
"sync-token-do-not-export",
|
||||
"ctag-do-not-export",
|
||||
"provider-error-do-not-export",
|
||||
"source-secret-do-not-export",
|
||||
"ciphertext-do-not-export",
|
||||
"credential-password-do-not-export",
|
||||
"outbox-href-do-not-export",
|
||||
"outbox-payload-do-not-export",
|
||||
"expected-etag-do-not-export",
|
||||
"idempotency-do-not-export",
|
||||
"lease-do-not-export",
|
||||
"remote-etag-do-not-export",
|
||||
"outbox-error-do-not-export",
|
||||
"authorization-do-not-export",
|
||||
"migration-error-do-not-export",
|
||||
"/source/private-do-not-export",
|
||||
"source-etag-do-not-export",
|
||||
"/destination/private-do-not-export",
|
||||
"resource-error-do-not-export",
|
||||
):
|
||||
self.assertNotIn(hidden, serialized)
|
||||
|
||||
def test_conflicting_email_references_fail_closed_for_attendee_data(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
email="subject@example.test",
|
||||
external_references={"calendar.email": "other@example.test"},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual((), records)
|
||||
|
||||
def test_plan_retains_evidence_and_only_executes_preference_deletion(self) -> None:
|
||||
records = self._records()
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
records=records,
|
||||
)
|
||||
|
||||
self.assertTrue({"retain", "manual_review"}.issubset({a.kind for a in actions}))
|
||||
self.assertTrue(
|
||||
any(
|
||||
action.action_id == "calendar:retain:calendar_event:event-subject"
|
||||
for action in actions
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
{"calendar:delete:calendar_view_preference:preference-subject"},
|
||||
{action.action_id for action in actions if action.executable},
|
||||
)
|
||||
|
||||
def test_execution_is_revalidated_tenant_bound_and_idempotent(self) -> None:
|
||||
action = self._preference_delete_action()
|
||||
wrong_tenant = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-2",
|
||||
subject=self.subject,
|
||||
actions=(action,),
|
||||
request_id="dsar-wrong-tenant",
|
||||
)
|
||||
self.assertEqual("blocked", wrong_tenant[0].status)
|
||||
|
||||
first = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
actions=(action,),
|
||||
request_id="dsar-calendar-1",
|
||||
)
|
||||
self.assertEqual("executed", first[0].status)
|
||||
self.assertIsNone(self.session.get(CalendarViewPreference, self.preference.id))
|
||||
|
||||
repeated = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
actions=(action,),
|
||||
request_id="dsar-calendar-1",
|
||||
)
|
||||
self.assertEqual("unchanged", repeated[0].status)
|
||||
|
||||
def test_execution_blocks_when_preference_owner_changed_after_planning(
|
||||
self,
|
||||
) -> None:
|
||||
action = self._preference_delete_action()
|
||||
self.preference.user_id = "membership-2"
|
||||
self.session.flush()
|
||||
|
||||
result = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
actions=(action,),
|
||||
request_id="dsar-stale",
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", result[0].status)
|
||||
self.assertIsNotNone(
|
||||
self.session.get(CalendarViewPreference, self.preference.id)
|
||||
)
|
||||
|
||||
def test_core_workflow_discovers_active_provider_and_skips_it_when_disabled(
|
||||
self,
|
||||
) -> None:
|
||||
request = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-CALENDAR-1",
|
||||
request_kind="access_and_erasure",
|
||||
subject=self.subject,
|
||||
purpose="Respond to an authorized privacy request.",
|
||||
legal_basis="Article 15 and 17 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
self.session.commit()
|
||||
registry = _Registry(self.provider)
|
||||
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=registry,
|
||||
row=request,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual("searched", request.status)
|
||||
self.assertEqual(["calendar"], request.coverage["covered_modules"])
|
||||
plan_data_subject_erasure(
|
||||
self.session,
|
||||
registry=registry,
|
||||
row=request,
|
||||
expected_revision=2,
|
||||
)
|
||||
executable_ids = [
|
||||
action["action_id"]
|
||||
for action in request.erasure_plan["actions"]
|
||||
if action["executable"]
|
||||
]
|
||||
execute_data_subject_erasure(
|
||||
self.session,
|
||||
registry=registry,
|
||||
row=request,
|
||||
expected_revision=3,
|
||||
action_ids=executable_ids,
|
||||
)
|
||||
self.assertEqual("completed", request.status)
|
||||
|
||||
disabled = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-CALENDAR-DISABLED",
|
||||
request_kind="access",
|
||||
subject=self.subject,
|
||||
purpose="Verify disabled-module coverage.",
|
||||
legal_basis="Article 15 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider, calendar_active=False),
|
||||
row=disabled,
|
||||
expected_revision=1,
|
||||
)
|
||||
|
||||
self.assertEqual(0, disabled.search_result["record_count"])
|
||||
self.assertEqual(
|
||||
[CALENDAR_DSAR_CAPABILITY],
|
||||
disabled.coverage["inactive_provider_capabilities"],
|
||||
)
|
||||
|
||||
def _records(self):
|
||||
return self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
)
|
||||
|
||||
def _preference_delete_action(self):
|
||||
return next(
|
||||
action
|
||||
for action in self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
records=self._records(),
|
||||
)
|
||||
if action.resource_type == "calendar_view_preference" and action.executable
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+30
-1
@@ -3,11 +3,40 @@ from __future__ import annotations
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_calendar.backend.ical import expand_event_occurrences, event_to_ics, parse_vevent
|
||||
from govoplan_calendar.backend.ical import ICalendarError, _expand_event_occurrences, expand_event_occurrences, event_to_ics, parse_vevent
|
||||
|
||||
|
||||
class ICalendarParsingTests(unittest.TestCase):
|
||||
def test_recurrence_limit_rejects_without_materializing_between(self) -> None:
|
||||
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
event = SimpleNamespace(
|
||||
uid="bounded@example.test", start_at=start, end_at=None, duration_seconds=None, all_day=False,
|
||||
rrule={"FREQ": "SECONDLY", "COUNT": "121"}, rdate=[], exdate=[],
|
||||
)
|
||||
with patch("dateutil.rrule.rruleset.between", side_effect=AssertionError("Unbounded allocation")):
|
||||
with self.assertRaisesRegex(ICalendarError, "result limit"):
|
||||
_expand_event_occurrences(
|
||||
event, start, datetime(2026, 7, 1, 0, 2, tzinfo=timezone.utc), limit=1,
|
||||
)
|
||||
|
||||
def test_invalid_worker_ack_is_rejected(self) -> None:
|
||||
from govoplan_core.security.worker_payload import encode_worker_payload
|
||||
|
||||
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
event = SimpleNamespace(
|
||||
uid="expected@example.test", start_at=start, end_at=None, all_day=False,
|
||||
rrule={"FREQ": "DAILY", "COUNT": "1"}, rdate=[], exdate=[],
|
||||
)
|
||||
wrong = encode_worker_payload({"occurrences": [[{
|
||||
"uid": "different@example.test", "recurrence_id": "20260701T000000Z",
|
||||
"start_at": start, "end_at": None, "all_day": False,
|
||||
}]]})
|
||||
with patch("govoplan_core.security.bounded_process.run_bounded_operation", return_value=wrong):
|
||||
with self.assertRaisesRegex(ICalendarError, "invalid result"):
|
||||
expand_event_occurrences(event, start, start)
|
||||
|
||||
def test_parse_vevent_preserves_unknown_properties_and_params(self) -> None:
|
||||
payload = """BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
|
||||
@@ -10,6 +10,14 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class CalendarInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||
for topic in get_manifest().documentation:
|
||||
german = (topic.translations or {}).get("de", {})
|
||||
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||
self.assertTrue(
|
||||
all(str(value).strip() for value in german.values()), topic.id
|
||||
)
|
||||
|
||||
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
|
||||
frontend = get_manifest().frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
@@ -26,17 +34,26 @@ class CalendarInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
"calendar.outbox",
|
||||
"calendar.migration",
|
||||
"calendar.settings.preferences",
|
||||
"calendar.quick_access.agenda",
|
||||
"calendar.widget.upcoming",
|
||||
}
|
||||
self.assertEqual(expected, set(surfaces))
|
||||
self.assertEqual("calendar.page", surfaces["calendar.page.sidebar"].parent_id)
|
||||
self.assertEqual("calendar.page.sidebar", surfaces["calendar.page.agenda"].parent_id)
|
||||
self.assertEqual(
|
||||
"calendar.page.sidebar", surfaces["calendar.page.agenda"].parent_id
|
||||
)
|
||||
self.assertEqual("calendar.page", surfaces["calendar.page.workspace"].parent_id)
|
||||
self.assertEqual("calendar.page", surfaces["calendar.event-editor"].parent_id)
|
||||
self.assertEqual("calendar.page.sidebar", surfaces["calendar.collection-editor"].parent_id)
|
||||
self.assertEqual("calendar.collection-editor", surfaces["calendar.sync-status"].parent_id)
|
||||
self.assertEqual(
|
||||
"calendar.page.sidebar", surfaces["calendar.collection-editor"].parent_id
|
||||
)
|
||||
self.assertEqual(
|
||||
"calendar.collection-editor", surfaces["calendar.sync-status"].parent_id
|
||||
)
|
||||
self.assertEqual("calendar.sync-status", surfaces["calendar.outbox"].parent_id)
|
||||
self.assertEqual("calendar.sync-status", surfaces["calendar.migration"].parent_id)
|
||||
self.assertEqual(
|
||||
"calendar.sync-status", surfaces["calendar.migration"].parent_id
|
||||
)
|
||||
|
||||
def test_help_and_consequence_metadata_remain_published(self) -> None:
|
||||
topics = {topic.id: topic for topic in get_manifest().documentation}
|
||||
@@ -53,16 +70,54 @@ class CalendarInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
self.assertIn("calendar.outbox", recovery.metadata["help_contexts"])
|
||||
self.assertIn("reconcile_outbox", recovery.metadata["consequence_classes"])
|
||||
|
||||
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
|
||||
event_dialog = (REPO_ROOT / "webui/src/features/calendar/CalendarEventDialog.tsx").read_text(encoding="utf-8")
|
||||
collection_dialog = (REPO_ROOT / "webui/src/features/calendar/CalendarCollectionDialogs.tsx").read_text(encoding="utf-8")
|
||||
settings_panel = (REPO_ROOT / "webui/src/features/calendar/CalendarSettingsPanel.tsx").read_text(encoding="utf-8")
|
||||
quick_tool = get_manifest().frontend.quick_access_tools[0]
|
||||
self.assertEqual(("calendar.event",), quick_tool.returned_reference_kinds)
|
||||
self.assertEqual("calendar.quick_access.agenda", quick_tool.help_context_id)
|
||||
self.assertEqual("/calendar", quick_tool.full_page_path)
|
||||
|
||||
for component in ("ActionBlockerHint", "ConfirmDialog", "DocumentationHelpLink", "useUnsavedDraftGuard"):
|
||||
def test_quick_access_is_bounded_temporal_and_owner_launched(self) -> None:
|
||||
quick_access = (
|
||||
REPO_ROOT / "webui/src/features/calendar/CalendarQuickAccess.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
page = (REPO_ROOT / "webui/src/features/calendar/CalendarPage.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
self.assertIn("const AGENDA_LIMIT = 7", quick_access)
|
||||
self.assertIn("launchContext.temporalContext", quick_access)
|
||||
self.assertIn("limit: AGENDA_LIMIT", quick_access)
|
||||
self.assertIn('kind: "event"', quick_access)
|
||||
self.assertIn("quickAccessLaunchState(launchContext)", quick_access)
|
||||
self.assertIn('parameters.get("quickAction") !== "create-event"', page)
|
||||
|
||||
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
|
||||
event_dialog = (
|
||||
REPO_ROOT / "webui/src/features/calendar/CalendarEventDialog.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
collection_dialog = (
|
||||
REPO_ROOT / "webui/src/features/calendar/CalendarCollectionDialogs.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
settings_panel = (
|
||||
REPO_ROOT / "webui/src/features/calendar/CalendarSettingsPanel.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
for component in (
|
||||
"ActionBlockerHint",
|
||||
"ConfirmDialog",
|
||||
"titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}",
|
||||
"useUnsavedDraftGuard",
|
||||
):
|
||||
self.assertIn(component, event_dialog)
|
||||
for component in ("ActionBlockerHint", "DocumentationHelpLink", "useUnsavedDraftGuard"):
|
||||
for component in (
|
||||
"ActionBlockerHint",
|
||||
"titleHelp={<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />}",
|
||||
"useUnsavedDraftGuard",
|
||||
):
|
||||
self.assertIn(component, collection_dialog)
|
||||
for component in ("DocumentationHelpLink", "useUnsavedDraftGuard"):
|
||||
for component in (
|
||||
"titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}",
|
||||
"useUnsavedDraftGuard",
|
||||
):
|
||||
self.assertIn(component, settings_panel)
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine, create_mock_engine, event as sqlalchemy_event
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.orm import Query, Session, sessionmaker
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||
from govoplan_calendar.backend.db.models import CalendarEvent
|
||||
@@ -16,6 +18,8 @@ from govoplan_calendar.backend.schemas import (
|
||||
)
|
||||
from govoplan_calendar.backend.service import (
|
||||
CalendarError,
|
||||
_bounded_occurrence_candidates,
|
||||
OCCURRENCE_DETAIL_FIELDS,
|
||||
create_calendar,
|
||||
create_event,
|
||||
delete_event,
|
||||
@@ -73,6 +77,159 @@ class CalendarRecurrenceAndPreferenceTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_freebusy_returns_all_1001_hourly_occurrences(self) -> None:
|
||||
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
create_event(
|
||||
self.session, tenant_id="tenant-1", user_id=None,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=self.calendar.id, uid="dense@example.test", summary="Dense series",
|
||||
start_at=start, end_at=start + timedelta(minutes=1),
|
||||
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
busy = list_freebusy(
|
||||
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
|
||||
start_at=start, end_at=start + timedelta(days=43),
|
||||
)
|
||||
self.assertEqual(1001, len(busy))
|
||||
self.assertEqual(start + timedelta(hours=1000), busy[-1]["start_at"])
|
||||
|
||||
def test_freebusy_over_budget_fails_without_partial_busy_slots(self) -> None:
|
||||
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
create_event(
|
||||
self.session, tenant_id="tenant-1", user_id=None,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=self.calendar.id, uid="excessive@example.test", summary="Excessive series",
|
||||
start_at=start, end_at=start + timedelta(seconds=1),
|
||||
rrule={"FREQ": "MINUTELY", "COUNT": "10001"},
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
with self.assertRaisesRegex(CalendarError, "could not complete"):
|
||||
list_freebusy(
|
||||
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
|
||||
start_at=start, end_at=start + timedelta(days=8),
|
||||
)
|
||||
self.assertEqual(1, self.session.query(CalendarEvent).count())
|
||||
|
||||
def test_full_expansion_byte_cap_and_lightweight_complete_freebusy(self) -> None:
|
||||
from govoplan_calendar.backend import service
|
||||
|
||||
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
create_event(
|
||||
self.session, tenant_id="tenant-1", user_id=None,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=self.calendar.id, uid="heavy@example.test", summary="Heavy series",
|
||||
description="x" * 65536, start_at=start, end_at=start + timedelta(minutes=1),
|
||||
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
calendar_id = self.calendar.id
|
||||
self.session.expunge_all()
|
||||
with patch.object(service, "expanded_event_response", wraps=service.expanded_event_response) as expand:
|
||||
with self.assertRaisesRegex(CalendarError, "response exceeds its byte limit"):
|
||||
list_event_occurrences(self.session, tenant_id="tenant-1", start_at=start, end_at=start + timedelta(days=43), limit=1)
|
||||
self.assertLess(expand.call_count, 70)
|
||||
statements = []
|
||||
loaded = []
|
||||
def capture(conn, cursor, statement, parameters, context, executemany):
|
||||
statements.append(statement)
|
||||
def capture_loaded(session, instance):
|
||||
if isinstance(instance, CalendarEvent):
|
||||
loaded.append(instance)
|
||||
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
|
||||
sqlalchemy_event.listen(self.session, "loaded_as_persistent", capture_loaded)
|
||||
try:
|
||||
with patch.object(service, "event_response", side_effect=AssertionError("Full event data is unnecessary for availability")):
|
||||
busy = list_freebusy(self.session, tenant_id="tenant-1", calendar_ids=[calendar_id], start_at=start, end_at=start + timedelta(days=43))
|
||||
self.assertEqual(1001, len(busy))
|
||||
self.assertEqual([], loaded)
|
||||
for field in ("description", "raw_ics", "icalendar", "metadata", "attendees", "attachments"):
|
||||
self.assertNotIn(f"calendar_events.{field}", "\n".join(statements))
|
||||
finally:
|
||||
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
|
||||
sqlalchemy_event.remove(self.session, "loaded_as_persistent", capture_loaded)
|
||||
|
||||
def test_aggregate_sql_projection_hides_over_budget_values_before_driver_decoding(self) -> None:
|
||||
from govoplan_calendar.backend import service
|
||||
|
||||
first = self.recurring_master()
|
||||
second = create_event(
|
||||
self.session, tenant_id="tenant-1", user_id=None,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=self.calendar.id, uid="second@example.test", summary="Second",
|
||||
start_at=first.start_at, end_at=first.end_at, rrule={"FREQ": "DAILY", "COUNT": "2"},
|
||||
),
|
||||
)
|
||||
first.description = second.description = "x" * 1500
|
||||
self.session.commit()
|
||||
self.session.expunge_all()
|
||||
captured = []
|
||||
def capture(conn, cursor, statement, parameters, context, executemany):
|
||||
captured.append((statement, parameters))
|
||||
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
|
||||
try:
|
||||
with patch.object(service, "MAX_OCCURRENCE_PROJECTION_BYTES", 3000), patch.object(service, "expand_events_occurrences") as worker:
|
||||
with self.assertRaisesRegex(CalendarError, "candidate projection exceeds its byte limit"):
|
||||
list_event_occurrences(
|
||||
self.session, tenant_id="tenant-1", start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||
)
|
||||
worker.assert_not_called()
|
||||
finally:
|
||||
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
|
||||
self.assertEqual(1, len(captured))
|
||||
statement, parameters = captured[0]
|
||||
self.assertIn("sum(", statement)
|
||||
self.assertIn("OVER (ORDER BY", statement)
|
||||
with self.engine.connect() as connection:
|
||||
raw = list(connection.exec_driver_sql(statement, parameters))
|
||||
self.assertEqual(2, len(raw))
|
||||
self.assertIsNotNone(raw[0][1])
|
||||
self.assertTrue(all(value is None for value in raw[1][1:]))
|
||||
|
||||
def test_sql_projection_compiles_postgresql_without_binary_json_casts(self) -> None:
|
||||
statements = []
|
||||
session = Session(bind=create_mock_engine("postgresql://", lambda *args, **kwargs: None))
|
||||
def inspect_query(query):
|
||||
statements.append(str(query.statement.compile(dialect=postgresql.dialect())))
|
||||
return iter(())
|
||||
with patch.object(Query, "__iter__", inspect_query):
|
||||
self.assertEqual([], _bounded_occurrence_candidates(
|
||||
session.query(CalendarEvent).filter(CalendarEvent.tenant_id == "tenant-1").limit(2001),
|
||||
OCCURRENCE_DETAIL_FIELDS, [4096],
|
||||
))
|
||||
self.assertIn("octet_length(CAST(calendar_events.icalendar AS TEXT))", statements[0])
|
||||
self.assertIn("CASE WHEN", statements[0])
|
||||
self.assertIn("OVER (ORDER BY", statements[0])
|
||||
self.assertNotIn("BYTEA", statements[0])
|
||||
|
||||
def test_candidate_budget_is_not_bypassed_by_response_limit(self) -> None:
|
||||
self.recurring_master()
|
||||
self.session.commit()
|
||||
with patch("govoplan_calendar.backend.service.MAX_OCCURRENCE_CANDIDATES", 0):
|
||||
with self.assertRaisesRegex(CalendarError, "Too many recurring series"):
|
||||
list_event_occurrences(
|
||||
self.session, tenant_id="tenant-1", limit=1,
|
||||
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
def test_admission_rejection_precedes_database_projection(self) -> None:
|
||||
from govoplan_core.security.bounded_process import ProcessBudgetError
|
||||
|
||||
with patch("govoplan_core.security.bounded_process.bounded_operation_admission", side_effect=ProcessBudgetError("busy")):
|
||||
with patch.object(self.session, "query") as query:
|
||||
with self.assertRaisesRegex(CalendarError, "could not complete"):
|
||||
list_event_occurrences(
|
||||
self.session, tenant_id="tenant-1",
|
||||
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||
)
|
||||
query.assert_not_called()
|
||||
|
||||
def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
+88
-1
@@ -5,8 +5,20 @@ from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, User
|
||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent
|
||||
from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse
|
||||
from govoplan_calendar.backend.service import calendar_is_visible_to_principal, delete_calendar, event_response
|
||||
from govoplan_calendar.backend.service import (
|
||||
calendar_is_visible_to_principal,
|
||||
delete_calendar,
|
||||
event_response,
|
||||
list_event_occurrences,
|
||||
list_events,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class FakeSession:
|
||||
@@ -108,6 +120,81 @@ class CalendarVisibilityTests(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_event_queries_apply_visible_calendar_fence_and_limit(self) -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=(
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
CalendarCollection.__table__,
|
||||
CalendarEvent.__table__,
|
||||
),
|
||||
)
|
||||
session = Session(engine)
|
||||
start = datetime(2026, 8, 19, 9, tzinfo=timezone.utc)
|
||||
try:
|
||||
session.add_all(
|
||||
(
|
||||
CalendarCollection(
|
||||
id="visible-calendar",
|
||||
tenant_id="tenant-1",
|
||||
slug="visible",
|
||||
name="Visible",
|
||||
visibility="tenant",
|
||||
),
|
||||
CalendarCollection(
|
||||
id="private-calendar",
|
||||
tenant_id="tenant-1",
|
||||
slug="private",
|
||||
name="Private",
|
||||
visibility="private",
|
||||
owner_type="user",
|
||||
owner_id="other-user",
|
||||
),
|
||||
CalendarEvent(
|
||||
id="visible-event",
|
||||
tenant_id="tenant-1",
|
||||
calendar_id="visible-calendar",
|
||||
uid="visible@example.test",
|
||||
summary="Visible event",
|
||||
start_at=start,
|
||||
end_at=start + timedelta(hours=1),
|
||||
),
|
||||
CalendarEvent(
|
||||
id="private-event",
|
||||
tenant_id="tenant-1",
|
||||
calendar_id="private-calendar",
|
||||
uid="private@example.test",
|
||||
summary="Private event",
|
||||
start_at=start + timedelta(hours=2),
|
||||
end_at=start + timedelta(hours=3),
|
||||
),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
events = list_events(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
visible_calendar_ids=("visible-calendar",),
|
||||
limit=1,
|
||||
)
|
||||
occurrences = list_event_occurrences(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
start_at=start - timedelta(hours=1),
|
||||
end_at=start + timedelta(days=1),
|
||||
visible_calendar_ids=("visible-calendar",),
|
||||
limit=1,
|
||||
)
|
||||
|
||||
self.assertEqual(["visible-event"], [event.id for event in events])
|
||||
self.assertEqual(["visible-event"], [event["id"] for event in occurrences])
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
def test_private_calendar_is_hidden_from_other_readers_but_visible_to_admin(self) -> None:
|
||||
calendar = self.calendar()
|
||||
self.assertFalse(
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/calendar-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -18,7 +18,7 @@
|
||||
"test:calendar-page": "tsc -p tsconfig.calendar-page-tests.json && node --experimental-strip-types tests/calendar-view-model.test.ts && node tests/calendar-page-structure.test.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@govoplan/core-webui": "^0.1.44",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -472,13 +472,14 @@ export function cancelCalendarMigration(
|
||||
|
||||
export function listCalendarEvents(
|
||||
settings: ApiSettings,
|
||||
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {}
|
||||
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean; limit?: number } = {}
|
||||
): Promise<CalendarEventListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
|
||||
if (params.start_at) search.set("start_at", params.start_at);
|
||||
if (params.end_at) search.set("end_at", params.end_at);
|
||||
if (params.expand_recurring) search.set("expand_recurring", "true");
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { ArrowRightLeft, ListChecks, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
import { FormGrid, DialogForm,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
ColorPickerField,
|
||||
@@ -352,7 +352,8 @@ export function CalendarCollectionDialog({
|
||||
<Dialog
|
||||
open
|
||||
title={isEdit ? "i18n:govoplan-calendar.edit_calendar.a47a2a7a" : "i18n:govoplan-calendar.add_calendar.8fadb5bc"}
|
||||
className="calendar-event-dialog"
|
||||
titleHelp={<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />}
|
||||
size="large"
|
||||
footerClassName="calendar-event-dialog-footer"
|
||||
closeDisabled={saving}
|
||||
onClose={onCancel}
|
||||
@@ -372,10 +373,7 @@ export function CalendarCollectionDialog({
|
||||
</>
|
||||
}>
|
||||
|
||||
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
||||
<div className="calendar-dialog-documentation">
|
||||
<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />
|
||||
</div>
|
||||
<DialogForm id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
||||
{migrationLocked && (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
@@ -443,7 +441,7 @@ export function CalendarCollectionDialog({
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div className="calendar-caldav-setup">
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow" className="calendar-caldav-setup">
|
||||
<label className="calendar-dialog-wide">
|
||||
<span>{calendarSourceUrlLabel(sourceMode)}</span>
|
||||
<input
|
||||
@@ -532,7 +530,7 @@ export function CalendarCollectionDialog({
|
||||
}
|
||||
{effectiveCollectionUrl && <span>{effectiveCollectionUrl}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</FormGrid>
|
||||
{discoveryError && <p className="calendar-form-error">{discoveryError}</p>}
|
||||
{calendarSourceUsesCalDav(sourceMode) && discoveredCalendars.length > 0 &&
|
||||
<label>
|
||||
@@ -548,13 +546,13 @@ export function CalendarCollectionDialog({
|
||||
}
|
||||
<details className="calendar-advanced-settings">
|
||||
<summary>i18n:govoplan-calendar.advanced.4d064726</summary>
|
||||
<div className="calendar-dialog-grid-two">
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.display_name.c7874aaa</span>
|
||||
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="calendar-sync-settings">
|
||||
</FormGrid>
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||
<ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} />
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.interval.011efcd5</span>
|
||||
@@ -574,7 +572,7 @@ export function CalendarCollectionDialog({
|
||||
<option value="overwrite">i18n:govoplan-calendar.overwrite_remote.39625e32</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</details>
|
||||
{source &&
|
||||
<div className="calendar-sync-status-panel">
|
||||
@@ -614,7 +612,7 @@ export function CalendarCollectionDialog({
|
||||
{needsSourceSecret && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54</p>}
|
||||
</section>
|
||||
}
|
||||
</form>
|
||||
</DialogForm>
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
@@ -675,6 +673,7 @@ export function CalendarCollectionDeleteDialog({
|
||||
open
|
||||
role="alertdialog"
|
||||
title={`${actionLabel} calendar`}
|
||||
titleHelp={<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />}
|
||||
className="calendar-delete-dialog"
|
||||
footerClassName="calendar-event-dialog-footer"
|
||||
closeDisabled={saving}
|
||||
@@ -689,9 +688,6 @@ export function CalendarCollectionDeleteDialog({
|
||||
}>
|
||||
|
||||
<div className="calendar-delete-dialog-body">
|
||||
<div className="calendar-dialog-documentation">
|
||||
<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />
|
||||
</div>
|
||||
<p className="calendar-delete-warning">
|
||||
{loadingEventCount ?
|
||||
"i18n:govoplan-calendar.loading_event_count.716ad3c2" :
|
||||
@@ -703,6 +699,8 @@ export function CalendarCollectionDeleteDialog({
|
||||
<legend>{eventCount} event{eventCount === 1 ? "" : "s"}</legend>
|
||||
<label>
|
||||
<input
|
||||
data-help-context-id="calendar.collection-editor"
|
||||
data-help-module-id="calendar"
|
||||
type="radio"
|
||||
name="calendar-delete-event-action"
|
||||
value="delete"
|
||||
@@ -713,6 +711,8 @@ export function CalendarCollectionDeleteDialog({
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
data-help-context-id="calendar.collection-editor"
|
||||
data-help-module-id="calendar"
|
||||
type="radio"
|
||||
name="calendar-delete-event-action"
|
||||
value="move"
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
import { FormGrid, DialogForm,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
@@ -281,7 +281,8 @@ export function CalendarEventDialog({
|
||||
<Dialog
|
||||
open
|
||||
title={event ? "i18n:govoplan-calendar.edit_event.a7028454" : "i18n:govoplan-calendar.new_event.2ef3795c"}
|
||||
className="calendar-event-dialog calendar-vevent-dialog"
|
||||
titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}
|
||||
className="calendar-vevent-dialog"
|
||||
footerClassName="calendar-event-dialog-footer"
|
||||
closeDisabled={saving}
|
||||
onClose={onCancel}
|
||||
@@ -289,7 +290,7 @@ export function CalendarEventDialog({
|
||||
<>
|
||||
<div>
|
||||
{event && canDelete &&
|
||||
<Button type="button" variant="danger" onClick={requestDelete} disabled={saving} disabledReason={saving ? CALENDAR_I18N.saving : undefined}>
|
||||
<Button type="button" variant="danger" helpContextId="calendar.event-editor" helpModuleId="calendar" onClick={requestDelete} disabled={saving} disabledReason={saving ? CALENDAR_I18N.saving : undefined}>
|
||||
<Trash2 size={16} /> i18n:govoplan-calendar.delete.f6fdbe48
|
||||
</Button>
|
||||
}
|
||||
@@ -301,10 +302,7 @@ export function CalendarEventDialog({
|
||||
</>
|
||||
}>
|
||||
|
||||
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
||||
<div className="calendar-dialog-documentation">
|
||||
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
|
||||
</div>
|
||||
<DialogForm id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
||||
{!canWrite && (
|
||||
<ActionBlockerHint
|
||||
tone="info"
|
||||
@@ -355,7 +353,7 @@ export function CalendarEventDialog({
|
||||
<input value={location} onChange={(item) => setLocation(item.target.value)} maxLength={500} disabled={saving || !canWrite} />
|
||||
</label>
|
||||
<ToggleSwitch label="i18n:govoplan-calendar.whole_day.951c82d1" checked={allDay} disabled={saving || !canWrite} onChange={handleAllDayChange} />
|
||||
<div className="calendar-dialog-date-row">
|
||||
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.start_date.ff99f5b5</span>
|
||||
<DateField value={startDate} onChange={handleStartDateChange} required disabled={saving || !canWrite} />
|
||||
@@ -364,9 +362,9 @@ export function CalendarEventDialog({
|
||||
<span>i18n:govoplan-calendar.start_time.88d8206d</span>
|
||||
<TimeField value={startTime} onChange={handleStartTimeChange} disabled={saving || !canWrite || allDay} />
|
||||
</label>
|
||||
</div>
|
||||
</FormGrid>
|
||||
{!allDay &&
|
||||
<div className="calendar-dialog-date-row">
|
||||
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.end_mode.5a06de37</span>
|
||||
<select value={endMode} onChange={(item) => setEndMode(item.target.value as CalendarEventEndMode)} disabled={saving || !canWrite}>
|
||||
@@ -380,10 +378,10 @@ export function CalendarEventDialog({
|
||||
<input type="number" min={1} step={60} value={durationSeconds} onChange={(item) => setDurationSeconds(item.target.value)} required disabled={saving || !canWrite} />
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</FormGrid>
|
||||
}
|
||||
{(allDay || endMode === "end") &&
|
||||
<div className="calendar-dialog-date-row">
|
||||
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.end_date.89d10cd6</span>
|
||||
<DateField value={endDate} min={startDate} onChange={setEndDate} required disabled={saving || !canWrite} />
|
||||
@@ -392,13 +390,13 @@ export function CalendarEventDialog({
|
||||
<span>i18n:govoplan-calendar.end_time.cd7800da</span>
|
||||
<TimeField value={endTime} min={!allDay && startDate === endDate ? startTime : undefined} onChange={setEndTime} disabled={saving || !canWrite || allDay} />
|
||||
</label>
|
||||
</div>
|
||||
</FormGrid>
|
||||
}
|
||||
<details className="calendar-advanced-settings calendar-vevent-details">
|
||||
<summary>i18n:govoplan-calendar.vevent.9cf5be75</summary>
|
||||
<section className="calendar-vevent-section">
|
||||
<h3>i18n:govoplan-calendar.identity.7e5a975b</h3>
|
||||
<div className="calendar-dialog-grid-three">
|
||||
<FormGrid columns={3} gap="small" collapseAt="narrow">
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.uid.d946adf5</span>
|
||||
<input value={uid} onChange={(item) => setUid(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
|
||||
@@ -411,11 +409,11 @@ export function CalendarEventDialog({
|
||||
<span>i18n:govoplan-calendar.sequence.5c8f4e0e</span>
|
||||
<input type="number" min={0} step={1} value={sequence} onChange={(item) => setSequence(item.target.value)} disabled={saving || !canWrite} />
|
||||
</label>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</section>
|
||||
<section className="calendar-vevent-section">
|
||||
<h3>i18n:govoplan-calendar.state.a7250206</h3>
|
||||
<div className="calendar-dialog-grid-three">
|
||||
<FormGrid columns={3} gap="small" collapseAt="narrow">
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.status.bae7d5be</span>
|
||||
<select value={status} onChange={(item) => setStatus(item.target.value)} disabled={saving || !canWrite}>
|
||||
@@ -447,11 +445,11 @@ export function CalendarEventDialog({
|
||||
<span>i18n:govoplan-calendar.categories.6ccb6007</span>
|
||||
<input value={categoriesText} onChange={(item) => setCategoriesText(item.target.value)} disabled={saving || !canWrite} />
|
||||
</label>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</section>
|
||||
<section className="calendar-vevent-section">
|
||||
<h3>i18n:govoplan-calendar.participants.cd56e083</h3>
|
||||
<div className="calendar-dialog-grid-two">
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.organizer_json.3add6f9f</span>
|
||||
<textarea value={organizerJson} onChange={(item) => setOrganizerJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
||||
@@ -460,7 +458,7 @@ export function CalendarEventDialog({
|
||||
<span>i18n:govoplan-calendar.attendees_json.aeb487bb</span>
|
||||
<textarea value={attendeesJson} onChange={(item) => setAttendeesJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
||||
</label>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</section>
|
||||
<section className="calendar-vevent-section">
|
||||
<h3>i18n:govoplan-calendar.recurrence.f7ad40f5</h3>
|
||||
@@ -468,7 +466,7 @@ export function CalendarEventDialog({
|
||||
<span>i18n:govoplan-calendar.rrule.c7b2f8a3</span>
|
||||
<input value={rruleText} onChange={(item) => setRruleText(item.target.value)} disabled={saving || !canWrite} />
|
||||
</label>
|
||||
<div className="calendar-dialog-grid-two">
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.rdate_json.5b51fca4</span>
|
||||
<textarea value={rdateJson} onChange={(item) => setRdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
||||
@@ -477,11 +475,11 @@ export function CalendarEventDialog({
|
||||
<span>i18n:govoplan-calendar.exdate_json.7d0c538d</span>
|
||||
<textarea value={exdateJson} onChange={(item) => setExdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
||||
</label>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</section>
|
||||
<section className="calendar-vevent-section">
|
||||
<h3>i18n:govoplan-calendar.related.917df91e</h3>
|
||||
<div className="calendar-dialog-grid-three">
|
||||
<FormGrid columns={3} gap="small" collapseAt="narrow">
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.reminders_json.ca25e08f</span>
|
||||
<textarea value={remindersJson} onChange={(item) => setRemindersJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
||||
@@ -494,11 +492,11 @@ export function CalendarEventDialog({
|
||||
<span>i18n:govoplan-calendar.related_to_json.2d4e8f59</span>
|
||||
<textarea value={relatedToJson} onChange={(item) => setRelatedToJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
|
||||
</label>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</section>
|
||||
<section className="calendar-vevent-section">
|
||||
<h3>i18n:govoplan-calendar.source.6da13add</h3>
|
||||
<div className="calendar-dialog-grid-three">
|
||||
<FormGrid columns={3} gap="small" collapseAt="narrow">
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.source_kind.7eda9bc4</span>
|
||||
<input value={sourceKind} onChange={(item) => setSourceKind(item.target.value)} maxLength={30} disabled={saving || !canWrite} />
|
||||
@@ -511,11 +509,11 @@ export function CalendarEventDialog({
|
||||
<span>i18n:govoplan-calendar.etag.11d00f6e</span>
|
||||
<input value={etag} onChange={(item) => setEtag(item.target.value)} maxLength={255} disabled={saving || !canWrite} />
|
||||
</label>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</section>
|
||||
<section className="calendar-vevent-section">
|
||||
<h3>i18n:govoplan-calendar.raw.da433cd4</h3>
|
||||
<div className="calendar-dialog-grid-two">
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.icalendar_json.fb6cc33e</span>
|
||||
<textarea value={icalendarJson} onChange={(item) => setICalendarJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
|
||||
@@ -524,10 +522,10 @@ export function CalendarEventDialog({
|
||||
<span>i18n:govoplan-calendar.metadata_json.b0e4c283</span>
|
||||
<textarea value={metadataJson} onChange={(item) => setMetadataJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
|
||||
</label>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</section>
|
||||
</details>
|
||||
</form>
|
||||
</DialogForm>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={Boolean(event && confirmingDelete)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { RefreshCw, XCircle } from "lucide-react";
|
||||
import {
|
||||
ActionToolbar,
|
||||
Button,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
@@ -96,6 +97,7 @@ export function CalendarMigrationDialog({
|
||||
<Dialog
|
||||
open
|
||||
title="Remote calendar move"
|
||||
titleHelp={<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />}
|
||||
className="calendar-migration-dialog"
|
||||
closeDisabled={working}
|
||||
onClose={onClose}
|
||||
@@ -112,7 +114,6 @@ export function CalendarMigrationDialog({
|
||||
<LoadingFrame loading label="Loading remote move"><div /></LoadingFrame>
|
||||
) : (
|
||||
<div className="calendar-migration-body">
|
||||
<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />
|
||||
{error && (
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
@@ -120,13 +121,13 @@ export function CalendarMigrationDialog({
|
||||
)}
|
||||
{batch && (
|
||||
<>
|
||||
<div className="calendar-migration-heading">
|
||||
<ActionToolbar surface="section-header" className="calendar-migration-heading">
|
||||
<div>
|
||||
<strong>{phaseLabel(batch.phase)}</strong>
|
||||
<span>Updated {dateTimeLabel(new Date(batch.updated_at))}</span>
|
||||
</div>
|
||||
<StatusBadge status={batch.status} label={statusLabel(batch.status)} />
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<progress value={progress} max={100} aria-label="Remote move progress" />
|
||||
<dl className="calendar-migration-summary">
|
||||
<div><dt>Events</dt><dd>{batch.total_events}</dd></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshCw, RotateCcw, ScanSearch, Trash2 } from "lucide-react";
|
||||
import {
|
||||
import { ActionToolbar,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
@@ -95,6 +95,7 @@ export function CalendarOutboxDialog({
|
||||
<Dialog
|
||||
open
|
||||
title="i18n:govoplan-calendar.outbound_changes.7038a839"
|
||||
titleHelp={<DocumentationHelpLink reference={CALENDAR_RECOVERY_DOCUMENTATION} />}
|
||||
className="calendar-outbox-dialog"
|
||||
closeDisabled={Boolean(busyOperationId)}
|
||||
onClose={onClose}
|
||||
@@ -111,9 +112,8 @@ export function CalendarOutboxDialog({
|
||||
>
|
||||
<div className="calendar-outbox-body">
|
||||
<p className="calendar-outbox-calendar-name">{calendar.name}</p>
|
||||
<DocumentationHelpLink reference={CALENDAR_RECOVERY_DOCUMENTATION} />
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<div className="calendar-outbox-toolbar">
|
||||
<ActionToolbar justify="between" className="calendar-outbox-toolbar">
|
||||
<SegmentedControl<OutboxFilter>
|
||||
value={filter}
|
||||
ariaLabel="i18n:govoplan-calendar.outbox_filter.8305af40"
|
||||
@@ -128,7 +128,7 @@ export function CalendarOutboxDialog({
|
||||
<div><dt>i18n:govoplan-calendar.conflicts_dead.31252c3a</dt><dd>{conflictCount}</dd></div>
|
||||
<div><dt>i18n:govoplan-calendar.shown.498e85a1</dt><dd>{visibleOperations.length}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
{loading && !operations.length
|
||||
? <p className="calendar-form-note">i18n:govoplan-calendar.loading_outbound_changes.3fc59656</p>
|
||||
: visibleOperations.length
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
type WheelEvent as ReactWheelEvent } from
|
||||
"react";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
import { ToolbarGroup, ActionToolbar,
|
||||
AdminIconButton,
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
TextWithHelp,
|
||||
LoadingFrame,
|
||||
SegmentedControl,
|
||||
TableActionGroup,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import {
|
||||
createCalendar,
|
||||
createCalendarEvent,
|
||||
@@ -124,6 +126,8 @@ const modeOptions: {id: CalendarMode;label: string;}[] = [
|
||||
|
||||
|
||||
export default function CalendarPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
|
||||
const [syncSources, setSyncSources] = useState<CalendarSyncSource[]>([]);
|
||||
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
|
||||
@@ -185,6 +189,38 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
void loadCalendars();
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const parameters = new URLSearchParams(location.search);
|
||||
const focusParameter = parameters.get("focusDate");
|
||||
const requestedFocus = validCalendarLaunchDate(focusParameter);
|
||||
if (requestedFocus) setFocusDate(requestedFocus);
|
||||
if (parameters.get("quickAction") !== "create-event" || loading) return;
|
||||
|
||||
const requestedStart = validCalendarLaunchDate(parameters.get("startAt"));
|
||||
if (canWrite && calendars.length > 0 && targetCalendarId) {
|
||||
setFocusDate(requestedStart ?? requestedFocus ?? new Date());
|
||||
setEventDialog({ kind: "create" });
|
||||
}
|
||||
|
||||
parameters.delete("quickAction");
|
||||
parameters.delete("startAt");
|
||||
if (requestedStart) parameters.set("focusDate", requestedStart.toISOString());
|
||||
const search = parameters.toString();
|
||||
navigate(
|
||||
{ pathname: location.pathname, search: search ? `?${search}` : "" },
|
||||
{ replace: true, state: location.state }
|
||||
);
|
||||
}, [
|
||||
calendars.length,
|
||||
canWrite,
|
||||
loading,
|
||||
location.pathname,
|
||||
location.search,
|
||||
location.state,
|
||||
navigate,
|
||||
targetCalendarId
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
saveCalendarMode(mode);
|
||||
}, [mode]);
|
||||
@@ -764,7 +800,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-calendar.loading_calendar.7eb8f548" className="calendar-loading-frame">
|
||||
<div className="calendar-shell">
|
||||
<aside className="calendar-sidebar">
|
||||
<div className="calendar-sidebar-heading">i18n:govoplan-calendar.calendars.94445018</div>
|
||||
<TextWithHelp as="div" className="calendar-sidebar-heading" help={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}>i18n:govoplan-calendar.calendars.94445018</TextWithHelp>
|
||||
<div className="calendar-list">
|
||||
{calendars.map((calendar) => {
|
||||
const source = syncSourceByCalendarId.get(calendar.id) ?? null;
|
||||
@@ -854,8 +890,8 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
</aside>
|
||||
|
||||
<section className="calendar-main-panel" aria-label="i18n:govoplan-calendar.calendar.adab5090">
|
||||
<div className="calendar-view-toolbar" aria-label="i18n:govoplan-calendar.calendar_controls.974f4fa1">
|
||||
<div className="calendar-toolbar-left">
|
||||
<ActionToolbar className="calendar-view-toolbar" aria-label="i18n:govoplan-calendar.calendar_controls.974f4fa1">
|
||||
<ToolbarGroup grow className="calendar-toolbar-left">
|
||||
<SegmentedControl
|
||||
className="calendar-mode-switch"
|
||||
size="equal"
|
||||
@@ -870,9 +906,9 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
}}
|
||||
options={modeOptions}
|
||||
/>
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
|
||||
<div className="calendar-toolbar-center">
|
||||
<ToolbarGroup align="center" className="calendar-toolbar-center">
|
||||
<div className="calendar-icon-group" aria-label="i18n:govoplan-calendar.calendar_navigation.7ba43cd2">
|
||||
<AdminIconButton
|
||||
label="i18n:govoplan-calendar.previous.50f94286"
|
||||
@@ -892,10 +928,9 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
<strong>{heading}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
|
||||
<div className="calendar-toolbar-right">
|
||||
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
|
||||
<ToolbarGroup align="end" className="calendar-toolbar-right">
|
||||
<AdminIconButton
|
||||
label="i18n:govoplan-calendar.refresh.56e3badc"
|
||||
icon={<RefreshCw size={18} />}
|
||||
@@ -906,8 +941,8 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
<Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
</ActionToolbar>
|
||||
|
||||
<div className={`calendar-view-shell is-${mode}`}>
|
||||
{mode === "continuous" ?
|
||||
@@ -1083,6 +1118,12 @@ function calendarRemoteMoveBatchId(calendar: CalendarCollection): string {
|
||||
return typeof batchId === "string" ? batchId : "";
|
||||
}
|
||||
|
||||
function validCalendarLaunchDate(value: string | null): Date | null {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function calendarViewPreferences(
|
||||
response: CalendarViewPreferencesResponse
|
||||
): CalendarViewPreferences {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { CalendarDays, ExternalLink, MapPin, Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
hasScope,
|
||||
quickAccessLaunchState,
|
||||
useDashboardWidgetData,
|
||||
type QuickAccessToolRenderContext
|
||||
} from "@govoplan/core-webui";
|
||||
import { listCalendarEvents, type CalendarEvent } from "../../api/calendar";
|
||||
|
||||
const AGENDA_DAYS = 21;
|
||||
const AGENDA_LIMIT = 7;
|
||||
|
||||
type Props = Pick<
|
||||
QuickAccessToolRenderContext,
|
||||
"settings" | "auth" | "launchContext" | "complete" | "close"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Calendar-owned, range- and result-bounded agenda. The API applies tenant,
|
||||
* scope, and collection-visibility checks before any event reaches the rail.
|
||||
*/
|
||||
export default function CalendarQuickAccess({
|
||||
settings,
|
||||
auth,
|
||||
launchContext,
|
||||
complete,
|
||||
close
|
||||
}: Props) {
|
||||
const [selectedKey, setSelectedKey] = useState("");
|
||||
const rangeStart = useMemo(
|
||||
() => agendaStart(launchContext.temporalContext),
|
||||
[
|
||||
launchContext.temporalContext.validAt,
|
||||
launchContext.temporalContext.validityMode
|
||||
]
|
||||
);
|
||||
const rangeEnd = useMemo(() => {
|
||||
const end = new Date(rangeStart);
|
||||
end.setDate(end.getDate() + AGENDA_DAYS);
|
||||
return end;
|
||||
}, [rangeStart]);
|
||||
const load = useCallback(async () => {
|
||||
const response = await listCalendarEvents(settings, {
|
||||
start_at: rangeStart.toISOString(),
|
||||
end_at: rangeEnd.toISOString(),
|
||||
expand_recurring: true,
|
||||
limit: AGENDA_LIMIT
|
||||
});
|
||||
return response.events.filter(
|
||||
(event) => event.status.toUpperCase() !== "CANCELLED"
|
||||
);
|
||||
}, [rangeEnd, rangeStart, settings]);
|
||||
const { data: events, loading, error } = useDashboardWidgetData(load, 0);
|
||||
const items = events ?? [];
|
||||
const selected = useMemo(
|
||||
() => items.find((event) => eventKey(event) === selectedKey) ?? items[0] ?? null,
|
||||
[items, selectedKey]
|
||||
);
|
||||
const canCreate = hasScope(auth, "calendar:event:write");
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedKey && items[0]) setSelectedKey(eventKey(items[0]));
|
||||
if (selectedKey && !items.some((event) => eventKey(event) === selectedKey)) {
|
||||
setSelectedKey(items[0] ? eventKey(items[0]) : "");
|
||||
}
|
||||
}, [items, selectedKey]);
|
||||
|
||||
function selectForHost(event: CalendarEvent) {
|
||||
complete({
|
||||
contractVersion: "1",
|
||||
outcome: "completed",
|
||||
action: "selected",
|
||||
reference: {
|
||||
ownerModule: "calendar",
|
||||
kind: "event",
|
||||
objectId: eventKey(event),
|
||||
tenantId: event.tenant_id,
|
||||
label: event.summary,
|
||||
version: `${event.sequence}:${event.updated_at}`,
|
||||
path: calendarFocusPath(event.start_at)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-calendar.loading_calendar.7eb8f548">
|
||||
{error ? (
|
||||
<DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>
|
||||
) : null}
|
||||
<p className="calendar-quick-range">
|
||||
i18n:govoplan-calendar.quick_access_range: {rangeLabel(rangeStart, rangeEnd)}
|
||||
</p>
|
||||
|
||||
{items.length ? (
|
||||
<SelectionList variant="navigation" label="i18n:govoplan-calendar.agenda.891e9d6d">
|
||||
{items.map((event) => (
|
||||
<SelectionListItem
|
||||
key={eventKey(event)}
|
||||
selected={selected ? eventKey(event) === eventKey(selected) : false}
|
||||
onClick={() => setSelectedKey(eventKey(event))}
|
||||
>
|
||||
<SelectionListItemContent
|
||||
leading={<CalendarDays size={16} aria-hidden="true" />}
|
||||
title={event.summary}
|
||||
description={eventTimeLabel(event)}
|
||||
/>
|
||||
</SelectionListItem>
|
||||
))}
|
||||
</SelectionList>
|
||||
) : !loading && !error ? (
|
||||
<p className="muted">i18n:govoplan-calendar.no_events.e339ba73</p>
|
||||
) : null}
|
||||
|
||||
{selected ? (
|
||||
<section className="calendar-quick-detail" aria-label="i18n:govoplan-calendar.quick_access_event_details">
|
||||
<strong>{selected.summary}</strong>
|
||||
<span>{eventTimeLabel(selected)}</span>
|
||||
{selected.location ? (
|
||||
<span><MapPin size={13} aria-hidden="true" /> {selected.location}</span>
|
||||
) : null}
|
||||
{selected.description ? <p>{selected.description}</p> : null}
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => selectForHost(selected)}>
|
||||
i18n:govoplan-calendar.quick_access_select_event
|
||||
</Button>
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to={calendarFocusPath(selected.start_at)}
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={() => selectForHost(selected)}
|
||||
>
|
||||
<ExternalLink size={15} aria-hidden="true" />
|
||||
i18n:govoplan-calendar.quick_access_open_calendar
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{canCreate ? (
|
||||
<div className="dashboard-contribution-footer">
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to={calendarCreatePath(rangeStart)}
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={close}
|
||||
>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
i18n:govoplan-calendar.new_event.2ef3795c
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function agendaStart(
|
||||
context: QuickAccessToolRenderContext["launchContext"]["temporalContext"]
|
||||
): Date {
|
||||
if (context.validityMode === "at" && context.validAt) {
|
||||
const parsed = new Date(context.validAt);
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function eventKey(event: CalendarEvent): string {
|
||||
return event.instance_id || event.id;
|
||||
}
|
||||
|
||||
function calendarFocusPath(startAt: string): string {
|
||||
return `/calendar?focusDate=${encodeURIComponent(startAt)}`;
|
||||
}
|
||||
|
||||
function calendarCreatePath(startAt: Date): string {
|
||||
return `/calendar?quickAction=create-event&startAt=${encodeURIComponent(startAt.toISOString())}`;
|
||||
}
|
||||
|
||||
function eventTimeLabel(event: CalendarEvent): string {
|
||||
const start = new Date(event.start_at);
|
||||
if (event.all_day) {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(start);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
}).format(start);
|
||||
}
|
||||
|
||||
function rangeLabel(start: Date, end: Date): string {
|
||||
const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
|
||||
return `${formatter.format(start)} – ${formatter.format(end)}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
import {
|
||||
import { FormGrid, ContentGrid,
|
||||
Button,
|
||||
Card,
|
||||
DismissibleAlert,
|
||||
@@ -124,12 +124,9 @@ export default function CalendarSettingsPanel({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="dashboard-grid settings-dashboard-grid calendar-settings-panel">
|
||||
<div className="calendar-settings-documentation">
|
||||
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
|
||||
</div>
|
||||
<Card title="Calendar display">
|
||||
<div className="form-grid">
|
||||
<ContentGrid columns={2} collapseAt="workspace" className="calendar-settings-panel">
|
||||
<Card title="Calendar display" titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}>
|
||||
<FormGrid columns={1} collapseAt="standard" className="">
|
||||
<ToggleSwitch
|
||||
label="Dim weekends"
|
||||
help="Use a quieter background for Saturday and Sunday in week views."
|
||||
@@ -147,7 +144,7 @@ export default function CalendarSettingsPanel({
|
||||
setDraft((current) => ({ ...current, dim_off_hours }))
|
||||
}
|
||||
/>
|
||||
<div className="calendar-dialog-grid-two">
|
||||
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||
<FormField label="Workday starts">
|
||||
<input
|
||||
type="number"
|
||||
@@ -178,11 +175,11 @@ export default function CalendarSettingsPanel({
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</FormGrid>
|
||||
</Card>
|
||||
<Card title="Continuous view">
|
||||
<div className="form-grid">
|
||||
<FormGrid columns={1} collapseAt="standard" className="">
|
||||
<ToggleSwitch
|
||||
label="Virtualize distant weeks"
|
||||
help="Keep the continuous calendar responsive by rendering only nearby weeks."
|
||||
@@ -240,9 +237,9 @@ export default function CalendarSettingsPanel({
|
||||
{message}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
</div>
|
||||
</FormGrid>
|
||||
</Card>
|
||||
</div>
|
||||
</ContentGrid>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
|
||||
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
|
||||
"i18n:govoplan-calendar.calendar.adab5090": "Calendar",
|
||||
"i18n:govoplan-calendar.quick_access_description": "Upcoming events across visible calendars.",
|
||||
"i18n:govoplan-calendar.quick_access_range": "Agenda range",
|
||||
"i18n:govoplan-calendar.quick_access_event_details": "Event details",
|
||||
"i18n:govoplan-calendar.quick_access_select_event": "Select event",
|
||||
"i18n:govoplan-calendar.quick_access_open_calendar": "Open in Calendar",
|
||||
"i18n:govoplan-calendar.calendars_are_unavailable.f074c862": "Calendars are unavailable.",
|
||||
"i18n:govoplan-calendar.calendars.94445018": "Calendars",
|
||||
"i18n:govoplan-calendar.cancel.77dfd213": "Cancel",
|
||||
@@ -289,6 +294,11 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
|
||||
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
|
||||
"i18n:govoplan-calendar.calendar.adab5090": "Kalender",
|
||||
"i18n:govoplan-calendar.quick_access_description": "Anstehende Termine aus den sichtbaren Kalendern.",
|
||||
"i18n:govoplan-calendar.quick_access_range": "Agendazeitraum",
|
||||
"i18n:govoplan-calendar.quick_access_event_details": "Termindetails",
|
||||
"i18n:govoplan-calendar.quick_access_select_event": "Termin auswählen",
|
||||
"i18n:govoplan-calendar.quick_access_open_calendar": "Im Kalender öffnen",
|
||||
"i18n:govoplan-calendar.calendars_are_unavailable.f074c862": "Kalender sind nicht verfügbar.",
|
||||
"i18n:govoplan-calendar.calendars.94445018": "Calendars",
|
||||
"i18n:govoplan-calendar.cancel.77dfd213": "Abbrechen",
|
||||
|
||||
+22
-3
@@ -3,12 +3,15 @@ import type {
|
||||
CalendarPickerUiCapability,
|
||||
DashboardWidgetsUiCapability,
|
||||
PlatformWebModule,
|
||||
QuickAccessToolsUiCapability,
|
||||
SettingsSectionsUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import { generatedTranslations as productSurfaceTranslations } from "@govoplan/core-webui/outcome-product-surface-translations";
|
||||
import "./styles/calendar.css";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import CalendarPicker from "./features/calendar/CalendarPicker";
|
||||
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
|
||||
import CalendarQuickAccess from "./features/calendar/CalendarQuickAccess";
|
||||
|
||||
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
||||
const CalendarSettingsPanel = lazy(
|
||||
@@ -17,8 +20,8 @@ const CalendarSettingsPanel = lazy(
|
||||
|
||||
const eventRead = ["calendar:event:read"];
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
en: { ...generatedTranslations.en, ...productSurfaceTranslations.en },
|
||||
de: { ...generatedTranslations.de, ...productSurfaceTranslations.de }
|
||||
};
|
||||
|
||||
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
|
||||
@@ -90,6 +93,14 @@ const calendarDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
}
|
||||
]
|
||||
};
|
||||
const calendarQuickAccessTools: QuickAccessToolsUiCapability = {
|
||||
tools: [
|
||||
{
|
||||
id: "calendar.agenda",
|
||||
render: (context) => createElement(CalendarQuickAccess, context)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const calendarModule: PlatformWebModule = {
|
||||
id: "calendar",
|
||||
@@ -122,6 +133,13 @@ export const calendarModule: PlatformWebModule = {
|
||||
kind: "section",
|
||||
label: "Calendar preferences",
|
||||
order: 45
|
||||
},
|
||||
{
|
||||
id: "calendar.quick_access.agenda",
|
||||
moduleId: "calendar",
|
||||
kind: "quick_access",
|
||||
label: "Calendar Quick Access",
|
||||
order: 50
|
||||
}
|
||||
],
|
||||
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55, surfaceId: "calendar.navigation" }],
|
||||
@@ -130,7 +148,8 @@ export const calendarModule: PlatformWebModule = {
|
||||
uiCapabilities: {
|
||||
"calendar.picker": calendarPicker,
|
||||
"dashboard.widgets": calendarDashboardWidgets,
|
||||
"settings.sections": calendarSettingsSections
|
||||
"settings.sections": calendarSettingsSections,
|
||||
"quickAccess.tools": calendarQuickAccessTools
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
flex: 0 1 520px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
width: min(520px, 100%);
|
||||
max-width: 520px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.calendar-mode-switch .segmented-control-option {
|
||||
@@ -149,7 +149,7 @@
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
@@ -165,7 +165,7 @@
|
||||
gap: 4px;
|
||||
min-height: 36px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.calendar-list-row:hover,
|
||||
@@ -187,7 +187,7 @@
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--calendar-switch-bg);
|
||||
cursor: pointer;
|
||||
transition: background .16s ease, border-color .16s ease;
|
||||
@@ -201,7 +201,7 @@
|
||||
.calendar-visibility-switch span {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius-round);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-thumb);
|
||||
transform: translateX(0);
|
||||
@@ -266,6 +266,33 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.calendar-quick-range {
|
||||
margin: 0 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calendar-quick-detail {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 12px;
|
||||
border-top: var(--border-line);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.calendar-quick-detail > span,
|
||||
.calendar-quick-detail > p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calendar-quick-detail > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.calendar-agenda {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -495,7 +522,7 @@
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--calendar-event-border);
|
||||
border-left: 4px solid var(--calendar-event-color);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--calendar-event-bg);
|
||||
color: var(--calendar-event-text);
|
||||
cursor: pointer;
|
||||
@@ -720,7 +747,7 @@
|
||||
top: -5px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--green);
|
||||
content: "";
|
||||
}
|
||||
@@ -731,7 +758,7 @@
|
||||
left: 10px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--calendar-success-border);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--green);
|
||||
color: var(--on-accent);
|
||||
font-size: 11px;
|
||||
@@ -744,7 +771,7 @@
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -815,7 +842,7 @@
|
||||
left: 50%;
|
||||
width: 34px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--calendar-overlay);
|
||||
content: "";
|
||||
opacity: 0;
|
||||
@@ -850,10 +877,6 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.calendar-event-dialog {
|
||||
width: min(680px, 100%);
|
||||
}
|
||||
|
||||
.calendar-vevent-dialog {
|
||||
width: min(920px, 100%);
|
||||
}
|
||||
@@ -899,13 +922,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.calendar-dialog-row,
|
||||
.calendar-dialog-date-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calendar-dialog-name-color-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 142px;
|
||||
@@ -926,7 +942,7 @@
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -944,19 +960,6 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calendar-dialog-grid-two,
|
||||
.calendar-dialog-grid-three,
|
||||
.calendar-caldav-setup,
|
||||
.calendar-sync-settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calendar-dialog-grid-three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.calendar-dialog-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -1048,7 +1051,7 @@
|
||||
.calendar-sync-status {
|
||||
padding: 3px 8px;
|
||||
border: var(--border-line-dark);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
@@ -1078,7 +1081,7 @@
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
@@ -1134,13 +1137,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calendar-outbox-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calendar-outbox-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1183,7 +1179,7 @@
|
||||
gap: 8px;
|
||||
padding: 11px 12px;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
@@ -1199,8 +1195,8 @@
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-left: 3px solid var(--color-danger, #b42318);
|
||||
background: var(--color-danger-subtle, rgba(180, 35, 24, 0.08));
|
||||
border-left: 3px solid var(--danger-border-deep);
|
||||
background: var(--danger-muted-bg);
|
||||
}
|
||||
|
||||
.calendar-remote-move-confirmation label,
|
||||
@@ -1225,13 +1221,6 @@
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.calendar-migration-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calendar-migration-heading > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
@@ -1310,7 +1299,7 @@
|
||||
|
||||
.calendar-migration-error {
|
||||
margin: 0;
|
||||
color: var(--color-danger, #b42318);
|
||||
color: var(--danger-border-deep);
|
||||
}
|
||||
|
||||
.calendar-migration-cancel {
|
||||
@@ -1324,7 +1313,7 @@
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
@media (max-width: 760px) {
|
||||
.calendar-migration-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -1388,7 +1377,7 @@
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--calendar-danger-border);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--calendar-danger-bg);
|
||||
color: var(--red);
|
||||
font-size: 13px;
|
||||
@@ -1398,7 +1387,7 @@
|
||||
.calendar-form-note {
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -1420,7 +1409,7 @@
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -1521,13 +1510,7 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.calendar-dialog-row,
|
||||
.calendar-dialog-date-row,
|
||||
.calendar-dialog-name-color-row,
|
||||
.calendar-dialog-grid-two,
|
||||
.calendar-dialog-grid-three,
|
||||
.calendar-caldav-setup,
|
||||
.calendar-sync-settings,
|
||||
.calendar-sync-status-panel dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"src/features/calendar/CalendarPage.tsx",
|
||||
"src/features/calendar/CalendarSettingsPanel.tsx",
|
||||
"src/features/calendar/UpcomingEventsWidget.tsx",
|
||||
"src/features/calendar/CalendarQuickAccess.tsx",
|
||||
"src/features/calendar/CalendarViews.tsx",
|
||||
"src/features/calendar/CalendarCollectionDialogs.tsx",
|
||||
"src/features/calendar/CalendarEventDialog.tsx",
|
||||
|
||||
Reference in New Issue
Block a user