feat(postbox): schedule lifecycle notification reconciliation

This commit is contained in:
2026-08-20 04:58:35 +02:00
parent be7b79612c
commit 0aae6f0539
3 changed files with 63 additions and 8 deletions
+27 -8
View File
@@ -1188,7 +1188,7 @@ def dispatch_postbox_routes(
tenant_id: str | None = None, tenant_id: str | None = None,
limit: int = 50, limit: int = 50,
): ):
"""Deliver due Postbox vacancy escalations from durable route rows.""" """Deliver due routes and reconcile assignment-derived notification facts."""
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
@@ -1202,21 +1202,40 @@ def dispatch_postbox_routes(
"cancelled": 0, "cancelled": 0,
"failed": 0, "failed": 0,
"route_ids": [], "route_ids": [],
"lifecycle_scanned": 0,
"lifecycle_changed": 0,
"lifecycle_events": 0,
"lifecycle_notifications": 0,
"lifecycle_notification_failures": 0,
} }
if not registry.has_capability(CAPABILITY_POSTBOX_ROUTING): if not registry.has_capability(CAPABILITY_POSTBOX_ROUTING):
return defaults return defaults
provider = _postbox_routing_provider(registry)
def dispatch_tenant(effective_tenant_id: str) -> Mapping[str, object]:
route_result = provider.dispatch_due_routes( # type: ignore[union-attr]
session,
tenant_id=effective_tenant_id,
limit=limit,
)
lifecycle_result = provider.reconcile_notification_lifecycle( # type: ignore[union-attr]
session,
tenant_id=effective_tenant_id,
limit=limit,
)
return {
**route_result,
**{
f"lifecycle_{key}": value for key, value in lifecycle_result.items()
},
}
result = _run_tenant_worker_batches( result = _run_tenant_worker_batches(
registry, registry,
session, session,
capability_name=CAPABILITY_POSTBOX_ROUTING, capability_name=CAPABILITY_POSTBOX_ROUTING,
tenant_id=tenant_id, tenant_id=tenant_id,
operation=lambda effective_tenant_id: _postbox_routing_provider( operation=dispatch_tenant,
registry
).dispatch_due_routes( # type: ignore[union-attr]
session,
tenant_id=effective_tenant_id,
limit=limit,
),
defaults=defaults, defaults=defaults,
) )
session.commit() session.commit()
+12
View File
@@ -309,6 +309,7 @@ class PostboxDeliveryRequest:
body_text: str | None = None body_text: str | None = None
sender_label: str | None = None sender_label: str | None = None
classification: str = "internal" classification: str = "internal"
action_required: bool = False
participants: tuple[PostboxParticipantRef, ...] = () participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = () attachments: tuple[PostboxAttachmentRef, ...] = ()
expires_at: datetime | None = None expires_at: datetime | None = None
@@ -511,6 +512,17 @@ class PostboxRoutingProvider(Protocol):
) -> Mapping[str, object]: ) -> Mapping[str, object]:
... ...
def reconcile_notification_lifecycle(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
"""Reconcile assignment-derived Postbox notification facts."""
...
@runtime_checkable @runtime_checkable
class PostboxPortalProjectionProvider(Protocol): class PostboxPortalProjectionProvider(Protocol):
+24
View File
@@ -25,6 +25,16 @@ class _RoutingProvider:
del session, tenant_id, limit del session, tenant_id, limit
return {"selected": 0} return {"selected": 0}
def reconcile_notification_lifecycle(
self,
session,
*,
tenant_id=None,
limit=50,
):
del session, tenant_id, limit
return {"scanned": 0}
class PostboxRoutingWorkerTests(unittest.TestCase): class PostboxRoutingWorkerTests(unittest.TestCase):
def test_contract_is_runtime_checkable_and_resolved(self) -> None: def test_contract_is_runtime_checkable_and_resolved(self) -> None:
@@ -59,6 +69,13 @@ class PostboxRoutingWorkerTests(unittest.TestCase):
"selected": 1, "selected": 1,
"delivered": 1, "delivered": 1,
} }
provider.reconcile_notification_lifecycle.return_value = {
"scanned": 2,
"changed": 1,
"events": 2,
"notifications": 1,
"notification_failures": 0,
}
with ( with (
patch( patch(
@@ -81,8 +98,15 @@ class PostboxRoutingWorkerTests(unittest.TestCase):
tenant_id="tenant-1", tenant_id="tenant-1",
limit=25, limit=25,
) )
provider.reconcile_notification_lifecycle.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=25,
)
session.commit.assert_called_once_with() session.commit.assert_called_once_with()
self.assertEqual(1, result["delivered"]) self.assertEqual(1, result["delivered"])
self.assertEqual(2, result["lifecycle_scanned"])
self.assertEqual(1, result["lifecycle_notifications"])
def test_route_and_periodic_recovery_are_registered(self) -> None: def test_route_and_periodic_recovery_are_registered(self) -> None:
self.assertEqual( self.assertEqual(