Add CalDAV outbox recovery UI
This commit is contained in:
@@ -70,9 +70,11 @@ write local creates, updates, and deletes back with CalDAV `PUT`/`DELETE` and
|
|||||||
ETag preconditions. If a remote resource changed, the local mutation is rejected
|
ETag preconditions. If a remote resource changed, the local mutation is rejected
|
||||||
and the user must sync before retrying. A calendar-owned task,
|
and the user must sync before retrying. A calendar-owned task,
|
||||||
`govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or
|
`govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or
|
||||||
cron-style scheduler.
|
cron-style scheduler. Due-source and outbox-dispatch HTTP routes require a
|
||||||
|
service-account principal and are not interactive administration actions.
|
||||||
|
|
||||||
Calendar exposes collection and credential management, sync status/actions,
|
Calendar exposes collection and credential management, sync status/actions,
|
||||||
|
bounded outbound-change diagnostics and guarded retry/reconcile/discard recovery,
|
||||||
durable per-user view preferences, recurrence expansion, detached occurrence
|
durable per-user view preferences, recurrence expansion, detached occurrence
|
||||||
overrides, instance/series editing, and a free/busy API primitive for scheduling
|
overrides, instance/series editing, and a free/busy API primitive for scheduling
|
||||||
and appointment modules. It is not yet a CalDAV network server: external clients
|
and appointment modules. It is not yet a CalDAV network server: external clients
|
||||||
|
|||||||
@@ -89,14 +89,23 @@ per-source throughput for a simple ordering guarantee: a stale inbound report
|
|||||||
cannot land after a newer outbound result, and queued leases do not expire while
|
cannot land after a newer outbound result, and queued leases do not expire while
|
||||||
waiting behind another network request for the same source.
|
waiting behind another network request for the same source.
|
||||||
|
|
||||||
Operators can list, dispatch, retry, reconcile, and discard tenant operations
|
Operators open **Outbound changes** from a synchronized calendar's settings to
|
||||||
through the `/calendar/caldav/outbox` administration endpoints. Terminal ETag
|
inspect a bounded queue and use only the retry, reconcile, or discard actions
|
||||||
|
that are valid for the latest resource generation. Dispatch and due-source
|
||||||
|
routes are worker-only and reject interactive sessions even when the account is
|
||||||
|
an administrator. Terminal ETag
|
||||||
conflicts and exhausted retries remain the current local desired state and
|
conflicts and exhausted retries remain the current local desired state and
|
||||||
shield that resource from inbound overwrite until explicitly resolved.
|
shield that resource from inbound overwrite until explicitly resolved.
|
||||||
Discarding is the administrator's accept-remote transition: it is allowed only
|
Discarding is the administrator's accept-remote transition: it is allowed only
|
||||||
for the latest generation, atomically cancels its unresolved predecessor chain,
|
for the latest generation, atomically cancels its unresolved predecessor chain,
|
||||||
marks the event projection as discarded, clears the sync token, and schedules a
|
marks the event projection as discarded, clears the sync token, and schedules a
|
||||||
full inbound reconciliation so an unchanged remote object is not missed.
|
full inbound reconciliation so an unchanged remote object is not missed.
|
||||||
|
The UI requires a separate destructive confirmation and explains that accepting
|
||||||
|
remote may lose the unresolved local desired state. Disabled and inbound-only
|
||||||
|
sources still permit discard when the retained source belongs to the tenant,
|
||||||
|
but they cannot be retried or reconciled until made writable again. Missing
|
||||||
|
sources, active worker leases, stale generations, and already-resolved rows
|
||||||
|
expose diagnostics instead of unsafe action buttons.
|
||||||
|
|
||||||
Disabling outbound delivery or switching to inbound-only is rejected while
|
Disabling outbound delivery or switching to inbound-only is rejected while
|
||||||
unresolved desired state exists; retirement is the explicit exception and
|
unresolved desired state exists; retirement is the explicit exception and
|
||||||
|
|||||||
@@ -554,6 +554,23 @@ manifest = ModuleManifest(
|
|||||||
related_modules=("connectors", "audit", "ops"),
|
related_modules=("connectors", "audit", "ops"),
|
||||||
metadata={"kind": "reference"},
|
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_factories={
|
||||||
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
|
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from collections.abc import Callable, Mapping
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import and_, event, or_
|
from sqlalchemy import and_, event, or_, tuple_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_calendar.backend.caldav import (
|
from govoplan_calendar.backend.caldav import (
|
||||||
@@ -394,6 +394,119 @@ def list_calendar_outbox_operations(
|
|||||||
).limit(max(1, min(limit, 500))).all()
|
).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(
|
def get_calendar_outbox_operation(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -1273,7 +1386,13 @@ def retry_calendar_outbox_operation(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
operation_id=operation_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")
|
raise ValueError(f"A {operation.status} Calendar outbox operation cannot be retried")
|
||||||
_assert_latest_resource_generation(session, operation)
|
_assert_latest_resource_generation(session, operation)
|
||||||
unavailable_reason = _operation_source_unavailable_reason(operation, source)
|
unavailable_reason = _operation_source_unavailable_reason(operation, source)
|
||||||
@@ -1553,7 +1672,11 @@ def calendar_outbox_has_unresolved_desired_state(
|
|||||||
return False
|
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 {
|
return {
|
||||||
"id": operation.id,
|
"id": operation.id,
|
||||||
"tenant_id": operation.tenant_id,
|
"tenant_id": operation.tenant_id,
|
||||||
@@ -1577,6 +1700,9 @@ def calendar_outbox_operation_response(operation: CalendarOutboxOperation) -> di
|
|||||||
"created_at": operation.created_at,
|
"created_at": operation.created_at,
|
||||||
"updated_at": operation.updated_at,
|
"updated_at": operation.updated_at,
|
||||||
"metadata": dict(operation.metadata_ or {}),
|
"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,
|
CalendarViewPreferencesUpdateRequest,
|
||||||
)
|
)
|
||||||
from govoplan_calendar.backend.outbox import (
|
from govoplan_calendar.backend.outbox import (
|
||||||
|
calendar_outbox_operation_action_states,
|
||||||
calendar_outbox_operation_response,
|
calendar_outbox_operation_response,
|
||||||
discard_calendar_outbox_operation,
|
discard_calendar_outbox_operation,
|
||||||
dispatch_calendar_outbox,
|
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))
|
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:
|
def _calendar_response(calendar) -> CalendarCollectionResponse:
|
||||||
return CalendarCollectionResponse.model_validate(calendar_response(calendar))
|
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))
|
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(
|
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),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:event:import")
|
_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)
|
results = sync_due_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
|
||||||
session.commit()
|
session.commit()
|
||||||
return CalendarSyncDueSyncResponse(
|
return CalendarSyncDueSyncResponse(
|
||||||
@@ -616,6 +633,7 @@ def api_sync_due_caldav_sources(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:event:import")
|
_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)
|
results = sync_due_caldav_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
|
||||||
session.commit()
|
session.commit()
|
||||||
return CalendarCalDavDueSyncResponse(
|
return CalendarCalDavDueSyncResponse(
|
||||||
@@ -652,8 +670,17 @@ def api_list_caldav_outbox(
|
|||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
action_states = calendar_outbox_operation_action_states(session, operations)
|
||||||
return CalendarOutboxOperationListResponse(
|
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),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:calendar:admin")
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
|
_require_worker_principal(principal)
|
||||||
return CalendarOutboxDispatchResponse.model_validate(
|
return CalendarOutboxDispatchResponse.model_validate(
|
||||||
dispatch_calendar_outbox(
|
dispatch_calendar_outbox(
|
||||||
session,
|
session,
|
||||||
@@ -691,7 +719,7 @@ def api_retry_caldav_outbox_operation(
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(operation)
|
session.refresh(operation)
|
||||||
return _outbox_operation_response(operation)
|
return _outbox_operation_response(session, operation)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
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.commit()
|
||||||
session.refresh(operation)
|
session.refresh(operation)
|
||||||
return _outbox_operation_response(operation)
|
return _outbox_operation_response(session, operation)
|
||||||
except (ValueError, CalDAVError, CalendarError) as exc:
|
except (ValueError, CalDAVError, CalendarError) as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
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.commit()
|
||||||
session.refresh(operation)
|
session.refresh(operation)
|
||||||
return _outbox_operation_response(operation)
|
return _outbox_operation_response(session, operation)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
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)
|
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarOutboxActionAvailability(BaseModel):
|
||||||
|
allowed: bool = False
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CalendarOutboxOperationResponse(BaseModel):
|
class CalendarOutboxOperationResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
@@ -288,6 +293,9 @@ class CalendarOutboxOperationResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
actions: dict[str, CalendarOutboxActionAvailability] = Field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalendarOutboxOperationListResponse(BaseModel):
|
class CalendarOutboxOperationListResponse(BaseModel):
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from collections.abc import Iterator
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
from govoplan_calendar.backend.service import CalendarError, http_request
|
from govoplan_calendar.backend.service import CalendarError, http_request
|
||||||
|
from govoplan_calendar.backend.router import _require_worker_principal
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
@@ -24,6 +26,17 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
|
|||||||
|
|
||||||
|
|
||||||
class CalendarHttpSecurityTests(unittest.TestCase):
|
class CalendarHttpSecurityTests(unittest.TestCase):
|
||||||
|
def test_worker_routes_reject_interactive_principals(self) -> None:
|
||||||
|
class Principal:
|
||||||
|
auth_method = "session"
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
_require_worker_principal(Principal()) # type: ignore[arg-type]
|
||||||
|
self.assertEqual(403, raised.exception.status_code)
|
||||||
|
|
||||||
|
Principal.auth_method = "service_account"
|
||||||
|
_require_worker_principal(Principal()) # type: ignore[arg-type]
|
||||||
|
|
||||||
def test_cross_origin_redirect_does_not_forward_authorization(self) -> None:
|
def test_cross_origin_redirect_does_not_forward_authorization(self) -> None:
|
||||||
forwarded_authorization: list[str | None] = []
|
forwarded_authorization: list[str | None] = []
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from govoplan_calendar.backend.caldav import (
|
|||||||
from govoplan_calendar.backend.db.models import CalendarOutboxOperation
|
from govoplan_calendar.backend.db.models import CalendarOutboxOperation
|
||||||
from govoplan_calendar.backend.outbox import (
|
from govoplan_calendar.backend.outbox import (
|
||||||
SqlCalendarOutboxProvider,
|
SqlCalendarOutboxProvider,
|
||||||
|
calendar_outbox_operation_action_states,
|
||||||
claim_next_calendar_outbox_operation,
|
claim_next_calendar_outbox_operation,
|
||||||
discard_calendar_outbox_operation,
|
discard_calendar_outbox_operation,
|
||||||
dispatch_calendar_outbox,
|
dispatch_calendar_outbox,
|
||||||
@@ -508,6 +509,66 @@ class CalendarOutboxTests(unittest.TestCase):
|
|||||||
operation_id=first.id,
|
operation_id=first.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_recovery_action_state_is_batched_and_respects_source_state(self) -> None:
|
||||||
|
event = self.create_local_event(summary="First")
|
||||||
|
self.session.commit()
|
||||||
|
operation = self.session.query(CalendarOutboxOperation).one()
|
||||||
|
|
||||||
|
state = calendar_outbox_operation_action_states(
|
||||||
|
self.session,
|
||||||
|
[operation],
|
||||||
|
)[operation.id]
|
||||||
|
self.assertFalse(state["retry"]["allowed"])
|
||||||
|
self.assertTrue(state["reconcile"]["allowed"])
|
||||||
|
self.assertTrue(state["discard"]["allowed"])
|
||||||
|
|
||||||
|
operation.status = "conflict"
|
||||||
|
self.source.sync_enabled = False
|
||||||
|
self.session.flush()
|
||||||
|
disabled = calendar_outbox_operation_action_states(
|
||||||
|
self.session,
|
||||||
|
[operation],
|
||||||
|
)[operation.id]
|
||||||
|
self.assertFalse(disabled["retry"]["allowed"])
|
||||||
|
self.assertIn("disabled", str(disabled["retry"]["reason"]))
|
||||||
|
self.assertFalse(disabled["reconcile"]["allowed"])
|
||||||
|
self.assertTrue(disabled["discard"]["allowed"])
|
||||||
|
|
||||||
|
self.source.sync_enabled = True
|
||||||
|
update_event(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
event_id=event.id,
|
||||||
|
payload=CalendarEventUpdateRequest(summary="Second"),
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
stale = calendar_outbox_operation_action_states(
|
||||||
|
self.session,
|
||||||
|
[operation],
|
||||||
|
)[operation.id]
|
||||||
|
self.assertFalse(any(item["allowed"] for item in stale.values()))
|
||||||
|
self.assertIn("newer desired state", str(stale["discard"]["reason"]))
|
||||||
|
|
||||||
|
def test_manual_retry_cannot_be_repeated_while_pending(self) -> None:
|
||||||
|
self.create_local_event()
|
||||||
|
self.session.commit()
|
||||||
|
operation = self.session.query(CalendarOutboxOperation).one()
|
||||||
|
operation.status = "dead"
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
retry_calendar_outbox_operation(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
operation_id=operation.id,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "pending"):
|
||||||
|
retry_calendar_outbox_operation(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
operation_id=operation.id,
|
||||||
|
)
|
||||||
|
|
||||||
def test_discard_latest_cancels_attempted_predecessor_chain(self) -> None:
|
def test_discard_latest_cancels_attempted_predecessor_chain(self) -> None:
|
||||||
event = self.create_local_event(summary="First")
|
event = self.create_local_event(summary="First")
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|||||||
@@ -191,6 +191,35 @@ export type CalendarSyncSourceSyncResponse = {
|
|||||||
|
|
||||||
export type CalendarCalDavSyncResponse = CalendarSyncSourceSyncResponse;
|
export type CalendarCalDavSyncResponse = CalendarSyncSourceSyncResponse;
|
||||||
|
|
||||||
|
export type CalendarOutboxActionAvailability = {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarOutboxOperation = {
|
||||||
|
id: string;
|
||||||
|
source_id: string;
|
||||||
|
event_id?: string | null;
|
||||||
|
operation_kind: "put" | "delete" | string;
|
||||||
|
resource_href: string;
|
||||||
|
status: string;
|
||||||
|
attempt_count: number;
|
||||||
|
max_attempts: number;
|
||||||
|
available_at: string;
|
||||||
|
last_attempt_at?: string | null;
|
||||||
|
lease_expires_at?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
reconciled_at?: string | null;
|
||||||
|
last_error?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
actions: Record<string, CalendarOutboxActionAvailability>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarOutboxOperationListResponse = {
|
||||||
|
operations: CalendarOutboxOperation[];
|
||||||
|
};
|
||||||
|
|
||||||
export type CalendarCollectionCreatePayload = {
|
export type CalendarCollectionCreatePayload = {
|
||||||
name: string;
|
name: string;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
@@ -345,6 +374,28 @@ export function syncCalDavSource(settings: ApiSettings, sourceId: string, payloa
|
|||||||
return apiFetch<CalendarCalDavSyncResponse>(settings, `/api/v1/calendar/caldav/sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) });
|
return apiFetch<CalendarCalDavSyncResponse>(settings, `/api/v1/calendar/caldav/sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listCalendarOutbox(
|
||||||
|
settings: ApiSettings,
|
||||||
|
params: { source_id?: string; status?: string; limit?: number } = {},
|
||||||
|
): Promise<CalendarOutboxOperationListResponse> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.source_id) search.set("source_id", params.source_id);
|
||||||
|
if (params.status) search.set("status", params.status);
|
||||||
|
if (params.limit) search.set("limit", String(params.limit));
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<CalendarOutboxOperationListResponse>(settings, `/api/v1/calendar/caldav/outbox${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recoverCalendarOutboxOperation(
|
||||||
|
settings: ApiSettings,
|
||||||
|
operationId: string,
|
||||||
|
action: "retry" | "reconcile" | "discard",
|
||||||
|
): Promise<CalendarOutboxOperation> {
|
||||||
|
return apiFetch<CalendarOutboxOperation>(settings, `/api/v1/calendar/caldav/outbox/${operationId}/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listCalendarEvents(
|
export function listCalendarEvents(
|
||||||
settings: ApiSettings,
|
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 } = {}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { RefreshCw, Trash2 } from "lucide-react";
|
import { ListChecks, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ColorPickerField,
|
ColorPickerField,
|
||||||
@@ -101,6 +101,7 @@ export function CalendarCollectionDialog({
|
|||||||
onSave,
|
onSave,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onSync,
|
onSync,
|
||||||
|
onOpenOutbox,
|
||||||
onDiscover
|
onDiscover
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ export function CalendarCollectionDialog({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
|
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onOpenOutbox: (source: CalendarSyncSource) => void;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
|
||||||
const calendar = state.kind === "edit" ? state.calendar : null;
|
const calendar = state.kind === "edit" ? state.calendar : null;
|
||||||
const isEdit = Boolean(calendar);
|
const isEdit = Boolean(calendar);
|
||||||
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? calendarSourceModeForSource(source) : "local");
|
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? calendarSourceModeForSource(source) : "local");
|
||||||
@@ -559,6 +560,11 @@ export function CalendarCollectionDialog({
|
|||||||
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}>
|
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}>
|
||||||
i18n:govoplan-calendar.full_sync.21b89c76
|
i18n:govoplan-calendar.full_sync.21b89c76
|
||||||
</Button>
|
</Button>
|
||||||
|
{source.source_kind === "caldav" && canManageSources && (
|
||||||
|
<Button type="button" onClick={() => onOpenOutbox(source)} disabled={saving || syncing}>
|
||||||
|
<ListChecks size={16} /> i18n:govoplan-calendar.outbound_changes.7038a839
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { RefreshCw, RotateCcw, ScanSearch, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
SegmentedControl,
|
||||||
|
StatusBadge,
|
||||||
|
type ApiSettings,
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
listCalendarOutbox,
|
||||||
|
recoverCalendarOutboxOperation,
|
||||||
|
type CalendarCollection,
|
||||||
|
type CalendarOutboxOperation,
|
||||||
|
type CalendarSyncSource,
|
||||||
|
} from "../../api/calendar";
|
||||||
|
import { dateTimeLabel, errorText } from "./calendarViewModel";
|
||||||
|
|
||||||
|
type OutboxFilter = "unresolved" | "all";
|
||||||
|
type RecoveryAction = "retry" | "reconcile" | "discard";
|
||||||
|
|
||||||
|
const RESOLVED_STATUSES = new Set(["succeeded", "superseded", "cancelled"]);
|
||||||
|
|
||||||
|
export function CalendarOutboxDialog({
|
||||||
|
settings,
|
||||||
|
calendar,
|
||||||
|
source,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
calendar: CalendarCollection;
|
||||||
|
source: CalendarSyncSource;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [operations, setOperations] = useState<CalendarOutboxOperation[]>([]);
|
||||||
|
const [filter, setFilter] = useState<OutboxFilter>("unresolved");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busyOperationId, setBusyOperationId] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [discardOperation, setDiscardOperation] = useState<CalendarOutboxOperation | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await listCalendarOutbox(settings, {
|
||||||
|
source_id: source.id,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
setOperations(response.operations);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [settings, source.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const visibleOperations = useMemo(
|
||||||
|
() => filter === "all"
|
||||||
|
? operations
|
||||||
|
: operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)),
|
||||||
|
[filter, operations],
|
||||||
|
);
|
||||||
|
const unresolvedCount = operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)).length;
|
||||||
|
const conflictCount = operations.filter((operation) => ["conflict", "dead"].includes(operation.status)).length;
|
||||||
|
|
||||||
|
async function recover(operation: CalendarOutboxOperation, action: RecoveryAction) {
|
||||||
|
setBusyOperationId(operation.id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await recoverCalendarOutboxOperation(settings, operation.id, action);
|
||||||
|
setDiscardOperation(null);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setBusyOperationId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
title="i18n:govoplan-calendar.outbound_changes.7038a839"
|
||||||
|
className="calendar-outbox-dialog"
|
||||||
|
closeDisabled={Boolean(busyOperationId)}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" onClick={() => void load()} disabled={loading || Boolean(busyOperationId)}>
|
||||||
|
<RefreshCw size={16} className={loading ? "calendar-sync-spin" : undefined} /> i18n:govoplan-calendar.refresh.56e3badc
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={onClose} disabled={Boolean(busyOperationId)}>
|
||||||
|
i18n:govoplan-core.close.bbfa773e
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="calendar-outbox-body">
|
||||||
|
<p className="calendar-outbox-calendar-name">{calendar.name}</p>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
<div className="calendar-outbox-toolbar">
|
||||||
|
<SegmentedControl<OutboxFilter>
|
||||||
|
value={filter}
|
||||||
|
ariaLabel="i18n:govoplan-calendar.outbox_filter.8305af40"
|
||||||
|
onChange={setFilter}
|
||||||
|
options={[
|
||||||
|
{ id: "unresolved", label: "i18n:govoplan-calendar.unresolved.11c41de4" },
|
||||||
|
{ id: "all", label: "i18n:govoplan-calendar.all_history.8829c44e" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<dl className="calendar-outbox-summary">
|
||||||
|
<div><dt>i18n:govoplan-calendar.unresolved.11c41de4</dt><dd>{unresolvedCount}</dd></div>
|
||||||
|
<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>
|
||||||
|
{loading && !operations.length
|
||||||
|
? <p className="calendar-form-note">i18n:govoplan-calendar.loading_outbound_changes.3fc59656</p>
|
||||||
|
: visibleOperations.length
|
||||||
|
? (
|
||||||
|
<ol className="calendar-outbox-list">
|
||||||
|
{visibleOperations.map((operation) => (
|
||||||
|
<li key={operation.id} className="calendar-outbox-item">
|
||||||
|
<div className="calendar-outbox-item-heading">
|
||||||
|
<div>
|
||||||
|
<StatusBadge status={operation.status} />
|
||||||
|
<strong>{operation.operation_kind.toUpperCase()}</strong>
|
||||||
|
</div>
|
||||||
|
<time dateTime={operation.updated_at}>{dateTimeLabel(new Date(operation.updated_at))}</time>
|
||||||
|
</div>
|
||||||
|
<code title={operation.resource_href}>{operation.resource_href}</code>
|
||||||
|
<dl className="calendar-outbox-item-facts">
|
||||||
|
<div><dt>i18n:govoplan-calendar.attempts.448fa12e</dt><dd>{operation.attempt_count}/{operation.max_attempts}</dd></div>
|
||||||
|
<div><dt>i18n:govoplan-calendar.available.17bc146b</dt><dd>{dateTimeLabel(new Date(operation.available_at))}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{operation.last_error && <p className="calendar-outbox-error">{operation.last_error}</p>}
|
||||||
|
<RecoveryActions
|
||||||
|
operation={operation}
|
||||||
|
busy={busyOperationId === operation.id}
|
||||||
|
onRecover={(action) => action === "discard"
|
||||||
|
? setDiscardOperation(operation)
|
||||||
|
: void recover(operation, action)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
: <p className="calendar-form-note">i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64</p>}
|
||||||
|
{operations.length === 100 && (
|
||||||
|
<p className="calendar-form-note">i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(discardOperation)}
|
||||||
|
title="i18n:govoplan-calendar.discard_local_desired_state.952f3be1"
|
||||||
|
message="i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d"
|
||||||
|
confirmLabel="i18n:govoplan-calendar.discard_change.85474ec4"
|
||||||
|
tone="danger"
|
||||||
|
busy={Boolean(busyOperationId)}
|
||||||
|
onCancel={() => setDiscardOperation(null)}
|
||||||
|
onConfirm={() => discardOperation && void recover(discardOperation, "discard")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecoveryActions({
|
||||||
|
operation,
|
||||||
|
busy,
|
||||||
|
onRecover,
|
||||||
|
}: {
|
||||||
|
operation: CalendarOutboxOperation;
|
||||||
|
busy: boolean;
|
||||||
|
onRecover: (action: RecoveryAction) => void;
|
||||||
|
}) {
|
||||||
|
const available = (["retry", "reconcile", "discard"] as const).filter(
|
||||||
|
(action) => operation.actions[action]?.allowed,
|
||||||
|
);
|
||||||
|
const unavailableReason = Object.values(operation.actions).find((action) => action.reason)?.reason;
|
||||||
|
return (
|
||||||
|
<div className="calendar-outbox-recovery">
|
||||||
|
{available.length > 0 && (
|
||||||
|
<div className="calendar-outbox-actions">
|
||||||
|
{available.includes("retry") && (
|
||||||
|
<Button type="button" onClick={() => onRecover("retry")} disabled={busy}>
|
||||||
|
<RotateCcw size={15} /> i18n:govoplan-calendar.retry.3fda8f1c
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{available.includes("reconcile") && (
|
||||||
|
<Button type="button" onClick={() => onRecover("reconcile")} disabled={busy}>
|
||||||
|
<ScanSearch size={15} /> i18n:govoplan-calendar.reconcile.6c595f64
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{available.includes("discard") && (
|
||||||
|
<Button type="button" variant="danger" onClick={() => onRecover("discard")} disabled={busy}>
|
||||||
|
<Trash2 size={15} /> i18n:govoplan-calendar.discard.23a76911
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!available.length && unavailableReason && <p className="calendar-form-note">{unavailableReason}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
type CalendarDeleteDialogState,
|
type CalendarDeleteDialogState,
|
||||||
} from "./CalendarCollectionDialogs";
|
} from "./CalendarCollectionDialogs";
|
||||||
import { CalendarEventDialog } from "./CalendarEventDialog";
|
import { CalendarEventDialog } from "./CalendarEventDialog";
|
||||||
|
import { CalendarOutboxDialog } from "./CalendarOutboxDialog";
|
||||||
import {
|
import {
|
||||||
CalendarTimeGrid,
|
CalendarTimeGrid,
|
||||||
CalendarWeekRows,
|
CalendarWeekRows,
|
||||||
@@ -132,6 +133,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
const [eventDialog, setEventDialog] = useState<EventDialogState | null>(null);
|
const [eventDialog, setEventDialog] = useState<EventDialogState | null>(null);
|
||||||
const [calendarDialog, setCalendarDialog] = useState<CalendarCollectionDialogState | null>(null);
|
const [calendarDialog, setCalendarDialog] = useState<CalendarCollectionDialogState | null>(null);
|
||||||
const [calendarDeleteDialog, setCalendarDeleteDialog] = useState<CalendarDeleteDialogState | null>(null);
|
const [calendarDeleteDialog, setCalendarDeleteDialog] = useState<CalendarDeleteDialogState | null>(null);
|
||||||
|
const [outboxDialog, setOutboxDialog] = useState<{ calendar: CalendarCollection; source: CalendarSyncSource } | null>(null);
|
||||||
const [syncingSourceId, setSyncingSourceId] = useState("");
|
const [syncingSourceId, setSyncingSourceId] = useState("");
|
||||||
const [continuousViewport, setContinuousViewport] = useState<ContinuousViewport>({ scrollTop: 0, height: 0 });
|
const [continuousViewport, setContinuousViewport] = useState<ContinuousViewport>({ scrollTop: 0, height: 0 });
|
||||||
const [draggingEventId, setDraggingEventId] = useState("");
|
const [draggingEventId, setDraggingEventId] = useState("");
|
||||||
@@ -1005,8 +1007,21 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
onSave={handleCalendarSave}
|
onSave={handleCalendarSave}
|
||||||
onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)}
|
onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)}
|
||||||
onSync={handleSyncSource}
|
onSync={handleSyncSource}
|
||||||
|
onOpenOutbox={(source) => {
|
||||||
|
if (calendarDialog.kind === "edit") {
|
||||||
|
setOutboxDialog({ calendar: calendarDialog.calendar, source });
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDiscover={handleCalDavDiscovery} />
|
onDiscover={handleCalDavDiscovery} />
|
||||||
|
|
||||||
|
}
|
||||||
|
{outboxDialog &&
|
||||||
|
<CalendarOutboxDialog
|
||||||
|
settings={settings}
|
||||||
|
calendar={outboxDialog.calendar}
|
||||||
|
source={outboxDialog.source}
|
||||||
|
onClose={() => setOutboxDialog(null)} />
|
||||||
|
|
||||||
}
|
}
|
||||||
{calendarDeleteDialog &&
|
{calendarDeleteDialog &&
|
||||||
<CalendarCollectionDeleteDialog
|
<CalendarCollectionDeleteDialog
|
||||||
|
|||||||
@@ -193,6 +193,23 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-calendar.week.f82be68a": "Week",
|
"i18n:govoplan-calendar.week.f82be68a": "Week",
|
||||||
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
|
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
|
||||||
"i18n:govoplan-calendar.working.049ac820": "Working...",
|
"i18n:govoplan-calendar.working.049ac820": "Working...",
|
||||||
|
"i18n:govoplan-calendar.all_history.8829c44e": "All history",
|
||||||
|
"i18n:govoplan-calendar.attempts.448fa12e": "Attempts",
|
||||||
|
"i18n:govoplan-calendar.available.17bc146b": "Available",
|
||||||
|
"i18n:govoplan-calendar.conflicts_dead.31252c3a": "Conflicts / dead",
|
||||||
|
"i18n:govoplan-calendar.discard.23a76911": "Discard",
|
||||||
|
"i18n:govoplan-calendar.discard_change.85474ec4": "Discard change",
|
||||||
|
"i18n:govoplan-calendar.discard_local_desired_state.952f3be1": "Discard local desired state?",
|
||||||
|
"i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d": "Discarding cancels this local outbound change and its unresolved predecessors. The next full sync accepts the remote state, so local changes may be lost.",
|
||||||
|
"i18n:govoplan-calendar.loading_outbound_changes.3fc59656": "Loading outbound changes...",
|
||||||
|
"i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64": "No outbound changes match this filter.",
|
||||||
|
"i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365": "Only the 100 most recent outbound changes are shown.",
|
||||||
|
"i18n:govoplan-calendar.outbound_changes.7038a839": "Outbound changes",
|
||||||
|
"i18n:govoplan-calendar.outbox_filter.8305af40": "Outbound change filter",
|
||||||
|
"i18n:govoplan-calendar.reconcile.6c595f64": "Reconcile",
|
||||||
|
"i18n:govoplan-calendar.retry.3fda8f1c": "Retry",
|
||||||
|
"i18n:govoplan-calendar.shown.498e85a1": "Shown",
|
||||||
|
"i18n:govoplan-calendar.unresolved.11c41de4": "Unresolved",
|
||||||
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
|
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
|
||||||
},
|
},
|
||||||
"de": {
|
"de": {
|
||||||
@@ -387,6 +404,23 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-calendar.week.f82be68a": "Woche",
|
"i18n:govoplan-calendar.week.f82be68a": "Woche",
|
||||||
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
|
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
|
||||||
"i18n:govoplan-calendar.working.049ac820": "Working...",
|
"i18n:govoplan-calendar.working.049ac820": "Working...",
|
||||||
|
"i18n:govoplan-calendar.all_history.8829c44e": "Gesamter Verlauf",
|
||||||
|
"i18n:govoplan-calendar.attempts.448fa12e": "Versuche",
|
||||||
|
"i18n:govoplan-calendar.available.17bc146b": "Verfügbar",
|
||||||
|
"i18n:govoplan-calendar.conflicts_dead.31252c3a": "Konflikte / endgültig fehlgeschlagen",
|
||||||
|
"i18n:govoplan-calendar.discard.23a76911": "Verwerfen",
|
||||||
|
"i18n:govoplan-calendar.discard_change.85474ec4": "Änderung verwerfen",
|
||||||
|
"i18n:govoplan-calendar.discard_local_desired_state.952f3be1": "Lokalen Sollzustand verwerfen?",
|
||||||
|
"i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d": "Das Verwerfen bricht diese lokale ausgehende Änderung und ihre ungelösten Vorgänger ab. Die nächste vollständige Synchronisierung übernimmt den entfernten Stand; lokale Änderungen können verloren gehen.",
|
||||||
|
"i18n:govoplan-calendar.loading_outbound_changes.3fc59656": "Ausgehende Änderungen werden geladen...",
|
||||||
|
"i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64": "Keine ausgehenden Änderungen entsprechen diesem Filter.",
|
||||||
|
"i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365": "Es werden nur die 100 neuesten ausgehenden Änderungen angezeigt.",
|
||||||
|
"i18n:govoplan-calendar.outbound_changes.7038a839": "Ausgehende Änderungen",
|
||||||
|
"i18n:govoplan-calendar.outbox_filter.8305af40": "Filter für ausgehende Änderungen",
|
||||||
|
"i18n:govoplan-calendar.reconcile.6c595f64": "Abgleichen",
|
||||||
|
"i18n:govoplan-calendar.retry.3fda8f1c": "Erneut versuchen",
|
||||||
|
"i18n:govoplan-calendar.shown.498e85a1": "Angezeigt",
|
||||||
|
"i18n:govoplan-calendar.unresolved.11c41de4": "Ungelöst",
|
||||||
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
|
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1099,6 +1099,146 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-dialog {
|
||||||
|
width: min(860px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-dialog .dialog-body {
|
||||||
|
min-height: 360px;
|
||||||
|
max-height: min(70vh, 720px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-body {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-calendar-name {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
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;
|
||||||
|
gap: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-summary div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-summary dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-summary dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-list {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 4px 0 0;
|
||||||
|
overflow: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-item-heading,
|
||||||
|
.calendar-outbox-item-heading > div,
|
||||||
|
.calendar-outbox-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-item-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-item-heading time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-item code {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-item-facts {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-item-facts div {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-item-facts dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-item-facts dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border-left: 3px solid var(--red);
|
||||||
|
background: var(--calendar-danger-bg);
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-outbox-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-form-error {
|
.calendar-form-error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user