feat: add governed postbox delivery and report hardening
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxDeliveryCatalogRef,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxTargetRef,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.field_values import (
|
||||
effective_entry_field_values,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
Behavior,
|
||||
CampaignConfig,
|
||||
EntryConfig,
|
||||
PostboxTargetConfig,
|
||||
PostboxTargetMatch,
|
||||
PostboxTargetMode,
|
||||
effective_postbox_targets,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import postbox_integration
|
||||
from govoplan_campaign.backend.messages.models import (
|
||||
MessageIssue,
|
||||
MessageValidationStatus,
|
||||
)
|
||||
|
||||
|
||||
def _apply_behavior(
|
||||
current: MessageValidationStatus,
|
||||
behavior: Behavior,
|
||||
) -> MessageValidationStatus:
|
||||
if behavior == Behavior.BLOCK:
|
||||
return MessageValidationStatus.BLOCKED
|
||||
if behavior == Behavior.DROP:
|
||||
return MessageValidationStatus.EXCLUDED
|
||||
if behavior == Behavior.ASK and current not in {
|
||||
MessageValidationStatus.BLOCKED,
|
||||
MessageValidationStatus.EXCLUDED,
|
||||
}:
|
||||
return MessageValidationStatus.NEEDS_REVIEW
|
||||
if behavior == Behavior.WARN and current == MessageValidationStatus.READY:
|
||||
return MessageValidationStatus.WARNING
|
||||
return current
|
||||
|
||||
|
||||
def _issue(
|
||||
*,
|
||||
code: str,
|
||||
message: str,
|
||||
behavior: Behavior,
|
||||
) -> MessageIssue:
|
||||
return MessageIssue(
|
||||
severity="error" if behavior == Behavior.BLOCK else "warning",
|
||||
code=code,
|
||||
message=message,
|
||||
behavior=behavior.value,
|
||||
source="postbox",
|
||||
)
|
||||
|
||||
|
||||
def _field_value(
|
||||
values: dict[str, Any],
|
||||
field_name: str | None,
|
||||
) -> str | None:
|
||||
if not field_name:
|
||||
return None
|
||||
value = values.get(field_name)
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _match_unit(
|
||||
catalog: PostboxDeliveryCatalogRef,
|
||||
value: str | None,
|
||||
match: PostboxTargetMatch,
|
||||
):
|
||||
if not value:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
unit
|
||||
for unit in catalog.organization_units
|
||||
if (unit.id if match == PostboxTargetMatch.ID else unit.slug) == value
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _match_function(unit, value: str | None, match: PostboxTargetMatch):
|
||||
if unit is None or not value:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
function
|
||||
for function in unit.functions
|
||||
if (
|
||||
function.id
|
||||
if match == PostboxTargetMatch.ID
|
||||
else function.slug
|
||||
)
|
||||
== value
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _target_ref(
|
||||
target: PostboxTargetConfig,
|
||||
*,
|
||||
values: dict[str, Any],
|
||||
catalog: PostboxDeliveryCatalogRef,
|
||||
) -> tuple[PostboxTargetRef | None, str | None]:
|
||||
if target.mode == PostboxTargetMode.DIRECT:
|
||||
return (
|
||||
PostboxTargetRef(
|
||||
postbox_id=target.postbox_id,
|
||||
address_key=target.address_key,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
unit_value = target.organization_unit_id or _field_value(
|
||||
values,
|
||||
target.organization_unit_field,
|
||||
)
|
||||
unit_match = (
|
||||
PostboxTargetMatch.ID
|
||||
if target.organization_unit_id
|
||||
else target.organization_unit_match
|
||||
)
|
||||
unit = _match_unit(catalog, unit_value, unit_match)
|
||||
if unit is None:
|
||||
return None, (
|
||||
f"Organization unit {unit_value!r} could not be resolved by "
|
||||
f"{unit_match.value}."
|
||||
)
|
||||
|
||||
function_value = target.function_id or _field_value(
|
||||
values,
|
||||
target.function_field,
|
||||
)
|
||||
function_match = (
|
||||
PostboxTargetMatch.ID
|
||||
if target.function_id
|
||||
else target.function_match
|
||||
)
|
||||
function = _match_function(unit, function_value, function_match)
|
||||
if function is None:
|
||||
return None, (
|
||||
f"Organization function {function_value!r} could not be resolved "
|
||||
f"inside {unit.name!r} by {function_match.value}."
|
||||
)
|
||||
|
||||
context_key = target.context_key or _field_value(
|
||||
values,
|
||||
target.context_field,
|
||||
)
|
||||
return (
|
||||
PostboxTargetRef(
|
||||
template_id=target.template_id,
|
||||
organization_unit_id=unit.id,
|
||||
function_id=function.id,
|
||||
context_key=context_key,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _resolved_target_payload(
|
||||
target: PostboxTargetConfig,
|
||||
entry: PostboxDirectoryEntryRef,
|
||||
*,
|
||||
position: int,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"target_id": target.id,
|
||||
"position": position,
|
||||
"mode": target.mode.value,
|
||||
"requested": target.model_dump(mode="json", exclude_none=True),
|
||||
"postbox_id": entry.id,
|
||||
"address": entry.address,
|
||||
"address_key": entry.address_key,
|
||||
"name": entry.name,
|
||||
"status": entry.status,
|
||||
"classification": entry.classification,
|
||||
"organization_unit_id": entry.organization_unit_id,
|
||||
"organization_unit_name": entry.organization_unit_name,
|
||||
"function_id": entry.function_id,
|
||||
"function_name": entry.function_name,
|
||||
"context_key": entry.context_key,
|
||||
"template_revision_id": entry.template_revision_id,
|
||||
"holder_count": entry.holder_count,
|
||||
"vacant": entry.vacant,
|
||||
}
|
||||
|
||||
|
||||
def resolve_entry_postbox_targets(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
config: CampaignConfig,
|
||||
entry: EntryConfig,
|
||||
validation_status: MessageValidationStatus,
|
||||
materialize: bool,
|
||||
) -> tuple[
|
||||
list[dict[str, Any]],
|
||||
list[MessageIssue],
|
||||
MessageValidationStatus,
|
||||
]:
|
||||
integration = postbox_integration()
|
||||
policy = config.delivery.postbox
|
||||
targets = effective_postbox_targets(config, entry)
|
||||
if not targets:
|
||||
issue = _issue(
|
||||
code="postbox_target_missing",
|
||||
message="Postbox delivery requires at least one target.",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
return (
|
||||
[],
|
||||
[issue],
|
||||
_apply_behavior(validation_status, policy.unresolved_target),
|
||||
)
|
||||
|
||||
try:
|
||||
catalog = integration.delivery_catalog(session, tenant_id=tenant_id)
|
||||
except Exception as exc:
|
||||
issue = _issue(
|
||||
code="postbox_unavailable",
|
||||
message=str(exc),
|
||||
behavior=Behavior.BLOCK,
|
||||
)
|
||||
return [], [issue], MessageValidationStatus.BLOCKED
|
||||
|
||||
values = effective_entry_field_values(config, entry)
|
||||
resolved: list[dict[str, Any]] = []
|
||||
issues: list[MessageIssue] = []
|
||||
status = validation_status
|
||||
seen_postbox_ids: set[str] = set()
|
||||
for position, target in enumerate(targets):
|
||||
target_ref, resolution_error = _target_ref(
|
||||
target,
|
||||
values=values,
|
||||
catalog=catalog,
|
||||
)
|
||||
if target_ref is None:
|
||||
issue = _issue(
|
||||
code="postbox_target_unresolved",
|
||||
message=resolution_error or "Postbox target could not be resolved.",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.unresolved_target)
|
||||
continue
|
||||
try:
|
||||
entry_ref = integration.resolve_postbox(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
target=target_ref,
|
||||
materialize=materialize,
|
||||
)
|
||||
except Exception as exc:
|
||||
issue = _issue(
|
||||
code="postbox_target_unresolved",
|
||||
message=f"Postbox target {target.id!r} could not be resolved: {exc}",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.unresolved_target)
|
||||
continue
|
||||
if entry_ref is None:
|
||||
issue = _issue(
|
||||
code="postbox_target_unresolved",
|
||||
message=f"Postbox target {target.id!r} does not exist.",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.unresolved_target)
|
||||
continue
|
||||
if entry_ref.id in seen_postbox_ids:
|
||||
issue = _issue(
|
||||
code="postbox_target_duplicate",
|
||||
message=(
|
||||
f"Postbox {entry_ref.address!r} is selected more than once; "
|
||||
"it will receive one message."
|
||||
),
|
||||
behavior=policy.duplicate_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.duplicate_target)
|
||||
continue
|
||||
seen_postbox_ids.add(entry_ref.id)
|
||||
resolved.append(
|
||||
_resolved_target_payload(
|
||||
target,
|
||||
entry_ref,
|
||||
position=position,
|
||||
)
|
||||
)
|
||||
if entry_ref.vacant:
|
||||
issue = _issue(
|
||||
code="postbox_target_vacant",
|
||||
message=(
|
||||
f"Postbox {entry_ref.address!r} currently has no function "
|
||||
"holder."
|
||||
),
|
||||
behavior=policy.vacant_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.vacant_target)
|
||||
return resolved, issues, status
|
||||
|
||||
|
||||
def delivery_catalog_payload(catalog: PostboxDeliveryCatalogRef) -> dict[str, Any]:
|
||||
return asdict(catalog)
|
||||
Reference in New Issue
Block a user