Add CalDAV outbox recovery UI
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user