From 00add294a8d113284cf143fce3bc56b69bf31d9d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 4 Aug 2026 09:29:35 +0200 Subject: [PATCH] Gate calendar sync workers by tenant entitlement --- src/govoplan_calendar/backend/manifest.py | 2 +- src/govoplan_calendar/backend/tasks.py | 74 ++++++++++++++++++----- tests/test_tasks.py | 55 +++++++++++++++++ 3 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 tests/test_tasks.py diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index b7814c9..b1be6a7 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -600,7 +600,7 @@ 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. 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"), audience=("user", "calendar_manager", "operator"), related_modules=("connectors", "audit", "ops"), diff --git a/src/govoplan_calendar/backend/tasks.py b/src/govoplan_calendar/backend/tasks.py index 1488758..96a2e5c 100644 --- a/src/govoplan_calendar/backend/tasks.py +++ b/src/govoplan_calendar/backend/tasks.py @@ -6,22 +6,66 @@ from celery import shared_task @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]]: 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.settings import settings 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() - return [ - { - "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 - ] + return payload diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..972cea4 --- /dev/null +++ b/tests/test_tasks.py @@ -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()