Gate calendar sync workers by tenant entitlement
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user