Add governed contact channel facts
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user