Consume governed recipients and add operational checks

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent fa4eb39e0b
commit 82ddc0c34c
15 changed files with 354 additions and 14 deletions
+13 -1
View File
@@ -27,6 +27,7 @@ from govoplan_core.core.modules import (
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.operations import OperationalCheckProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_core.core.postbox import (
@@ -254,7 +255,7 @@ manifest = ModuleManifest(
),
ModuleInterfaceRequirement(
name="addresses.recipient_source",
version_min="0.1.0",
version_min="0.1.9",
version_max_exclusive="0.2.0",
optional=True,
),
@@ -750,6 +751,17 @@ manifest = ModuleManifest(
fromlist=["retention_capability"],
).retention_capability(context),
},
operational_check_providers=(
OperationalCheckProviderRegistration(
module_id="campaigns",
check_id="campaign.generated_eml_storage",
provider=lambda: __import__(
"govoplan_campaign.backend.operational_checks",
fromlist=["generated_eml_storage_check"],
).generated_eml_storage_check(),
cache_seconds=60,
),
),
)
@@ -0,0 +1,55 @@
from __future__ import annotations
import os
import secrets
from pathlib import Path
from govoplan_core.core.operations import OperationalCheck
from govoplan_campaign.backend.persistence.campaigns import BUILD_OUTPUT_DIR
def generated_eml_storage_check() -> OperationalCheck:
"""Verify the current EML evidence path and report its node-local boundary."""
root = Path(BUILD_OUTPUT_DIR)
probe = root / ".govoplan-health" / f"{secrets.token_hex(16)}.probe"
payload = secrets.token_bytes(64)
try:
probe.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
with probe.open("xb") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
if probe.read_bytes() != payload:
raise OSError("generated EML persistence returned different bytes")
except OSError as exc:
return OperationalCheck(
id="campaign.generated_eml_storage",
label="Generated Campaign EML evidence",
state="error",
detail=(
"The generated EML evidence path failed a bounded durable-write probe "
f"({type(exc).__name__})."
),
readiness_critical=True,
metrics={"backend": "node_local_filesystem"},
)
finally:
try:
probe.unlink(missing_ok=True)
probe.parent.rmdir()
except OSError:
pass
return OperationalCheck(
id="campaign.generated_eml_storage",
label="Generated Campaign EML evidence",
state="warning",
detail=(
"Generated EML passed a local write/fsync/read probe, but remains node-local. "
"Use one shared runtime volume and include it in coordinated backups until "
"Campaign evidence is migrated to managed object storage."
),
metrics={"backend": "node_local_filesystem"},
)
@@ -356,7 +356,15 @@ def validate_campaign_version(
postbox_available=postbox_integration().available,
)
report_json = report.model_dump(mode="json")
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
report_json.update(
{
"ok": report.ok,
"error_count": report.error_count,
"warning_count": report.warning_count,
"validated_at": datetime.now(UTC).isoformat(),
"validated_by_user_id": user_id,
}
)
version.validation_summary = report_json
# Replace version-level semantic issues from previous validations.
@@ -642,6 +650,7 @@ def build_campaign_version(
tenant_id: str,
version_id: str,
write_eml: bool = True,
user_id: str | None = None,
) -> dict[str, Any]:
version, snapshot_path, config = load_version_config(session, version_id)
campaign = session.get(Campaign, version.campaign_id)
@@ -687,6 +696,7 @@ def build_campaign_version(
entries_by_index=entries_by_index,
)
report_json = _campaign_build_report(result, files)
report_json["built_by_user_id"] = user_id
version.build_summary = report_json
editor_state = copy.deepcopy(version.editor_state or {})
editor_state.pop("review_send", None)
@@ -19,6 +19,7 @@ from govoplan_campaign.backend.schemas import (
CampaignRecipientAddressSourcesResponse,
CampaignRecipientAddressSourceSnapshotRequest,
CampaignRecipientAddressSourceSnapshotResponse,
CampaignRecipientSnapshotExcludedItem,
CampaignRecipientSnapshotItem,
RecipientImportMappingProfileListResponse,
RecipientImportMappingProfilePayload,
@@ -764,7 +765,11 @@ def snapshot_campaign_recipient_address_source(
)
try:
snapshot = getattr(capability, "snapshot")(
session, principal, source_id=payload.source_id
session,
principal,
source_id=payload.source_id,
purpose=payload.purpose,
requested_channels=("email",),
)
except ValueError as exc:
raise HTTPException(
@@ -775,6 +780,15 @@ def snapshot_campaign_recipient_address_source(
CampaignRecipientSnapshotItem.model_validate(_capability_payload(item))
for item in snapshot_payload.get("recipients", [])
]
excluded = [
CampaignRecipientSnapshotExcludedItem.model_validate(_capability_payload(item))
for item in snapshot_payload.get("excluded", [])
]
provenance = (
snapshot_payload.get("provenance")
if isinstance(snapshot_payload.get("provenance"), dict)
else {}
)
return CampaignRecipientAddressSourceSnapshotResponse(
source_id=str(snapshot_payload.get("source_id") or ""),
source_label=str(snapshot_payload.get("source_label") or ""),
@@ -782,9 +796,16 @@ def snapshot_campaign_recipient_address_source(
source_revision=str(snapshot_payload.get("source_revision") or ""),
generated_at=str(snapshot_payload.get("generated_at") or ""),
recipients=recipients,
provenance=snapshot_payload.get("provenance")
if isinstance(snapshot_payload.get("provenance"), dict)
else {},
excluded=excluded,
included_count=len(recipients),
excluded_count=len(excluded),
purpose=str(snapshot_payload.get("purpose") or payload.purpose),
provenance={
**provenance,
"campaign_id": campaign_id,
"purpose": payload.purpose,
"requested_channels": ["email"],
},
)
@@ -658,6 +658,7 @@ def build_version(
tenant_id=principal.tenant_id,
version_id=version_id,
write_eml=payload.write_eml if payload else True,
user_id=principal.user.id,
)
audit_from_principal(
session,
+17
View File
@@ -378,6 +378,7 @@ class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_id: str = Field(min_length=1)
purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120)
class CampaignRecipientSnapshotItem(BaseModel):
@@ -389,6 +390,18 @@ class CampaignRecipientSnapshotItem(BaseModel):
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientSnapshotExcludedItem(BaseModel):
contact_id: str
display_name: str
channel: str
target: str
contact_point_id: str | None = None
status: str
reason_code: str | None = None
explanation: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
source_id: str
source_label: str
@@ -396,6 +409,10 @@ class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
source_revision: str
generated_at: str
recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list)
excluded: list[CampaignRecipientSnapshotExcludedItem] = Field(default_factory=list)
included_count: int = 0
excluded_count: int = 0
purpose: str = "campaign_delivery"
provenance: dict[str, Any] = Field(default_factory=dict)