Integrate governed address contact resolution
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user