Integrate governed address contact resolution

This commit is contained in:
2026-08-02 07:24:29 +02:00
parent 7b0c9cfdde
commit 8099e34c6c
9 changed files with 774 additions and 12 deletions
+3 -1
View File
@@ -22,7 +22,9 @@ Distribution Lists owns:
- German-administration `Verteiler` semantics - German-administration `Verteiler` semantics
- static, parameterized, and Dataflow-backed dynamic audience definitions - static, parameterized, and Dataflow-backed dynamic audience definitions
- expansion of mixed entries into concrete delivery/recipient targets - 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 - stale-source detection for expanded entries
- eligibility and policy explanations for disabled recipient entries - eligibility and policy explanations for disabled recipient entries
- audit evidence for who was resolved at execution time - audit evidence for who was resolved at execution time
+5 -5
View File
@@ -180,7 +180,7 @@ typed output template, and Report/Campaign run configuration.
| Multiple email, phone, and postal contact points | Implemented | Addresses | | Multiple email, phone, and postal contact points | Implemented | Addresses |
| Static address lists | Implemented | Addresses | | Static address lists | Implemented | Addresses |
| vCard import/export and CardDAV sync | 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 | | Source provenance and sync conflicts | Implemented | Addresses |
| Organization units and function definitions | Implemented | Organizations | | Organization units and function definitions | Implemented | Organizations |
| Effective-dated identity-to-function assignments | Implemented first slice | IDM | | 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 | | Campaign snapshots, review, digital delivery, and evidence | Implemented first channel | Campaign |
| Typed template library and document rendering | Scaffold/issue only | Templates | | Typed template library and document rendering | Scaffold/issue only | Templates |
| BI reports and spreadsheet-grade exports | Scaffold/issue only | Reporting | | BI reports and spreadsheet-grade exports | Scaffold/issue only | Reporting |
| Mixed operational distribution lists and governed segments | Contract/implementation issues only | Distribution Lists | | Mixed operational distribution lists and governed segments | Implemented broad first slice | Distribution Lists |
| Contact-point consent, suppression, and preferences | Open issue | Addresses/Policy | | Contact-point consent, suppression, and preferences | Implemented | Addresses/Policy |
| Deduplication, merge, quality, and complete history | Open issue | Addresses | | Deduplication, merge, quality, and complete history | Implemented first slice | Addresses |
| Channel-neutral expansion and frozen postal targets | Missing | Distribution Lists/Addresses | | Channel-neutral expansion and frozen postal targets | Implemented | Distribution Lists/Addresses |
| Printable label, envelope, and letter output | Open issue | Templates | | Printable label, envelope, and letter output | Open issue | Templates |
| End-to-end guided AdreMa workflow | Missing | Workflow plus consuming modules | | End-to-end guided AdreMa workflow | Missing | Workflow plus consuming modules |
+13 -3
View File
@@ -83,6 +83,16 @@ The first capabilities should be:
- `dist_lists.expand`: expand a distribution list into immutable recipient rows. - `dist_lists.expand`: expand a distribution list into immutable recipient rows.
- `dist_lists.writer`: explain whether a caller can create or change a list. - `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 Consumers must resolve those capabilities through core and must not import
distribution-list ORM or service internals. distribution-list ORM or service internals.
@@ -98,9 +108,9 @@ Likely consumers:
## Relationship To Address Lists ## Relationship To Address Lists
`govoplan-addresses` should implement classical address lists first. A `govoplan-addresses` implements classical address lists. A distribution list
distribution list may later include an address list as an entry, but the may include an address list as an entry, but the address module remains the
address module remains the owner of contact records and address-only grouping. owner of contact records and address-only grouping.
The distribution-list module owns mixed operational routing. This prevents The distribution-list module owns mixed operational routing. This prevents
campaign-specific recipient logic from becoming the platform's de facto campaign-specific recipient logic from becoming the platform's de facto
+5 -1
View File
@@ -44,13 +44,17 @@ Goal: allow distribution lists to resolve through installed modules.
Tasks: 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 - [x] integrate with IDM identity and effective function-assignment resolution
- [ ] resolve typed IDM groups once `govoplan-idm#7` exposes the provider contract; - [ ] resolve typed IDM groups once `govoplan-idm#7` exposes the provider contract;
until then the selector and expansion return an explicit unavailable outcome until then the selector and expansion return an explicit unavailable outcome
- [x] integrate with Organizations unit/function resolution - [x] integrate with Organizations unit/function resolution
- [x] consume governed Datasource/Connector states through pinned Dataflow outputs - [x] consume governed Datasource/Connector states through pinned Dataflow outputs
- [x] apply Addresses preference/consent facts and explainable Policy decisions - [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 - [x] preserve module independence when optional providers are absent
## Milestone 4: UI ## Milestone 4: UI
+396 -2
View File
@@ -16,6 +16,14 @@ from govoplan_core.core.dataflows import (
DataflowDatasetRequest, DataflowDatasetRequest,
dataflow_dataset_output, 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 ( from govoplan_core.core.distribution_lists import (
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
CAPABILITY_RECIPIENT_CHANNEL_FACTS, CAPABILITY_RECIPIENT_CHANNEL_FACTS,
@@ -566,6 +574,199 @@ def _nested_list_recipients(
def _address_recipients( def _address_recipients(
context: _ExpansionContext, context: _ExpansionContext,
entry: DistributionListEntryRef, 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]: ) -> list[DistributionRecipientRef]:
if entry.kind == "address_list": if entry.kind == "address_list":
provider = _capability(context.registry, ADDRESSES_RECIPIENT_SOURCE) provider = _capability(context.registry, ADDRESSES_RECIPIENT_SOURCE)
@@ -631,6 +832,194 @@ def _address_recipients(
return [_address_lookup_recipient(entry, item) for item in exact] 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( def _address_snapshot_recipient(
entry: DistributionListEntryRef, entry: DistributionListEntryRef,
item: object, item: object,
@@ -1063,7 +1452,11 @@ def _apply_channel_decisions(
CAPABILITY_RECIPIENT_CHANNEL_FACTS, CAPABILITY_RECIPIENT_CHANNEL_FACTS,
RecipientChannelFactsProvider, 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 source = channels[0].source if channels else None
if source is not None: if source is not None:
facts = facts_provider.resolve_channel_facts( facts = facts_provider.resolve_channel_facts(
@@ -1097,12 +1490,13 @@ def _apply_channel_decisions(
else set() else set()
) )
requested_by_caller = set(context.request.requested_channels) requested_by_caller = set(context.request.requested_channels)
has_channel_constraint = bool(requested_by_entry or requested_by_caller)
requested_channels = ( requested_channels = (
requested_by_entry.intersection(requested_by_caller) requested_by_entry.intersection(requested_by_caller)
if requested_by_entry and requested_by_caller if requested_by_entry and requested_by_caller
else requested_by_entry or requested_by_caller else requested_by_entry or requested_by_caller
) )
if requested_channels: if has_channel_constraint:
channels = [ channels = [
item item
if item.channel in requested_channels if item.channel in requested_channels
@@ -7,6 +7,9 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
) )
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_DATASET_OUTPUT 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 ( from govoplan_core.core.distribution_lists import (
CAPABILITY_DISTRIBUTION_LIST_EXPAND, CAPABILITY_DISTRIBUTION_LIST_EXPAND,
CAPABILITY_DISTRIBUTION_LIST_SOURCE, CAPABILITY_DISTRIBUTION_LIST_SOURCE,
@@ -30,6 +33,7 @@ from govoplan_core.core.modules import (
MigrationSpec, MigrationSpec,
ModuleContext, ModuleContext,
ModuleInterfaceProvider, ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
@@ -95,6 +99,24 @@ DOCUMENTATION = (
related_modules=("addresses", "campaigns", "mail", "postbox", "notifications", "scheduling", "poll", "workflow_engine", "tasks"), related_modules=("addresses", "campaigns", "mail", "postbox", "notifications", "scheduling", "poll", "workflow_engine", "tasks"),
metadata={"seed": True}, 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_IDM_FUNCTION_ASSIGNMENTS,
CAPABILITY_ORGANIZATION_DIRECTORY, CAPABILITY_ORGANIZATION_DIRECTORY,
CAPABILITY_DATAFLOW_DATASET_OUTPUT, CAPABILITY_DATAFLOW_DATASET_OUTPUT,
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
CAPABILITY_RECIPIENT_CHANNEL_FACTS, CAPABILITY_RECIPIENT_CHANNEL_FACTS,
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
), ),
@@ -179,6 +202,20 @@ manifest = ModuleManifest(
version=MODULE_VERSION, 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, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
nav_items=( nav_items=(
+11
View File
@@ -15,6 +15,17 @@ class DistributionListsManifestTests(unittest.TestCase):
self.assertFalse(manifest.dependencies) self.assertFalse(manifest.dependencies)
self.assertIn("addresses", manifest.optional_dependencies) self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("auth.principalResolver", manifest.optional_capabilities) 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.expand", {interface.name for interface in manifest.provides_interfaces})
self.assertIn("dist_lists:list:read", {permission.scope for permission in manifest.permissions}) self.assertIn("dist_lists:list:read", {permission.scope for permission in manifest.permissions})
self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.route_factory)
+250
View File
@@ -6,6 +6,12 @@ import unittest
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.concurrency import RevisionConflictError 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 ( from govoplan_core.core.dataflows import (
DataflowDatasetDescriptor, DataflowDatasetDescriptor,
DataflowDatasetResult, DataflowDatasetResult,
@@ -14,7 +20,9 @@ from govoplan_core.core.distribution_lists import (
DistributionChannelPolicyDecision, DistributionChannelPolicyDecision,
DistributionExpansionLimits, DistributionExpansionLimits,
DistributionExpansionRequest, DistributionExpansionRequest,
DistributionExplanation,
DistributionListConflictError, DistributionListConflictError,
DistributionSourceReference,
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database 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: class _Registry:
def __init__(self, capabilities=None) -> None: def __init__(self, capabilities=None) -> None:
self.capabilities = dict(capabilities or {}) 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("channel.not_requested", by_channel["email"].reason_code)
self.assertEqual("usable", by_channel["postal"].status) 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]: def _nested_entry(list_id: str, key: str) -> dict[str, object]:
return { return {
@@ -930,6 +930,55 @@ function EntryEditorDialog({
<FormField label="Effective until"> <FormField label="Effective until">
<input type="datetime-local" value={state.effectiveUntil} onChange={(event) => onChange({ ...state, effectiveUntil: event.target.value })} /> <input type="datetime-local" value={state.effectiveUntil} onChange={(event) => onChange({ ...state, effectiveUntil: event.target.value })} />
</FormField> </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> </div>
<fieldset className="dist-lists-channel-fieldset"> <fieldset className="dist-lists-channel-fieldset">
<legend>Requested channels</legend> <legend>Requested channels</legend>
@@ -1155,6 +1204,11 @@ function directKind(mode: DirectSourceMode): EntryKind {
return mode; return mode;
} }
function isAddressProviderEntry(state: EntryEditorState): boolean {
return state.sourceMode === "provider"
&& Boolean(state.providerOption?.kind.startsWith("address_"));
}
function directModeForKind(kind: EntryKind): SourceMode { function directModeForKind(kind: EntryKind): SourceMode {
if (kind === "raw_email") return "email"; if (kind === "raw_email") return "email";
if (kind === "raw_postal_address") return "postal"; if (kind === "raw_postal_address") return "postal";