Gate calendar sync workers by tenant entitlement

This commit is contained in:
2026-08-04 09:29:35 +02:00
parent b4e5351b8c
commit 00add294a8
3 changed files with 115 additions and 16 deletions
+1 -1
View File
@@ -600,7 +600,7 @@ manifest = ModuleManifest(
id="calendar.external-sources-and-sync", id="calendar.external-sources-and-sync",
title="Connect and synchronize external calendars", 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.", 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. 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="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"), documentation_types=("admin", "user"),
audience=("user", "calendar_manager", "operator"), audience=("user", "calendar_manager", "operator"),
related_modules=("connectors", "audit", "ops"), related_modules=("connectors", "audit", "ops"),
+59 -15
View File
@@ -6,22 +6,66 @@ from celery import shared_task
@shared_task(name="govoplan_calendar.sync_due_caldav_sources") @shared_task(name="govoplan_calendar.sync_due_caldav_sources")
def sync_due_caldav_sources_task(tenant_id: str | None = None, limit: int = 50) -> list[dict[str, object]]: def sync_due_caldav_sources_task(tenant_id: str | None = None, limit: int = 50) -> list[dict[str, object]]:
from govoplan_calendar.backend.service import sync_due_sources from govoplan_calendar.backend.service import sync_due_sources
from govoplan_core.core.module_entitlements import tenant_execution_scope
from govoplan_core.core.worker_runtime import build_worker_platform_registry
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
from govoplan_core.settings import settings
with get_database().SessionLocal() as session: with get_database().SessionLocal() as session:
results = sync_due_sources(session, tenant_id=tenant_id, limit=limit) registry = build_worker_platform_registry(settings)
resolver = registry.tenant_entitlement_resolver()
admissions = (
(
resolver.admission(
session,
tenant_id=tenant_id,
module_id="calendar",
work_state="accepted",
),
)
if tenant_id is not None
else resolver.active_tenant_admissions(
session,
module_id="calendar",
work_state="accepted",
)
)
payload: list[dict[str, object]] = []
for admission in admissions:
if not admission.allowed:
payload.append(
{
"tenant_id": admission.tenant_id,
"status": "operator_action_required",
"operator_action": admission.payload(),
}
)
continue
with tenant_execution_scope(
resolver,
session,
tenant_id=admission.tenant_id,
work_state="accepted",
):
results = sync_due_sources(
session,
tenant_id=admission.tenant_id,
limit=limit,
)
payload.extend(
{
"tenant_id": admission.tenant_id,
"source_id": item.source_id,
"calendar_id": item.calendar_id,
"status": item.status,
"error": item.error,
"created": item.stats.created if item.stats else 0,
"updated": item.stats.updated if item.stats else 0,
"deleted": item.stats.deleted if item.stats else 0,
"unchanged": item.stats.unchanged if item.stats else 0,
"fetched": item.stats.fetched if item.stats else 0,
}
for item in results
)
session.commit() session.commit()
return [ return payload
{
"source_id": item.source_id,
"calendar_id": item.calendar_id,
"status": item.status,
"error": item.error,
"created": item.stats.created if item.stats else 0,
"updated": item.stats.updated if item.stats else 0,
"deleted": item.stats.deleted if item.stats else 0,
"unchanged": item.stats.unchanged if item.stats else 0,
"fetched": item.stats.fetched if item.stats else 0,
}
for item in results
]
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import unittest
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_calendar.backend.tasks import sync_due_caldav_sources_task
from govoplan_core.core.module_entitlements import TenantModuleAdmission
class CalendarTaskEntitlementTests(unittest.TestCase):
def test_disabled_tenant_preserves_due_sync_for_operator(self) -> None:
session = SimpleNamespace(commit=lambda: None)
@contextmanager
def session_scope():
yield session
admission = TenantModuleAdmission(
tenant_id="tenant-1",
module_id="calendar",
revision=2,
work_state="accepted",
allowed=False,
disposition="operator_action_required",
reason="Calendar is unavailable.",
)
resolver = SimpleNamespace(admission=lambda *_args, **_kwargs: admission)
registry = SimpleNamespace(
tenant_entitlement_resolver=lambda: resolver,
)
database = SimpleNamespace(SessionLocal=session_scope)
with (
patch(
"govoplan_core.core.worker_runtime.build_worker_platform_registry",
return_value=registry,
),
patch(
"govoplan_core.db.session.get_database",
return_value=database,
),
patch(
"govoplan_calendar.backend.service.sync_due_sources"
) as sync_due,
):
result = sync_due_caldav_sources_task.run("tenant-1", 10)
sync_due.assert_not_called()
self.assertEqual("operator_action_required", result[0]["status"])
if __name__ == "__main__":
unittest.main()