Add CalDAV outbox recovery UI

This commit is contained in:
2026-08-02 15:57:01 +02:00
parent 4e05ab2c3e
commit 77a40ec2f9
14 changed files with 737 additions and 14 deletions
+17
View File
@@ -554,6 +554,23 @@ manifest = ModuleManifest(
related_modules=("connectors", "audit", "ops"),
metadata={"kind": "reference"},
),
DocumentationTopic(
id="calendar.outbound-change-recovery",
title="Recover synchronized calendar writes",
summary="Inspect unresolved CalDAV writes and recover only the latest safe resource generation.",
body=(
"Calendar administrators open Outbound changes from a synchronized calendar's settings. "
"The bounded history explains attempts, conflicts, dead work, stale generations, disabled sources, "
"and active worker leases. Retry schedules a failed desired state, reconcile compares it with the "
"remote resource, and discard abandons the local desired state after a separate warning so the next "
"full sync can accept remote. Automatic dispatch and due-source execution remain service-account-only "
"worker operations and are not exposed as interactive controls."
),
documentation_types=("admin", "user"),
audience=("calendar_manager", "operator", "tenant_admin"),
related_modules=("ops", "audit"),
metadata={"kind": "runbook"},
),
),
capability_factories={
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
+129 -3
View File
@@ -7,7 +7,7 @@ from collections.abc import Callable, Mapping
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import and_, event, or_
from sqlalchemy import and_, event, or_, tuple_
from sqlalchemy.orm import Session
from govoplan_calendar.backend.caldav import (
@@ -394,6 +394,119 @@ def list_calendar_outbox_operations(
).limit(max(1, min(limit, 500))).all()
def calendar_outbox_operation_action_states(
session: Session,
operations: list[CalendarOutboxOperation],
) -> dict[str, dict[str, dict[str, object]]]:
"""Return recovery actions without per-row source or generation queries."""
if not operations:
return {}
source_ids = {operation.source_id for operation in operations}
sources = {
source.id: source
for source in session.query(CalendarSyncSource)
.filter(CalendarSyncSource.id.in_(sorted(source_ids)))
.all()
}
resource_keys = {
(operation.source_id, operation.resource_href) for operation in operations
}
generations = (
session.query(
CalendarOutboxOperation.id,
CalendarOutboxOperation.source_id,
CalendarOutboxOperation.resource_href,
CalendarOutboxOperation.created_at,
)
.filter(
tuple_(
CalendarOutboxOperation.source_id,
CalendarOutboxOperation.resource_href,
).in_(sorted(resource_keys))
)
.all()
)
latest_by_resource: dict[tuple[str, str], tuple[datetime, str]] = {}
for operation_id, source_id, resource_href, created_at in generations:
key = (source_id, resource_href)
candidate = (created_at, operation_id)
if key not in latest_by_resource or candidate > latest_by_resource[key]:
latest_by_resource[key] = candidate
return {
operation.id: _calendar_outbox_action_state(
operation,
source=sources.get(operation.source_id),
latest_operation_id=latest_by_resource[
(operation.source_id, operation.resource_href)
][1],
)
for operation in operations
}
def _calendar_outbox_action_state(
operation: CalendarOutboxOperation,
*,
source: CalendarSyncSource | None,
latest_operation_id: str,
) -> dict[str, dict[str, object]]:
actions = {
action: {"allowed": False, "reason": None}
for action in ("retry", "reconcile", "discard")
}
def block_all(reason: str) -> dict[str, dict[str, object]]:
for state in actions.values():
state["reason"] = reason
return actions
if operation.id != latest_operation_id:
return block_all(
"A newer desired state exists for this resource; recover the latest operation instead."
)
if operation.status in {"succeeded", "superseded", "cancelled"}:
return block_all(f"This operation is already {operation.status}.")
if (
operation.status == "in_progress"
and operation.lease_expires_at is not None
and _as_utc(operation.lease_expires_at) > utcnow()
):
return block_all("A worker currently holds the delivery lease.")
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if operation.status in {"retry", "dead", "conflict"}:
actions["retry"] = {
"allowed": unavailable_reason is None,
"reason": unavailable_reason,
}
else:
actions["retry"]["reason"] = (
"Retry is available after an automatic attempt reports a retryable or terminal failure."
)
if operation.status in {"pending", "retry", "dead", "conflict", "in_progress"}:
actions["reconcile"] = {
"allowed": unavailable_reason is None,
"reason": unavailable_reason,
}
discard_unavailable = (
"CalDAV source no longer exists"
if source is None
else (
"Calendar outbox source tenant does not match the operation tenant"
if source.tenant_id != operation.tenant_id
else None
)
)
actions["discard"] = {
"allowed": discard_unavailable is None,
"reason": discard_unavailable,
}
return actions
def get_calendar_outbox_operation(
session: Session,
*,
@@ -1273,7 +1386,13 @@ def retry_calendar_outbox_operation(
tenant_id=tenant_id,
operation_id=operation_id,
)
if operation.status in {"succeeded", "in_progress", "superseded", "cancelled"}:
if operation.status in {
"pending",
"succeeded",
"in_progress",
"superseded",
"cancelled",
}:
raise ValueError(f"A {operation.status} Calendar outbox operation cannot be retried")
_assert_latest_resource_generation(session, operation)
unavailable_reason = _operation_source_unavailable_reason(operation, source)
@@ -1553,7 +1672,11 @@ def calendar_outbox_has_unresolved_desired_state(
return False
def calendar_outbox_operation_response(operation: CalendarOutboxOperation) -> dict[str, Any]:
def calendar_outbox_operation_response(
operation: CalendarOutboxOperation,
*,
actions: Mapping[str, Mapping[str, object]] | None = None,
) -> dict[str, Any]:
return {
"id": operation.id,
"tenant_id": operation.tenant_id,
@@ -1577,6 +1700,9 @@ def calendar_outbox_operation_response(operation: CalendarOutboxOperation) -> di
"created_at": operation.created_at,
"updated_at": operation.updated_at,
"metadata": dict(operation.metadata_ or {}),
"actions": {
str(action): dict(state) for action, state in (actions or {}).items()
},
}
+34 -6
View File
@@ -55,6 +55,7 @@ from govoplan_calendar.backend.schemas import (
CalendarViewPreferencesUpdateRequest,
)
from govoplan_calendar.backend.outbox import (
calendar_outbox_operation_action_states,
calendar_outbox_operation_response,
discard_calendar_outbox_operation,
dispatch_calendar_outbox,
@@ -127,6 +128,14 @@ def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes))
def _require_worker_principal(principal: ApiPrincipal) -> None:
if principal.auth_method != "service_account":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This endpoint is restricted to service-account workers.",
)
def _calendar_response(calendar) -> CalendarCollectionResponse:
return CalendarCollectionResponse.model_validate(calendar_response(calendar))
@@ -271,9 +280,16 @@ def _sync_source_response(source) -> CalendarSyncSourceResponse:
return CalendarSyncSourceResponse.model_validate(caldav_source_response(source))
def _outbox_operation_response(operation) -> CalendarOutboxOperationResponse:
def _outbox_operation_response(
session: Session,
operation,
) -> CalendarOutboxOperationResponse:
actions = calendar_outbox_operation_action_states(session, [operation])
return CalendarOutboxOperationResponse.model_validate(
calendar_outbox_operation_response(operation)
calendar_outbox_operation_response(
operation,
actions=actions.get(operation.id),
)
)
@@ -468,6 +484,7 @@ def api_sync_due_sources(
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:import")
_require_worker_principal(principal)
results = sync_due_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
session.commit()
return CalendarSyncDueSyncResponse(
@@ -616,6 +633,7 @@ def api_sync_due_caldav_sources(
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:import")
_require_worker_principal(principal)
results = sync_due_caldav_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
session.commit()
return CalendarCalDavDueSyncResponse(
@@ -652,8 +670,17 @@ def api_list_caldav_outbox(
source_id=source_id,
limit=limit,
)
action_states = calendar_outbox_operation_action_states(session, operations)
return CalendarOutboxOperationListResponse(
operations=[_outbox_operation_response(operation) for operation in operations]
operations=[
CalendarOutboxOperationResponse.model_validate(
calendar_outbox_operation_response(
operation,
actions=action_states.get(operation.id),
)
)
for operation in operations
]
)
@@ -664,6 +691,7 @@ def api_dispatch_caldav_outbox(
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
_require_worker_principal(principal)
return CalendarOutboxDispatchResponse.model_validate(
dispatch_calendar_outbox(
session,
@@ -691,7 +719,7 @@ def api_retry_caldav_outbox_operation(
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
return _outbox_operation_response(session, operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@@ -715,7 +743,7 @@ def api_reconcile_caldav_outbox_operation(
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
return _outbox_operation_response(session, operation)
except (ValueError, CalDAVError, CalendarError) as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@@ -741,7 +769,7 @@ def api_discard_caldav_outbox_operation(
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
return _outbox_operation_response(session, operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
+8
View File
@@ -265,6 +265,11 @@ class CalendarCalDavDueSyncResponse(BaseModel):
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
class CalendarOutboxActionAvailability(BaseModel):
allowed: bool = False
reason: str | None = None
class CalendarOutboxOperationResponse(BaseModel):
id: str
tenant_id: str
@@ -288,6 +293,9 @@ class CalendarOutboxOperationResponse(BaseModel):
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
actions: dict[str, CalendarOutboxActionAvailability] = Field(
default_factory=dict
)
class CalendarOutboxOperationListResponse(BaseModel):