Add governed contact channel facts

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent 90a507d9a4
commit 41ccd4c807
11 changed files with 1364 additions and 14 deletions
+488 -10
View File
@@ -1,25 +1,44 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from datetime import UTC, datetime
import hashlib
import json
from typing import Any
from sqlalchemy import func
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.distribution_lists import (
DistributionChannelCandidate,
DistributionExplanation,
DistributionSourceReference,
RecipientChannelFacts,
RecipientChannelFactsRequest,
)
from govoplan_core.db.base import utcnow
from govoplan_core.core.people import (
PeopleSearchGroup,
PersonSearchCandidate,
person_selection_key,
)
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressList,
AddressListEntry,
Contact,
ContactChannelRule,
ContactEmail,
ContactPhone,
ContactPostalAddress,
)
from govoplan_addresses.backend.schemas import ContactCreateRequest
from govoplan_addresses.backend.service import (
AddressBookError,
create_contact,
get_visible_address_book,
get_visible_address_list,
get_visible_contact,
list_address_books,
list_address_list_entries,
list_address_lists,
@@ -60,6 +79,19 @@ class RecipientSnapshotItem:
provenance: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientSnapshotExcludedItem:
contact_id: str
display_name: str
channel: str
target: str
contact_point_id: str | None = None
status: str = "suppressed"
reason_code: str | None = None
explanation: str | None = None
provenance: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientSourceRef:
source_id: str
@@ -78,6 +110,9 @@ class RecipientSourceSnapshot:
source_revision: str
generated_at: str
recipients: tuple[RecipientSnapshotItem, ...]
excluded: tuple[RecipientSnapshotExcludedItem, ...] = ()
purpose: str | None = None
requested_channels: tuple[str, ...] = ()
provenance: dict[str, Any] = field(default_factory=dict)
@@ -283,13 +318,27 @@ class AddressesRecipientSourceCapability:
principal: ApiPrincipal,
*,
address_book_id: str,
purpose: str | None = None,
requested_channels: tuple[str, ...] = ("email",),
) -> RecipientSourceSnapshot:
book = get_visible_address_book(session, principal, address_book_id)
contacts = _active_address_book_contacts(session, book.id)
recipients: list[RecipientSnapshotItem] = []
for contact in contacts:
for email in contact.emails:
recipients.append(_recipient_snapshot_item(contact, email))
excluded: list[RecipientSnapshotExcludedItem] = []
if purpose is None:
for contact in contacts:
for email in contact.emails:
recipients.append(_recipient_snapshot_item(contact, email))
else:
for contact in contacts:
included_rows, excluded_rows = _governed_email_snapshot_rows(
session,
principal,
contact,
purpose=purpose,
)
recipients.extend(included_rows)
excluded.extend(excluded_rows)
revision = _recipient_source_revision(book, _address_book_contact_updated_at(session, [book.id]).get(book.id))
return RecipientSourceSnapshot(
source_id=f"addresses:address_book:{book.id}",
@@ -298,12 +347,18 @@ class AddressesRecipientSourceCapability:
source_revision=revision,
generated_at=utcnow().isoformat(),
recipients=tuple(recipients),
excluded=tuple(excluded),
purpose=purpose,
requested_channels=requested_channels,
provenance={
"module": "addresses",
"address_book_id": book.id,
"scope_type": book.scope_type,
"scope_id": book.scope_id,
"tenant_id": book.tenant_id,
"governance_applied": purpose is not None,
"included_count": len(recipients),
"excluded_count": len(excluded),
},
)
@@ -313,15 +368,31 @@ class AddressesRecipientSourceCapability:
principal: ApiPrincipal,
*,
address_list_id: str,
purpose: str | None = None,
requested_channels: tuple[str, ...] = ("email",),
) -> RecipientSourceSnapshot:
address_list = get_visible_address_list(session, principal, address_list_id)
entries = list_address_list_entries(session, principal, address_list.id)
recipients: list[RecipientSnapshotItem] = []
excluded: list[RecipientSnapshotExcludedItem] = []
for entry in entries:
email = _entry_email(entry)
if email is None:
continue
recipients.append(_recipient_snapshot_item(entry.contact, email, address_list=address_list, address_list_entry=entry))
if purpose is None:
recipients.append(_recipient_snapshot_item(entry.contact, email, address_list=address_list, address_list_entry=entry))
continue
included_rows, excluded_rows = _governed_email_snapshot_rows(
session,
principal,
entry.contact,
purpose=purpose,
email_ids={email.id},
address_list=address_list,
address_list_entry=entry,
)
recipients.extend(included_rows)
excluded.extend(excluded_rows)
revision = _address_list_source_revision(address_list, _address_list_updated_at(session, [address_list.id]).get(address_list.id))
return RecipientSourceSnapshot(
source_id=f"addresses:address_list:{address_list.id}",
@@ -330,6 +401,9 @@ class AddressesRecipientSourceCapability:
source_revision=revision,
generated_at=utcnow().isoformat(),
recipients=tuple(recipients),
excluded=tuple(excluded),
purpose=purpose,
requested_channels=requested_channels,
provenance={
"module": "addresses",
"address_book_id": address_list.address_book_id,
@@ -337,6 +411,9 @@ class AddressesRecipientSourceCapability:
"scope_type": address_list.address_book.scope_type,
"scope_id": address_list.address_book.scope_id,
"tenant_id": address_list.tenant_id,
"governance_applied": purpose is not None,
"included_count": len(recipients),
"excluded_count": len(excluded),
},
)
@@ -346,16 +423,157 @@ class AddressesRecipientSourceCapability:
principal: ApiPrincipal,
*,
source_id: str,
purpose: str | None = None,
requested_channels: tuple[str, ...] = ("email",),
) -> RecipientSourceSnapshot:
book_prefix = "addresses:address_book:"
if source_id.startswith(book_prefix):
return self.snapshot_address_book(session, principal, address_book_id=source_id.removeprefix(book_prefix))
return self.snapshot_address_book(
session,
principal,
address_book_id=source_id.removeprefix(book_prefix),
purpose=purpose,
requested_channels=requested_channels,
)
list_prefix = "addresses:address_list:"
if source_id.startswith(list_prefix):
return self.snapshot_address_list(session, principal, address_list_id=source_id.removeprefix(list_prefix))
return self.snapshot_address_list(
session,
principal,
address_list_id=source_id.removeprefix(list_prefix),
purpose=purpose,
requested_channels=requested_channels,
)
raise ValueError(f"Unsupported addresses recipient source id: {source_id}")
class AddressesChannelFactsCapability:
"""Resolve effective contact-point facts without making a Policy decision."""
def resolve_channel_facts(
self,
session: Any,
principal: ApiPrincipal,
*,
request: RecipientChannelFactsRequest,
) -> RecipientChannelFacts:
if request.tenant_id != principal.tenant_id:
raise PermissionError("Channel facts cannot be resolved across tenants.")
contact_id = _channel_facts_contact_id(request.source)
contact = get_visible_contact(session, principal, contact_id)
effective_at = _aware_datetime(request.effective_at)
active_rules = [
rule
for rule in contact.channel_rules
if _rule_matches_purpose(rule, request.purpose)
and _rule_is_effective(rule, effective_at)
]
expired_rules = [
rule
for rule in contact.channel_rules
if _rule_matches_purpose(rule, request.purpose)
and rule.effective_until is not None
and _aware_datetime(rule.effective_until) <= effective_at
]
revision, fingerprint = _contact_channel_revision(contact)
source = DistributionSourceReference(
provider="addresses",
resource_type="contact",
resource_id=contact.id,
revision=revision,
fingerprint=fingerprint,
label=contact.display_name,
metadata={
"address_book_id": contact.address_book_id,
"source_kind": contact.source_kind,
"source_ref": contact.source_ref,
},
)
requested_channels = set(request.requested_channels)
candidates: list[DistributionChannelCandidate] = []
for channel, point_id, target, target_key, intrinsically_valid in _contact_channel_points(contact):
if requested_channels and channel not in requested_channels:
continue
matching = [
rule
for rule in active_rules
if rule.channel == channel
and (rule.contact_point_id is None or rule.contact_point_id == point_id)
]
selected = max(matching, key=_channel_rule_priority, default=None)
state = selected.decision if selected is not None else "unknown"
status = _channel_candidate_status(state, intrinsically_valid=intrinsically_valid)
reason_code = (
f"addresses.channel.{state}"
if intrinsically_valid
else f"addresses.{channel}.invalid"
)
explanation = (
_channel_rule_explanation(selected)
if selected is not None
else (
f"The {channel.replace('_', ' ')} contact point has no applicable governance fact."
if intrinsically_valid
else f"The {channel.replace('_', ' ')} contact point is incomplete or invalid."
)
)
candidates.append(
DistributionChannelCandidate(
channel=channel,
target=target,
target_key=target_key,
status=status,
contact_point_id=point_id,
locale=selected.locale if selected is not None else None,
preferred=any(rule.decision == "preferred" for rule in matching),
reason_code=reason_code,
explanation=explanation,
source=source,
decision_provenance={
"provider": "addresses",
"governance_state": state,
"effective_at": effective_at.isoformat(),
"purpose": request.purpose,
"selected_rule_id": selected.id if selected is not None else None,
"rule_ids": [rule.id for rule in matching],
"legal_basis": selected.legal_basis if selected is not None else None,
"evidence_ref": selected.evidence_ref if selected is not None else None,
"preference_rank": selected.preference_rank if selected is not None else None,
},
)
)
explanations = tuple(
DistributionExplanation(
code="addresses.channel_fact.expired",
message=(
f"A {rule.channel.replace('_', ' ')} governance fact expired and was not applied."
),
severity="info",
provider="addresses",
source=source,
provenance={"rule_id": rule.id, "decision": rule.decision},
)
for rule in expired_rules
)
return RecipientChannelFacts(
candidates=tuple(candidates),
explanations=explanations,
source_revision=revision,
source_fingerprint=fingerprint,
provenance={
"module": "addresses",
"contact_id": contact.id,
"address_book_id": contact.address_book_id,
"effective_at": effective_at.isoformat(),
"purpose": request.purpose,
"active_rule_ids": [rule.id for rule in active_rules],
"expired_rule_ids": [rule.id for rule in expired_rules],
"source_revision": revision,
"source_fingerprint": fingerprint,
},
)
def lookup_capability(_context: Any) -> AddressesLookupCapability:
return AddressesLookupCapability()
@@ -368,6 +586,10 @@ def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapabi
return AddressesRecipientSourceCapability()
def channel_facts_capability(_context: Any) -> AddressesChannelFactsCapability:
return AddressesChannelFactsCapability()
def contact_writer_capability(_context: Any) -> AddressesContactWriterCapability:
return AddressesContactWriterCapability()
@@ -484,6 +706,7 @@ def _recipient_snapshot_item(
*,
address_list: AddressList | None = None,
address_list_entry: AddressListEntry | None = None,
channel_decision: dict[str, Any] | None = None,
) -> RecipientSnapshotItem:
primary_phone = next((phone.phone for phone in contact.phones if phone.is_primary), contact.phones[0].phone if contact.phones else None)
provenance = {
@@ -501,6 +724,8 @@ def _recipient_snapshot_item(
"address_list_entry_kind": address_list_entry.target_kind,
}
)
if channel_decision is not None:
provenance["channel_decision"] = dict(channel_decision)
return RecipientSnapshotItem(
contact_id=contact.id,
display_name=contact.display_name,
@@ -518,6 +743,91 @@ def _recipient_snapshot_item(
)
def _governed_email_snapshot_rows(
session: Any,
principal: ApiPrincipal,
contact: Contact,
*,
purpose: str,
email_ids: set[str] | None = None,
address_list: AddressList | None = None,
address_list_entry: AddressListEntry | None = None,
) -> tuple[list[RecipientSnapshotItem], list[RecipientSnapshotExcludedItem]]:
effective_at = utcnow()
facts = AddressesChannelFactsCapability().resolve_channel_facts(
session,
principal,
request=RecipientChannelFactsRequest(
tenant_id=principal.tenant_id,
source=DistributionSourceReference(
provider="addresses",
resource_type="contact",
resource_id=contact.id,
),
recipient_key=f"contact:{contact.id}",
effective_at=effective_at,
purpose=purpose,
requested_channels=("email",),
),
)
emails_by_id = {item.id: item for item in contact.emails}
included: list[RecipientSnapshotItem] = []
excluded: list[RecipientSnapshotExcludedItem] = []
for candidate in facts.candidates:
if candidate.channel != "email":
continue
point_id = candidate.contact_point_id
if email_ids is not None and point_id not in email_ids:
continue
email = emails_by_id.get(point_id or "")
if email is None:
continue
decision = {
**dict(candidate.decision_provenance),
"status": candidate.status,
"reason_code": candidate.reason_code,
"explanation": candidate.explanation,
"source_revision": facts.source_revision,
"source_fingerprint": facts.source_fingerprint,
}
if candidate.status in {"usable", "stale"}:
included.append(
_recipient_snapshot_item(
contact,
email,
address_list=address_list,
address_list_entry=address_list_entry,
channel_decision=decision,
)
)
else:
provenance = {
**_contact_provenance(contact),
"channel_decision": decision,
}
if address_list is not None and address_list_entry is not None:
provenance.update(
{
"address_list_id": address_list.id,
"address_list_entry_id": address_list_entry.id,
}
)
excluded.append(
RecipientSnapshotExcludedItem(
contact_id=contact.id,
display_name=contact.display_name,
channel="email",
target=email.email,
contact_point_id=email.id,
status=candidate.status,
reason_code=candidate.reason_code,
explanation=candidate.explanation,
provenance=provenance,
)
)
return included, excluded
def _contact_provenance(contact: Contact) -> dict[str, Any]:
return {
"module": "addresses",
@@ -638,7 +948,21 @@ def _address_book_contact_updated_at(session: Any, address_book_ids: list[str])
.group_by(Contact.address_book_id)
.all()
)
for address_book_id, updated_at in [*contact_rows, *deletion_rows, *email_rows, *phone_rows]:
postal_rows = (
session.query(Contact.address_book_id, func.max(ContactPostalAddress.updated_at))
.join(Contact, ContactPostalAddress.contact_id == Contact.id)
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_(None))
.group_by(Contact.address_book_id)
.all()
)
rule_rows = (
session.query(Contact.address_book_id, func.max(ContactChannelRule.updated_at))
.join(Contact, ContactChannelRule.contact_id == Contact.id)
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_(None))
.group_by(Contact.address_book_id)
.all()
)
for address_book_id, updated_at in [*contact_rows, *deletion_rows, *email_rows, *phone_rows, *postal_rows, *rule_rows]:
if updated_at is None:
continue
key = str(address_book_id)
@@ -670,7 +994,14 @@ def _address_list_updated_at(session: Any, address_list_ids: list[str]) -> dict[
.group_by(AddressListEntry.address_list_id)
.all()
)
for address_list_id, updated_at in [*entry_rows, *contact_rows, *email_rows]:
rule_rows = (
session.query(AddressListEntry.address_list_id, func.max(ContactChannelRule.updated_at))
.join(ContactChannelRule, AddressListEntry.contact_id == ContactChannelRule.contact_id)
.filter(AddressListEntry.address_list_id.in_(address_list_ids))
.group_by(AddressListEntry.address_list_id)
.all()
)
for address_list_id, updated_at in [*entry_rows, *contact_rows, *email_rows, *rule_rows]:
if updated_at is None:
continue
key = str(address_list_id)
@@ -690,3 +1021,150 @@ def _address_list_source_revision(address_list: AddressList, latest_entry_update
if latest_entry_updated_at is not None:
stamps.append(latest_entry_updated_at)
return max(stamps).isoformat()
def _channel_facts_contact_id(source: DistributionSourceReference) -> str:
metadata_contact_id = source.metadata.get("contact_id")
if metadata_contact_id:
return str(metadata_contact_id)
if source.resource_type not in {"contact", "address_contact", "address_email"}:
raise ValueError(f"Unsupported Addresses channel-facts source: {source.resource_type}.")
return source.resource_id
def _contact_channel_points(
contact: Contact,
) -> tuple[tuple[str, str | None, str, str, bool], ...]:
points: list[tuple[str, str | None, str, str, bool]] = []
for email in contact.emails:
target = email.email.strip()
valid = bool(target and "@" in target and len(target) <= 320)
points.append(("email", email.id, target, f"email:{target.casefold()}", valid))
for address in contact.postal_addresses:
target = ", ".join(
item
for item in (
address.street,
" ".join(part for part in (address.postal_code, address.locality) if part),
address.region,
address.country,
)
if item
)
points.append(("postal", address.id, target, f"postal:{' '.join(target.casefold().split())}", bool(target)))
provenance = dict(contact.provenance or {})
internal_target = provenance.get("internal_mail_account_id") or provenance.get("account_id")
if internal_target:
target = str(internal_target).strip()
points.append(("internal_mail", None, target, f"internal_mail:{target.casefold()}", bool(target)))
portal_target = provenance.get("portal_target") or provenance.get("portal_account_id")
if portal_target:
target = str(portal_target).strip()
points.append(("portal", None, target, f"portal:{target.casefold()}", bool(target)))
return tuple(points)
def _rule_matches_purpose(rule: ContactChannelRule, purpose: str | None) -> bool:
return rule.purpose is None or (purpose is not None and rule.purpose == purpose)
def _rule_is_effective(rule: ContactChannelRule, effective_at: datetime) -> bool:
return (
(rule.effective_from is None or _aware_datetime(rule.effective_from) <= effective_at)
and (rule.effective_until is None or _aware_datetime(rule.effective_until) > effective_at)
)
def _channel_rule_priority(rule: ContactChannelRule) -> tuple[int, int, int, datetime]:
decision_priority = {
"invalid": 90,
"returned": 80,
"suppressed": 70,
"opted_out": 60,
"temporarily_unavailable": 50,
"preferred": 30,
"opted_in": 20,
"allowed": 10,
}.get(rule.decision, 0)
return (
decision_priority,
int(rule.contact_point_id is not None),
int(rule.purpose is not None),
_aware_datetime(rule.updated_at),
)
def _channel_candidate_status(state: str, *, intrinsically_valid: bool) -> str:
if not intrinsically_valid or state in {"invalid", "returned"}:
return "invalid"
if state in {"suppressed", "opted_out", "temporarily_unavailable"}:
return "suppressed"
return "usable"
def _channel_rule_explanation(rule: ContactChannelRule) -> str:
if rule.reason:
return rule.reason
return {
"allowed": "Use of this contact point is explicitly allowed.",
"opted_in": "The contact opted in to this communication channel.",
"preferred": "This is a preferred communication channel.",
"opted_out": "The contact opted out of this communication channel.",
"suppressed": "This contact point is suppressed.",
"invalid": "This contact point is marked invalid.",
"returned": "Delivery to this contact point was returned.",
"temporarily_unavailable": "This contact point is temporarily unavailable.",
}.get(rule.decision, "An Addresses governance fact was applied.")
def _contact_channel_revision(contact: Contact) -> tuple[str, str]:
stamps = [contact.updated_at]
stamps.extend(item.updated_at for item in (*contact.emails, *contact.postal_addresses, *contact.channel_rules))
revision = max(_aware_datetime(item) for item in stamps).isoformat()
payload = {
"contact_id": contact.id,
"revision": revision,
"emails": [
{"id": item.id, "email": item.email, "primary": item.is_primary}
for item in contact.emails
],
"postal": [
{
"id": item.id,
"street": item.street,
"postal_code": item.postal_code,
"locality": item.locality,
"region": item.region,
"country": item.country,
"primary": item.is_primary,
}
for item in contact.postal_addresses
],
"rules": [
{
"id": rule.id,
"channel": rule.channel,
"purpose": rule.purpose,
"point": rule.contact_point_id,
"decision": rule.decision,
"basis": rule.legal_basis,
"evidence": rule.evidence_ref,
"rank": rule.preference_rank,
"locale": rule.locale,
"from": rule.effective_from.isoformat() if rule.effective_from else None,
"until": rule.effective_until.isoformat() if rule.effective_until else None,
"updated": rule.updated_at.isoformat(),
}
for rule in sorted(contact.channel_rules, key=lambda item: item.id)
],
}
fingerprint = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
return revision, fingerprint
def _aware_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value