Add IDM assignment lifecycle worker contract
This commit is contained in:
@@ -19,6 +19,10 @@ from govoplan_core.core.events import (
|
|||||||
PlatformEventOutbox,
|
PlatformEventOutbox,
|
||||||
publish_platform_event,
|
publish_platform_event,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.idm import (
|
||||||
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||||
|
IdmAssignmentLifecycle,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.core.mail import CAPABILITY_MAIL_DELIVERY_OUTBOX, MailDeliveryOutboxProvider
|
from govoplan_core.core.mail import CAPABILITY_MAIL_DELIVERY_OUTBOX, MailDeliveryOutboxProvider
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
@@ -62,6 +66,7 @@ celery.conf.update(
|
|||||||
"govoplan.postbox.dispatch_routes": {"queue": "postbox"},
|
"govoplan.postbox.dispatch_routes": {"queue": "postbox"},
|
||||||
"govoplan.events.dispatch_outbox": {"queue": "events"},
|
"govoplan.events.dispatch_outbox": {"queue": "events"},
|
||||||
"govoplan.events.purge_outbox": {"queue": "events"},
|
"govoplan.events.purge_outbox": {"queue": "events"},
|
||||||
|
"govoplan.idm.expire_assignments": {"queue": "idm"},
|
||||||
},
|
},
|
||||||
worker_prefetch_multiplier=1,
|
worker_prefetch_multiplier=1,
|
||||||
task_acks_late=True,
|
task_acks_late=True,
|
||||||
@@ -117,6 +122,11 @@ celery.conf.update(
|
|||||||
"schedule": 24 * 60 * 60.0,
|
"schedule": 24 * 60 * 60.0,
|
||||||
"args": (500,),
|
"args": (500,),
|
||||||
},
|
},
|
||||||
|
"idm-assignment-expiry-every-minute": {
|
||||||
|
"task": "govoplan.idm.expire_assignments",
|
||||||
|
"schedule": 60.0,
|
||||||
|
"args": (None, 100),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -238,6 +248,20 @@ def _platform_event_outbox(
|
|||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _idm_assignment_lifecycle(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> IdmAssignmentLifecycle | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(
|
||||||
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE
|
||||||
|
)
|
||||||
|
if not isinstance(capability, IdmAssignmentLifecycle):
|
||||||
|
raise RuntimeError("IDM assignment lifecycle capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
|
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
|
||||||
def send_email(self, job_id: str):
|
def send_email(self, job_id: str):
|
||||||
"""Send one explicitly queued campaign job.
|
"""Send one explicitly queued campaign job.
|
||||||
@@ -485,6 +509,35 @@ def dispatch_postbox_routes(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.idm.expire_assignments",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def expire_idm_assignments(
|
||||||
|
self,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
):
|
||||||
|
"""Emit idempotent lifecycle events for elapsed IDM assignments."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _idm_assignment_lifecycle()
|
||||||
|
if provider is None:
|
||||||
|
return {"selected": 0, "expired": 0, "assignment_ids": []}
|
||||||
|
result = dict(
|
||||||
|
provider.process_expired(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery.task(
|
@celery.task(
|
||||||
name="govoplan.events.dispatch_outbox",
|
name="govoplan.events.dispatch_outbox",
|
||||||
bind=True,
|
bind=True,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Literal, Protocol, runtime_checkable
|
|||||||
IDM_MODULE_ID = "idm"
|
IDM_MODULE_ID = "idm"
|
||||||
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
||||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
|
||||||
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE = "idm.assignmentLifecycle"
|
||||||
|
|
||||||
IdmStatus = Literal["active", "inactive", "suspended"]
|
IdmStatus = Literal["active", "inactive", "suspended"]
|
||||||
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||||
@@ -106,3 +107,36 @@ class IdmFunctionAssignmentDirectory(Protocol):
|
|||||||
effective_at: datetime | None = None,
|
effective_at: datetime | None = None,
|
||||||
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
|
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdmAssignmentLifecycle(Protocol):
|
||||||
|
"""Worker boundary for time-driven function-assignment transitions."""
|
||||||
|
|
||||||
|
def process_expired(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def idm_assignment_lifecycle(
|
||||||
|
registry: object | None,
|
||||||
|
) -> IdmAssignmentLifecycle | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, IdmAssignmentLifecycle)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from govoplan_core.celery_app import celery, expire_idm_assignments
|
||||||
|
from govoplan_core.core.idm import (
|
||||||
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||||
|
IdmAssignmentLifecycle,
|
||||||
|
idm_assignment_lifecycle,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class _Lifecycle:
|
||||||
|
def process_expired(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
tenant_id=None,
|
||||||
|
effective_at=None,
|
||||||
|
limit=100,
|
||||||
|
):
|
||||||
|
del session, tenant_id, effective_at, limit
|
||||||
|
return {"selected": 0, "expired": 0, "assignment_ids": []}
|
||||||
|
|
||||||
|
|
||||||
|
class IdmAssignmentLifecycleWorkerTests(unittest.TestCase):
|
||||||
|
def test_contract_is_runtime_checkable_and_optional(self) -> None:
|
||||||
|
provider = _Lifecycle()
|
||||||
|
self.assertIsInstance(provider, IdmAssignmentLifecycle)
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="idm_lifecycle_test",
|
||||||
|
name="IDM lifecycle test",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE: (
|
||||||
|
lambda _context: provider
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(provider, idm_assignment_lifecycle(registry))
|
||||||
|
self.assertIsNone(idm_assignment_lifecycle(PlatformRegistry()))
|
||||||
|
|
||||||
|
def test_worker_commits_provider_outcome(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
database = MagicMock()
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.process_expired.return_value = {
|
||||||
|
"selected": 2,
|
||||||
|
"expired": 1,
|
||||||
|
"assignment_ids": ["assignment-1"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._idm_assignment_lifecycle",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.db.session.get_database",
|
||||||
|
return_value=database,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = expire_idm_assignments.run("tenant-1", 25)
|
||||||
|
|
||||||
|
provider.process_expired.assert_called_once_with(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
limit=25,
|
||||||
|
)
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
self.assertEqual(1, result["expired"])
|
||||||
|
|
||||||
|
def test_route_and_periodic_job_are_registered(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
{"queue": "idm"},
|
||||||
|
celery.conf.task_routes["govoplan.idm.expire_assignments"],
|
||||||
|
)
|
||||||
|
schedule = celery.conf.beat_schedule[
|
||||||
|
"idm-assignment-expiry-every-minute"
|
||||||
|
]
|
||||||
|
self.assertEqual("govoplan.idm.expire_assignments", schedule["task"])
|
||||||
|
self.assertEqual(60.0, schedule["schedule"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user