Integrate governed address contact resolution
This commit is contained in:
@@ -22,7 +22,9 @@ Distribution Lists owns:
|
||||
- German-administration `Verteiler` semantics
|
||||
- static, parameterized, and Dataflow-backed dynamic audience definitions
|
||||
- expansion of mixed entries into concrete delivery/recipient targets
|
||||
- snapshot DTOs with source revision and provenance
|
||||
- purpose-aware Addresses contact-point resolution for email and postal targets
|
||||
- snapshot DTOs with exact rendered targets, contact-point IDs, source revision,
|
||||
fingerprint, and decision provenance
|
||||
- stale-source detection for expanded entries
|
||||
- eligibility and policy explanations for disabled recipient entries
|
||||
- audit evidence for who was resolved at execution time
|
||||
|
||||
@@ -180,7 +180,7 @@ typed output template, and Report/Campaign run configuration.
|
||||
| Multiple email, phone, and postal contact points | Implemented | Addresses |
|
||||
| Static address lists | Implemented | Addresses |
|
||||
| vCard import/export and CardDAV sync | Implemented | Addresses |
|
||||
| Read-only lookup and immutable email-recipient snapshots | Implemented | Addresses |
|
||||
| Read-only lookup and immutable email/postal recipient snapshots | Implemented | Addresses/Distribution Lists |
|
||||
| Source provenance and sync conflicts | Implemented | Addresses |
|
||||
| Organization units and function definitions | Implemented | Organizations |
|
||||
| Effective-dated identity-to-function assignments | Implemented first slice | IDM |
|
||||
@@ -190,10 +190,10 @@ typed output template, and Report/Campaign run configuration.
|
||||
| Campaign snapshots, review, digital delivery, and evidence | Implemented first channel | Campaign |
|
||||
| Typed template library and document rendering | Scaffold/issue only | Templates |
|
||||
| BI reports and spreadsheet-grade exports | Scaffold/issue only | Reporting |
|
||||
| Mixed operational distribution lists and governed segments | Contract/implementation issues only | Distribution Lists |
|
||||
| Contact-point consent, suppression, and preferences | Open issue | Addresses/Policy |
|
||||
| Deduplication, merge, quality, and complete history | Open issue | Addresses |
|
||||
| Channel-neutral expansion and frozen postal targets | Missing | Distribution Lists/Addresses |
|
||||
| Mixed operational distribution lists and governed segments | Implemented broad first slice | Distribution Lists |
|
||||
| Contact-point consent, suppression, and preferences | Implemented | Addresses/Policy |
|
||||
| Deduplication, merge, quality, and complete history | Implemented first slice | Addresses |
|
||||
| Channel-neutral expansion and frozen postal targets | Implemented | Distribution Lists/Addresses |
|
||||
| Printable label, envelope, and letter output | Open issue | Templates |
|
||||
| End-to-end guided AdreMa workflow | Missing | Workflow plus consuming modules |
|
||||
|
||||
|
||||
@@ -83,6 +83,16 @@ The first capabilities should be:
|
||||
- `dist_lists.expand`: expand a distribution list into immutable recipient rows.
|
||||
- `dist_lists.writer`: explain whether a caller can create or change a list.
|
||||
|
||||
When `addresses.contact_point_resolution` is available, address-contact and
|
||||
address-list entries are resolved through that versioned contract. Entry and
|
||||
expansion channel constraints are intersected, and purpose, effective time,
|
||||
address purpose, fallback rule, locale, and postal format are passed to the
|
||||
provider. Selected and rejected contact points retain stable reason codes and
|
||||
provider provenance. The frozen Distribution Lists snapshot stores the exact
|
||||
rendered target and contact-point ID, so later address changes do not rewrite
|
||||
delivery evidence. Legacy Addresses lookup/email-source capabilities are used
|
||||
only as a compatibility fallback.
|
||||
|
||||
Consumers must resolve those capabilities through core and must not import
|
||||
distribution-list ORM or service internals.
|
||||
|
||||
@@ -98,9 +108,9 @@ Likely consumers:
|
||||
|
||||
## Relationship To Address Lists
|
||||
|
||||
`govoplan-addresses` should implement classical address lists first. A
|
||||
distribution list may later include an address list as an entry, but the
|
||||
address module remains the owner of contact records and address-only grouping.
|
||||
`govoplan-addresses` implements classical address lists. A distribution list
|
||||
may include an address list as an entry, but the address module remains the
|
||||
owner of contact records and address-only grouping.
|
||||
|
||||
The distribution-list module owns mixed operational routing. This prevents
|
||||
campaign-specific recipient logic from becoming the platform's de facto
|
||||
|
||||
@@ -44,13 +44,17 @@ Goal: allow distribution lists to resolve through installed modules.
|
||||
|
||||
Tasks:
|
||||
|
||||
- [x] integrate with `addresses.lookup` and address-list source capability
|
||||
- [x] integrate with versioned, channel-neutral Addresses contact-point
|
||||
resolution; retain lookup/address-list source capabilities as compatibility
|
||||
fallbacks
|
||||
- [x] integrate with IDM identity and effective function-assignment resolution
|
||||
- [ ] resolve typed IDM groups once `govoplan-idm#7` exposes the provider contract;
|
||||
until then the selector and expansion return an explicit unavailable outcome
|
||||
- [x] integrate with Organizations unit/function resolution
|
||||
- [x] consume governed Datasource/Connector states through pinned Dataflow outputs
|
||||
- [x] apply Addresses preference/consent facts and explainable Policy decisions
|
||||
- [x] freeze exact postal/email targets, contact-point IDs, revisions,
|
||||
fingerprints, and decision provenance in expansion snapshots
|
||||
- [x] preserve module independence when optional providers are absent
|
||||
|
||||
## Milestone 4: UI
|
||||
|
||||
@@ -16,6 +16,14 @@ from govoplan_core.core.dataflows import (
|
||||
DataflowDatasetRequest,
|
||||
dataflow_dataset_output,
|
||||
)
|
||||
from govoplan_core.core.contact_points import (
|
||||
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
|
||||
ContactPointCandidate,
|
||||
ContactPointResolution,
|
||||
ContactPointResolutionProvider,
|
||||
ContactPointResolutionRequest,
|
||||
ContactPointSourceRequest,
|
||||
)
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
|
||||
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
|
||||
@@ -566,6 +574,199 @@ def _nested_list_recipients(
|
||||
def _address_recipients(
|
||||
context: _ExpansionContext,
|
||||
entry: DistributionListEntryRef,
|
||||
) -> list[DistributionRecipientRef]:
|
||||
provider = _typed_capability(
|
||||
context.registry,
|
||||
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
|
||||
ContactPointResolutionProvider,
|
||||
)
|
||||
if provider is not None:
|
||||
return _contact_point_recipients(context, entry, provider)
|
||||
return _legacy_address_recipients(context, entry)
|
||||
|
||||
|
||||
def _contact_point_recipients(
|
||||
context: _ExpansionContext,
|
||||
entry: DistributionListEntryRef,
|
||||
provider: ContactPointResolutionProvider,
|
||||
) -> list[DistributionRecipientRef]:
|
||||
requested_channels, constrained = _requested_channel_filter(context, entry)
|
||||
if constrained and not requested_channels:
|
||||
return [
|
||||
_unresolved(
|
||||
entry,
|
||||
"channel.no_common_selection",
|
||||
"The entry and expansion request do not allow a common delivery channel.",
|
||||
status="suppressed",
|
||||
)
|
||||
]
|
||||
if entry.kind == "address_list":
|
||||
return _contact_point_source_recipients(
|
||||
context,
|
||||
entry,
|
||||
provider,
|
||||
requested_channels=requested_channels,
|
||||
)
|
||||
try:
|
||||
resolution = provider.resolve_contact_points(
|
||||
context.session,
|
||||
context.principal,
|
||||
request=ContactPointResolutionRequest(
|
||||
tenant_id=context.principal.tenant_id,
|
||||
subject=entry.source,
|
||||
effective_at=context.effective_at,
|
||||
purpose=entry.purpose or context.request.purpose,
|
||||
requested_channels=requested_channels,
|
||||
address_purpose=_text(entry.configuration.get("address_purpose")),
|
||||
fallback_rule=_contact_point_fallback_rule(entry),
|
||||
locale=_text(entry.configuration.get("locale")),
|
||||
postal_format=_postal_format(entry),
|
||||
context={
|
||||
"distribution_list_id": context.request.list_id,
|
||||
"distribution_list_entry_id": entry.id,
|
||||
},
|
||||
),
|
||||
)
|
||||
except (LookupError, PermissionError, ValueError) as exc:
|
||||
return [_unresolved(entry, "addresses.contact_point_failed", str(exc))]
|
||||
contact_revision = _contact_record_revision(resolution)
|
||||
stale = _is_stale(
|
||||
entry.source,
|
||||
actual_revision=contact_revision,
|
||||
actual_fingerprint=resolution.source_fingerprint,
|
||||
)
|
||||
context.evidence.append(
|
||||
_contact_point_evidence(
|
||||
entry,
|
||||
actual_revision=contact_revision or resolution.source_revision,
|
||||
actual_fingerprint=resolution.source_fingerprint,
|
||||
stale=stale,
|
||||
details={
|
||||
"contract_version": resolution.contract_version,
|
||||
"contact_id": resolution.contact_id,
|
||||
"contact_point_revision": resolution.source_revision,
|
||||
"contact_point_fingerprint": resolution.source_fingerprint,
|
||||
"provenance": dict(resolution.provenance),
|
||||
},
|
||||
)
|
||||
)
|
||||
return [
|
||||
_contact_point_recipient(
|
||||
entry,
|
||||
resolution,
|
||||
stale=stale,
|
||||
expected_email=(
|
||||
_text(entry.configuration.get("email"))
|
||||
or _text(entry.source.metadata.get("email"))
|
||||
if entry.kind == "address_email"
|
||||
else None
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _contact_point_source_recipients(
|
||||
context: _ExpansionContext,
|
||||
entry: DistributionListEntryRef,
|
||||
provider: ContactPointResolutionProvider,
|
||||
*,
|
||||
requested_channels: tuple[str, ...],
|
||||
) -> list[DistributionRecipientRef]:
|
||||
source_id = _contact_point_source_id(entry)
|
||||
source_request = ContactPointSourceRequest(
|
||||
tenant_id=context.principal.tenant_id,
|
||||
source_id=source_id,
|
||||
effective_at=context.effective_at,
|
||||
purpose=entry.purpose or context.request.purpose,
|
||||
requested_channels=requested_channels, # type: ignore[arg-type]
|
||||
address_purpose=_text(entry.configuration.get("address_purpose")),
|
||||
fallback_rule=_contact_point_fallback_rule(entry),
|
||||
locale=_text(entry.configuration.get("locale")),
|
||||
postal_format=_postal_format(entry),
|
||||
max_items=20_000,
|
||||
context={
|
||||
"distribution_list_id": context.request.list_id,
|
||||
"distribution_list_entry_id": entry.id,
|
||||
},
|
||||
)
|
||||
resolutions: list[ContactPointResolution] = []
|
||||
preview = None
|
||||
source_revision: str | None = None
|
||||
source_fingerprint: str | None = None
|
||||
offset = 0
|
||||
provider_limit = context.request.limits.max_provider_results
|
||||
try:
|
||||
while len(resolutions) < provider_limit:
|
||||
page_limit = min(500, provider_limit - len(resolutions))
|
||||
preview = provider.preview_source(
|
||||
context.session,
|
||||
context.principal,
|
||||
request=source_request,
|
||||
offset=offset,
|
||||
limit=page_limit,
|
||||
)
|
||||
if source_revision is None:
|
||||
source_revision = preview.source_revision
|
||||
source_fingerprint = preview.source_fingerprint
|
||||
elif (
|
||||
preview.source_revision != source_revision
|
||||
or preview.source_fingerprint != source_fingerprint
|
||||
):
|
||||
return [
|
||||
_unresolved(
|
||||
entry,
|
||||
"addresses.source_changed",
|
||||
"The Addresses source changed while it was being expanded; retry the expansion.",
|
||||
status="stale",
|
||||
)
|
||||
]
|
||||
resolutions.extend(preview.resolutions)
|
||||
offset += len(preview.resolutions)
|
||||
if not preview.has_more or not preview.resolutions:
|
||||
break
|
||||
except (LookupError, PermissionError, ValueError) as exc:
|
||||
return [_unresolved(entry, "addresses.source_failed", str(exc))]
|
||||
if preview is None:
|
||||
return []
|
||||
stale = _is_stale(
|
||||
entry.source,
|
||||
actual_revision=source_revision,
|
||||
actual_fingerprint=source_fingerprint,
|
||||
)
|
||||
context.evidence.append(
|
||||
_contact_point_evidence(
|
||||
entry,
|
||||
actual_revision=source_revision,
|
||||
actual_fingerprint=source_fingerprint,
|
||||
stale=stale,
|
||||
generated_at=preview.generated_at,
|
||||
details={
|
||||
"contract_version": preview.contract_version,
|
||||
"source_id": source_id,
|
||||
"total_count": preview.total_count,
|
||||
"provenance": dict(preview.provenance),
|
||||
},
|
||||
)
|
||||
)
|
||||
if preview.total_count > len(resolutions):
|
||||
context.candidate_truncated = True
|
||||
context.diagnostics.append(
|
||||
_explanation(
|
||||
"provider.result_limit",
|
||||
"Addresses results were truncated by the provider limit.",
|
||||
provider="addresses",
|
||||
source=entry.source,
|
||||
)
|
||||
)
|
||||
return [
|
||||
_contact_point_recipient(entry, resolution, stale=stale)
|
||||
for resolution in resolutions
|
||||
]
|
||||
|
||||
|
||||
def _legacy_address_recipients(
|
||||
context: _ExpansionContext,
|
||||
entry: DistributionListEntryRef,
|
||||
) -> list[DistributionRecipientRef]:
|
||||
if entry.kind == "address_list":
|
||||
provider = _capability(context.registry, ADDRESSES_RECIPIENT_SOURCE)
|
||||
@@ -631,6 +832,194 @@ def _address_recipients(
|
||||
return [_address_lookup_recipient(entry, item) for item in exact]
|
||||
|
||||
|
||||
def _contact_point_recipient(
|
||||
entry: DistributionListEntryRef,
|
||||
resolution: ContactPointResolution,
|
||||
*,
|
||||
stale: bool,
|
||||
expected_email: str | None = None,
|
||||
) -> DistributionRecipientRef:
|
||||
accepted = list(resolution.candidates)
|
||||
rejected = list(resolution.excluded)
|
||||
if expected_email is not None:
|
||||
matching: list[ContactPointCandidate] = []
|
||||
for candidate in accepted:
|
||||
if candidate.channel == "email" and candidate.target.casefold() == expected_email.casefold():
|
||||
matching.append(candidate)
|
||||
else:
|
||||
rejected.append(
|
||||
replace(
|
||||
candidate,
|
||||
status="suppressed",
|
||||
reason_code="addresses.email.not_selected",
|
||||
explanation="This contact point is not the email address selected by the list entry.",
|
||||
)
|
||||
)
|
||||
accepted = matching
|
||||
channels = tuple(
|
||||
_distribution_channel_candidate(item, entry=entry, stale=stale)
|
||||
for item in (*accepted, *rejected)
|
||||
)
|
||||
selected_channels = channels[: len(accepted)]
|
||||
if any(item.status == "usable" for item in selected_channels):
|
||||
status = "usable"
|
||||
elif any(item.status == "stale" for item in selected_channels):
|
||||
status = "stale"
|
||||
else:
|
||||
status = _recipient_outcome(channels, resolution.status)
|
||||
explanations = [*resolution.explanations]
|
||||
explanations.extend(
|
||||
DistributionExplanation(
|
||||
code=item.reason_code,
|
||||
message=item.explanation,
|
||||
severity="warning",
|
||||
provider="addresses",
|
||||
source=item.source or entry.source,
|
||||
provenance=dict(item.provenance),
|
||||
)
|
||||
for item in rejected
|
||||
if item.reason_code and item.explanation
|
||||
)
|
||||
contact_id = resolution.contact_id
|
||||
return DistributionRecipientRef(
|
||||
recipient_key=f"contact:{contact_id}" if contact_id else f"unresolved:{entry.id}",
|
||||
display_name=(
|
||||
resolution.display_name
|
||||
or entry.label
|
||||
or entry.source.label
|
||||
or contact_id
|
||||
or entry.source.resource_id
|
||||
),
|
||||
status=status, # type: ignore[arg-type]
|
||||
channels=channels,
|
||||
contact_id=contact_id,
|
||||
source_entry_ids=(entry.id,),
|
||||
explanations=tuple(_unique_explanations(explanations)),
|
||||
attributes={
|
||||
"contact_points": [
|
||||
{
|
||||
"channel": item.channel,
|
||||
"contact_point_id": item.contact_point_id,
|
||||
"address_purpose": item.address_purpose,
|
||||
"value": dict(item.value),
|
||||
}
|
||||
for item in (*accepted, *rejected)
|
||||
],
|
||||
},
|
||||
provenance={
|
||||
**_entry_provenance(entry),
|
||||
"contact_point_resolution": {
|
||||
"contract_version": resolution.contract_version,
|
||||
"source_revision": resolution.source_revision,
|
||||
"source_fingerprint": resolution.source_fingerprint,
|
||||
"provenance": dict(resolution.provenance),
|
||||
},
|
||||
"channel_facts_resolved": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _distribution_channel_candidate(
|
||||
candidate: ContactPointCandidate,
|
||||
*,
|
||||
entry: DistributionListEntryRef,
|
||||
stale: bool,
|
||||
) -> DistributionChannelCandidate:
|
||||
status = candidate.status
|
||||
reason_code = candidate.reason_code
|
||||
explanation = candidate.explanation
|
||||
if stale and status == "usable":
|
||||
status = "stale"
|
||||
reason_code = "source.stale"
|
||||
explanation = "The source revision changed."
|
||||
return DistributionChannelCandidate(
|
||||
channel=candidate.channel,
|
||||
target=candidate.target,
|
||||
target_key=candidate.target_key,
|
||||
status=status,
|
||||
contact_point_id=candidate.contact_point_id,
|
||||
locale=candidate.locale,
|
||||
preferred=candidate.preferred,
|
||||
reason_code=reason_code,
|
||||
explanation=explanation,
|
||||
source=candidate.source or entry.source,
|
||||
decision_provenance={
|
||||
**dict(candidate.provenance),
|
||||
"address_purpose": candidate.address_purpose,
|
||||
"preference_rank": candidate.preference_rank,
|
||||
"source_revision": candidate.source_revision,
|
||||
"preference_revision": candidate.preference_revision,
|
||||
"consent_revision": candidate.consent_revision,
|
||||
"value": dict(candidate.value),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _contact_point_evidence(
|
||||
entry: DistributionListEntryRef,
|
||||
*,
|
||||
actual_revision: str | None,
|
||||
actual_fingerprint: str | None,
|
||||
stale: bool,
|
||||
details: Mapping[str, object],
|
||||
generated_at: datetime | None = None,
|
||||
) -> DistributionProviderEvidence:
|
||||
return DistributionProviderEvidence(
|
||||
provider="addresses",
|
||||
source=entry.source,
|
||||
actual_revision=actual_revision,
|
||||
actual_fingerprint=actual_fingerprint,
|
||||
stale=stale,
|
||||
generated_at=generated_at,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def _contact_record_revision(resolution: ContactPointResolution) -> str | None:
|
||||
revisions = {
|
||||
str(revision).strip()
|
||||
for candidate in (*resolution.candidates, *resolution.excluded)
|
||||
if (revision := candidate.provenance.get("source_revision")) is not None
|
||||
and str(revision).strip()
|
||||
}
|
||||
return next(iter(revisions)) if len(revisions) == 1 else None
|
||||
|
||||
|
||||
def _contact_point_source_id(entry: DistributionListEntryRef) -> str:
|
||||
resource_id = entry.source.resource_id
|
||||
if resource_id.startswith("addresses:"):
|
||||
return resource_id
|
||||
resource_type = entry.source.resource_type
|
||||
if resource_type in {"address_book", "book"}:
|
||||
return f"addresses:address_book:{resource_id}"
|
||||
return f"addresses:address_list:{resource_id}"
|
||||
|
||||
|
||||
def _contact_point_fallback_rule(entry: DistributionListEntryRef):
|
||||
value = _text(entry.configuration.get("fallback_rule")) or "primary"
|
||||
return value if value in {"none", "primary", "any"} else "primary"
|
||||
|
||||
|
||||
def _postal_format(entry: DistributionListEntryRef):
|
||||
value = _text(entry.configuration.get("postal_format")) or "domestic"
|
||||
return value if value in {"domestic", "international"} else "domestic"
|
||||
|
||||
|
||||
def _requested_channel_filter(
|
||||
context: _ExpansionContext,
|
||||
entry: DistributionListEntryRef,
|
||||
) -> tuple[tuple[str, ...], bool]:
|
||||
entry_channels = set(entry.requested_channels)
|
||||
if entry.kind == "address_email":
|
||||
entry_channels = {"email"}
|
||||
caller_channels = set(context.request.requested_channels)
|
||||
if entry_channels and caller_channels:
|
||||
selected = entry_channels.intersection(caller_channels)
|
||||
else:
|
||||
selected = entry_channels or caller_channels
|
||||
return tuple(sorted(selected)), bool(entry_channels or caller_channels)
|
||||
|
||||
|
||||
def _address_snapshot_recipient(
|
||||
entry: DistributionListEntryRef,
|
||||
item: object,
|
||||
@@ -1063,7 +1452,11 @@ def _apply_channel_decisions(
|
||||
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
|
||||
RecipientChannelFactsProvider,
|
||||
)
|
||||
if facts_provider is not None and recipient.source_entry_ids:
|
||||
if (
|
||||
facts_provider is not None
|
||||
and recipient.source_entry_ids
|
||||
and not recipient.provenance.get("channel_facts_resolved")
|
||||
):
|
||||
source = channels[0].source if channels else None
|
||||
if source is not None:
|
||||
facts = facts_provider.resolve_channel_facts(
|
||||
@@ -1097,12 +1490,13 @@ def _apply_channel_decisions(
|
||||
else set()
|
||||
)
|
||||
requested_by_caller = set(context.request.requested_channels)
|
||||
has_channel_constraint = bool(requested_by_entry or requested_by_caller)
|
||||
requested_channels = (
|
||||
requested_by_entry.intersection(requested_by_caller)
|
||||
if requested_by_entry and requested_by_caller
|
||||
else requested_by_entry or requested_by_caller
|
||||
)
|
||||
if requested_channels:
|
||||
if has_channel_constraint:
|
||||
channels = [
|
||||
item
|
||||
if item.channel in requested_channels
|
||||
|
||||
@@ -7,6 +7,9 @@ from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_DATASET_OUTPUT
|
||||
from govoplan_core.core.contact_points import (
|
||||
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
|
||||
)
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
|
||||
CAPABILITY_DISTRIBUTION_LIST_SOURCE,
|
||||
@@ -30,6 +33,7 @@ from govoplan_core.core.modules import (
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
@@ -95,6 +99,24 @@ DOCUMENTATION = (
|
||||
related_modules=("addresses", "campaigns", "mail", "postbox", "notifications", "scheduling", "poll", "workflow_engine", "tasks"),
|
||||
metadata={"seed": True},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.address-contact-resolution",
|
||||
title="Address contact-point resolution",
|
||||
summary="Resolve address contacts and lists into purpose-aware email and postal candidates.",
|
||||
body=(
|
||||
"When Addresses is enabled, Distribution Lists uses its versioned contact-point contract "
|
||||
"to resolve communication purpose, requested channels, address purpose, fallback, locale, "
|
||||
"quality, preference, consent, and suppression. Expansion previews explain rejected points. "
|
||||
"Frozen Distribution List snapshots retain the exact rendered email or postal target, contact-"
|
||||
"point ID, source revision and fingerprint, and decision provenance. If the new contract is "
|
||||
"unavailable, older Addresses lookup and email-source capabilities remain a compatibility fallback."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
related_modules=("addresses", "campaigns", "policy"),
|
||||
metadata={"seed": True},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -162,6 +184,7 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
|
||||
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
|
||||
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
|
||||
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
|
||||
),
|
||||
@@ -179,6 +202,20 @@ manifest = ModuleManifest(
|
||||
version=MODULE_VERSION,
|
||||
),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
|
||||
version_min="1.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_RECIPIENT_CHANNEL_FACTS,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
nav_items=(
|
||||
|
||||
@@ -15,6 +15,17 @@ class DistributionListsManifestTests(unittest.TestCase):
|
||||
self.assertFalse(manifest.dependencies)
|
||||
self.assertIn("addresses", manifest.optional_dependencies)
|
||||
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
|
||||
self.assertIn(
|
||||
"addresses.contact_point_resolution",
|
||||
manifest.optional_capabilities,
|
||||
)
|
||||
requirements = {item.name: item for item in manifest.requires_interfaces}
|
||||
self.assertTrue(requirements["addresses.contact_point_resolution"].optional)
|
||||
self.assertEqual(
|
||||
"1.0.0",
|
||||
requirements["addresses.contact_point_resolution"].version_min,
|
||||
)
|
||||
self.assertTrue(requirements["addresses.channel_facts"].optional)
|
||||
self.assertIn("dist_lists.expand", {interface.name for interface in manifest.provides_interfaces})
|
||||
self.assertIn("dist_lists:list:read", {permission.scope for permission in manifest.permissions})
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
|
||||
@@ -6,6 +6,12 @@ import unittest
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.concurrency import RevisionConflictError
|
||||
from govoplan_core.core.contact_points import (
|
||||
CONTACT_POINT_CONTRACT_VERSION,
|
||||
ContactPointCandidate,
|
||||
ContactPointResolution,
|
||||
ContactPointSourcePreview,
|
||||
)
|
||||
from govoplan_core.core.dataflows import (
|
||||
DataflowDatasetDescriptor,
|
||||
DataflowDatasetResult,
|
||||
@@ -14,7 +20,9 @@ from govoplan_core.core.distribution_lists import (
|
||||
DistributionChannelPolicyDecision,
|
||||
DistributionExpansionLimits,
|
||||
DistributionExpansionRequest,
|
||||
DistributionExplanation,
|
||||
DistributionListConflictError,
|
||||
DistributionSourceReference,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
@@ -151,6 +159,120 @@ class _Policy:
|
||||
)
|
||||
|
||||
|
||||
class _ContactPoints:
|
||||
def __init__(self) -> None:
|
||||
self.resolve_request = None
|
||||
self.source_requests = []
|
||||
source = DistributionSourceReference(
|
||||
provider="addresses",
|
||||
resource_type="contact",
|
||||
resource_id="contact-1",
|
||||
revision="contact-revision-1",
|
||||
fingerprint="contact-fingerprint-1",
|
||||
label="Ada Example",
|
||||
)
|
||||
self.resolution = ContactPointResolution(
|
||||
contract_version=CONTACT_POINT_CONTRACT_VERSION,
|
||||
subject=source,
|
||||
status="usable",
|
||||
contact_id="contact-1",
|
||||
display_name="Ada Example",
|
||||
candidates=(
|
||||
ContactPointCandidate(
|
||||
channel="postal",
|
||||
target="Ada Example\nMain Street 1\n10115 Berlin\nGermany",
|
||||
target_key="postal:main street 1|10115|berlin||germany",
|
||||
status="usable",
|
||||
contact_point_id="postal-1",
|
||||
address_purpose="official",
|
||||
locale="de-DE",
|
||||
preferred=True,
|
||||
preference_rank=1,
|
||||
source=source,
|
||||
source_revision="facts-revision-1",
|
||||
preference_revision="preference-revision-1",
|
||||
consent_revision="consent-revision-1",
|
||||
value={
|
||||
"street": "Main Street 1",
|
||||
"postal_code": "10115",
|
||||
"locality": "Berlin",
|
||||
"country": "Germany",
|
||||
},
|
||||
provenance={
|
||||
"rule_ids": ["rule-1"],
|
||||
"source_revision": "contact-revision-1",
|
||||
},
|
||||
),
|
||||
),
|
||||
excluded=(
|
||||
ContactPointCandidate(
|
||||
channel="email",
|
||||
target="ada@example.test",
|
||||
target_key="email:ada@example.test",
|
||||
status="suppressed",
|
||||
contact_point_id="email-1",
|
||||
reason_code="addresses.channel.opted_out",
|
||||
explanation="The contact opted out of email delivery.",
|
||||
source=source,
|
||||
source_revision="facts-revision-1",
|
||||
provenance={
|
||||
"rule_ids": ["rule-2"],
|
||||
"source_revision": "contact-revision-1",
|
||||
},
|
||||
),
|
||||
),
|
||||
explanations=(
|
||||
DistributionExplanation(
|
||||
code="addresses.channel_fact.expired",
|
||||
message="An older preference expired.",
|
||||
severity="info",
|
||||
provider="addresses",
|
||||
source=source,
|
||||
),
|
||||
),
|
||||
source_revision="facts-revision-1",
|
||||
source_fingerprint="facts-fingerprint-1",
|
||||
provenance={"active_rule_ids": ["rule-1", "rule-2"]},
|
||||
)
|
||||
|
||||
def resolve_contact_points(self, session, principal, *, request):
|
||||
del session, principal
|
||||
self.resolve_request = request
|
||||
return self.resolution
|
||||
|
||||
def preview_source(self, session, principal, *, request, offset=0, limit=100):
|
||||
del session, principal
|
||||
self.source_requests.append(request)
|
||||
resolutions = (self.resolution, self.resolution)[offset : offset + limit]
|
||||
return ContactPointSourcePreview(
|
||||
contract_version=CONTACT_POINT_CONTRACT_VERSION,
|
||||
source=DistributionSourceReference(
|
||||
provider="addresses",
|
||||
resource_type="address_list",
|
||||
resource_id="list-1",
|
||||
),
|
||||
request=request,
|
||||
resolutions=resolutions,
|
||||
total_count=2,
|
||||
usable_count=len(resolutions),
|
||||
excluded_count=len(resolutions),
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
has_more=offset + len(resolutions) < 2,
|
||||
source_revision="list-revision-1",
|
||||
source_fingerprint="list-fingerprint-1",
|
||||
generated_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
provenance={"bounded": True},
|
||||
)
|
||||
|
||||
def freeze_source(self, session, principal, *, request):
|
||||
raise AssertionError("Distribution Lists freezes its complete expansion snapshot.")
|
||||
|
||||
def get_snapshot(self, session, principal, *, snapshot_id):
|
||||
del session, principal, snapshot_id
|
||||
return None
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capabilities=None) -> None:
|
||||
self.capabilities = dict(capabilities or {})
|
||||
@@ -494,6 +616,134 @@ class DistributionListServiceTests(unittest.TestCase):
|
||||
self.assertEqual("channel.not_requested", by_channel["email"].reason_code)
|
||||
self.assertEqual("usable", by_channel["postal"].status)
|
||||
|
||||
def test_address_contact_resolution_is_purpose_aware_and_frozen(self) -> None:
|
||||
contact_points = _ContactPoints()
|
||||
registry = _Registry(
|
||||
{"addresses.contact_point_resolution": contact_points}
|
||||
)
|
||||
with self.database.session() as session:
|
||||
item, _ = create_distribution_list(
|
||||
session,
|
||||
principal(),
|
||||
DistributionListCreateRequest.model_validate(
|
||||
{
|
||||
"name": "Official postal recipients",
|
||||
"entries": [
|
||||
{
|
||||
"entry_key": "ada",
|
||||
"kind": "address_contact",
|
||||
"purpose": "official_notice",
|
||||
"requested_channels": ["postal"],
|
||||
"source": {
|
||||
"provider": "addresses",
|
||||
"resource_type": "contact",
|
||||
"resource_id": "contact-1",
|
||||
"revision": "contact-revision-1",
|
||||
},
|
||||
"configuration": {
|
||||
"address_purpose": "official",
|
||||
"fallback_rule": "none",
|
||||
"locale": "de-DE",
|
||||
"postal_format": "international",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
)
|
||||
result = expand_distribution_list(
|
||||
session,
|
||||
principal(),
|
||||
registry=registry,
|
||||
request=DistributionExpansionRequest(
|
||||
list_id=item.id,
|
||||
effective_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
requested_channels=("postal",),
|
||||
freeze=True,
|
||||
idempotency_key="official-postal-1",
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(1, len(result.recipients))
|
||||
recipient = result.recipients[0]
|
||||
self.assertEqual("contact-1", recipient.contact_id)
|
||||
self.assertEqual("postal-1", recipient.channels[0].contact_point_id)
|
||||
self.assertIn("Main Street 1", recipient.channels[0].target)
|
||||
self.assertEqual("suppressed", recipient.channels[1].status)
|
||||
self.assertEqual(
|
||||
"addresses.channel.opted_out",
|
||||
recipient.channels[1].reason_code,
|
||||
)
|
||||
self.assertEqual("official_notice", contact_points.resolve_request.purpose)
|
||||
self.assertEqual("official", contact_points.resolve_request.address_purpose)
|
||||
self.assertEqual("international", contact_points.resolve_request.postal_format)
|
||||
self.assertEqual(
|
||||
"1.0",
|
||||
recipient.provenance["contact_point_resolution"]["contract_version"],
|
||||
)
|
||||
snapshot = session.get(DistributionListSnapshot, result.snapshot_id)
|
||||
self.assertIn("Main Street 1", snapshot.recipients[0]["channels"][0]["target"])
|
||||
self.assertEqual(
|
||||
"postal-1",
|
||||
snapshot.recipients[0]["channels"][0]["contact_point_id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"facts-fingerprint-1",
|
||||
snapshot.provider_evidence[0]["actual_fingerprint"],
|
||||
)
|
||||
|
||||
def test_address_list_resolution_is_bounded_and_channel_neutral(self) -> None:
|
||||
contact_points = _ContactPoints()
|
||||
registry = _Registry(
|
||||
{"addresses.contact_point_resolution": contact_points}
|
||||
)
|
||||
with self.database.session() as session:
|
||||
item, _ = create_distribution_list(
|
||||
session,
|
||||
principal(),
|
||||
DistributionListCreateRequest.model_validate(
|
||||
{
|
||||
"name": "Address source",
|
||||
"entries": [
|
||||
{
|
||||
"entry_key": "address-list",
|
||||
"kind": "address_list",
|
||||
"source": {
|
||||
"provider": "addresses",
|
||||
"resource_type": "address_list",
|
||||
"resource_id": "addresses:address_list:list-1",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
)
|
||||
result = expand_distribution_list(
|
||||
session,
|
||||
principal(),
|
||||
registry=registry,
|
||||
request=DistributionExpansionRequest(
|
||||
list_id=item.id,
|
||||
effective_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
requested_channels=("postal",),
|
||||
limits=DistributionExpansionLimits(max_provider_results=1),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(result.recipients))
|
||||
self.assertTrue(result.truncated)
|
||||
self.assertEqual(
|
||||
"addresses:address_list:list-1",
|
||||
contact_points.source_requests[0].source_id,
|
||||
)
|
||||
self.assertEqual(("postal",), contact_points.source_requests[0].requested_channels)
|
||||
self.assertEqual("list-revision-1", result.provider_evidence[0].actual_revision)
|
||||
self.assertIn(
|
||||
"provider.result_limit",
|
||||
{item.code for item in result.diagnostics},
|
||||
)
|
||||
|
||||
|
||||
def _nested_entry(list_id: str, key: str) -> dict[str, object]:
|
||||
return {
|
||||
|
||||
@@ -930,6 +930,55 @@ function EntryEditorDialog({
|
||||
<FormField label="Effective until">
|
||||
<input type="datetime-local" value={state.effectiveUntil} onChange={(event) => onChange({ ...state, effectiveUntil: event.target.value })} />
|
||||
</FormField>
|
||||
{isAddressProviderEntry(state) ? (
|
||||
<>
|
||||
<FormField label="Address purpose" help="Selects matching contact points such as official, work, or private.">
|
||||
<input
|
||||
value={String(state.configuration.address_purpose ?? "")}
|
||||
onChange={(event) => onChange({
|
||||
...state,
|
||||
configuration: { ...state.configuration, address_purpose: event.target.value }
|
||||
})}
|
||||
placeholder="Any purpose"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Address fallback">
|
||||
<select
|
||||
value={String(state.configuration.fallback_rule ?? "primary")}
|
||||
onChange={(event) => onChange({
|
||||
...state,
|
||||
configuration: { ...state.configuration, fallback_rule: event.target.value }
|
||||
})}
|
||||
>
|
||||
<option value="none">No fallback</option>
|
||||
<option value="primary">Primary contact point</option>
|
||||
<option value="any">First available contact point</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Locale">
|
||||
<input
|
||||
value={String(state.configuration.locale ?? "")}
|
||||
onChange={(event) => onChange({
|
||||
...state,
|
||||
configuration: { ...state.configuration, locale: event.target.value }
|
||||
})}
|
||||
placeholder="For example de-DE"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Postal format">
|
||||
<select
|
||||
value={String(state.configuration.postal_format ?? "domestic")}
|
||||
onChange={(event) => onChange({
|
||||
...state,
|
||||
configuration: { ...state.configuration, postal_format: event.target.value }
|
||||
})}
|
||||
>
|
||||
<option value="domestic">Domestic</option>
|
||||
<option value="international">International</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<fieldset className="dist-lists-channel-fieldset">
|
||||
<legend>Requested channels</legend>
|
||||
@@ -1155,6 +1204,11 @@ function directKind(mode: DirectSourceMode): EntryKind {
|
||||
return mode;
|
||||
}
|
||||
|
||||
function isAddressProviderEntry(state: EntryEditorState): boolean {
|
||||
return state.sourceMode === "provider"
|
||||
&& Boolean(state.providerOption?.kind.startsWith("address_"));
|
||||
}
|
||||
|
||||
function directModeForKind(kind: EntryKind): SourceMode {
|
||||
if (kind === "raw_email") return "email";
|
||||
if (kind === "raw_postal_address") return "postal";
|
||||
|
||||
Reference in New Issue
Block a user