2238 lines
82 KiB
Python
2238 lines
82 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass, field, replace
|
|
from datetime import UTC, datetime
|
|
import hashlib
|
|
import json
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, or_
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from govoplan_core.auth import ApiPrincipal
|
|
from govoplan_core.core.distribution_lists import (
|
|
DistributionChannelCandidate,
|
|
DistributionExplanation,
|
|
DistributionSourceReference,
|
|
RecipientChannelFacts,
|
|
RecipientChannelFactsRequest,
|
|
)
|
|
from govoplan_core.core.contact_points import (
|
|
CONTACT_POINT_CONTRACT_VERSION,
|
|
ContactPointCandidate,
|
|
ContactPointResolution,
|
|
ContactPointResolutionProvider,
|
|
ContactPointResolutionRequest,
|
|
ContactPointSnapshotRef,
|
|
ContactPointSourcePreview,
|
|
ContactPointSourceRequest,
|
|
)
|
|
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,
|
|
ContactChannelRule,
|
|
ContactEmail,
|
|
ContactPhone,
|
|
ContactPointQualityDecision,
|
|
ContactPointSnapshot,
|
|
ContactPostalAddress,
|
|
)
|
|
from govoplan_addresses.backend.schemas import ContactCreateRequest
|
|
from govoplan_addresses.backend.service import (
|
|
AddressBookError,
|
|
create_contact,
|
|
current_contact_quality,
|
|
get_visible_address_book,
|
|
get_visible_address_list,
|
|
get_visible_contact,
|
|
list_address_books,
|
|
list_address_list_entries,
|
|
list_address_lists,
|
|
list_contacts,
|
|
resolve_contact_redirect,
|
|
)
|
|
|
|
|
|
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
|
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
|
CAPABILITY_ADDRESSES_CONTACT_WRITER = "addresses.contact_writer"
|
|
|
|
CONTACT_WRITE_OPERATIONS = ("create_contact", "update_contact", "delete_contact")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AddressLookupCandidate:
|
|
contact_id: str
|
|
address_book_id: str
|
|
display_name: str
|
|
email: str | None
|
|
email_label: str | None = None
|
|
organization: str | None = None
|
|
role_title: str | None = None
|
|
tags: tuple[str, ...] = ()
|
|
source_kind: str = "local"
|
|
source_ref: str | None = None
|
|
source_revision: str | None = None
|
|
provenance: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RecipientSnapshotItem:
|
|
contact_id: str
|
|
display_name: str
|
|
email: str
|
|
email_label: str | None = None
|
|
fields: dict[str, Any] = field(default_factory=dict)
|
|
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
|
|
source_label: str
|
|
source_kind: str
|
|
source_revision: str
|
|
recipient_count: int = 0
|
|
provenance: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RecipientSourceSnapshot:
|
|
source_id: str
|
|
source_label: str
|
|
source_kind: str
|
|
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)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AddressBookWriteDecision:
|
|
address_book_id: str
|
|
address_book_label: str | None
|
|
operation: str
|
|
allowed: bool
|
|
reason: str
|
|
message: str
|
|
scope_type: str | None = None
|
|
scope_id: str | None = None
|
|
tenant_id: str | None = None
|
|
source_kind: str | None = None
|
|
read_only: bool = False
|
|
required_scopes: tuple[str, ...] = ()
|
|
provenance: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ContactWriteResult:
|
|
contact_id: str
|
|
address_book_id: str
|
|
display_name: str
|
|
email: str | None = None
|
|
source_kind: str = "local"
|
|
provenance: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
class AddressWriterError(ValueError):
|
|
def __init__(self, decision: AddressBookWriteDecision):
|
|
super().__init__(decision.message)
|
|
self.decision = decision
|
|
|
|
|
|
class AddressesLookupCapability:
|
|
def lookup(
|
|
self,
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
query: str,
|
|
limit: int = 25,
|
|
address_book_id: str | None = None,
|
|
) -> tuple[AddressLookupCandidate, ...]:
|
|
normalized_limit = max(1, min(limit, 100))
|
|
contacts = list_contacts(
|
|
session,
|
|
principal,
|
|
address_book_id=address_book_id,
|
|
query=query,
|
|
limit=normalized_limit,
|
|
)
|
|
candidates: list[AddressLookupCandidate] = []
|
|
for contact in contacts:
|
|
email_rows: list[ContactEmail | None] = list(contact.emails) or [None]
|
|
for email in email_rows:
|
|
candidates.append(_lookup_candidate(contact, email))
|
|
if len(candidates) >= normalized_limit:
|
|
return tuple(candidates)
|
|
return tuple(candidates)
|
|
|
|
|
|
class AddressesPeopleSearchProvider:
|
|
"""Adapt visible address-book contacts to the shared people-search contract."""
|
|
|
|
def __init__(self, lookup: AddressesLookupCapability | None = None) -> None:
|
|
self._lookup = lookup or AddressesLookupCapability()
|
|
|
|
def search_people(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
query: str,
|
|
limit: int = 25,
|
|
) -> tuple[PeopleSearchGroup, ...]:
|
|
if not hasattr(principal, "account_id") or not hasattr(principal, "tenant_id") or not hasattr(principal, "group_ids"):
|
|
raise AddressBookError("Address contact search requires an authenticated tenant principal.")
|
|
candidates = self._lookup.lookup(
|
|
session,
|
|
principal, # type: ignore[arg-type] - validated principal contract
|
|
query=query,
|
|
limit=limit,
|
|
)
|
|
return (
|
|
PeopleSearchGroup(
|
|
key="contacts",
|
|
label="Contacts",
|
|
candidates=tuple(
|
|
PersonSearchCandidate(
|
|
selection_key=person_selection_key("contact", item.contact_id, email=item.email),
|
|
kind="contact",
|
|
reference_id=item.contact_id,
|
|
display_name=item.display_name,
|
|
email=item.email,
|
|
source_module="addresses",
|
|
source_label="Contacts",
|
|
source_ref=item.source_ref or f"addresses:contact:{item.contact_id}",
|
|
source_revision=item.source_revision,
|
|
description=" · ".join(part for part in (item.organization, item.role_title) if part) or None,
|
|
provenance=dict(item.provenance),
|
|
metadata={
|
|
"address_book_id": item.address_book_id,
|
|
"email_label": item.email_label,
|
|
"organization": item.organization,
|
|
"role_title": item.role_title,
|
|
"tags": tuple(item.tags),
|
|
"source_kind": item.source_kind,
|
|
},
|
|
)
|
|
for item in candidates
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
class AddressesContactWriterCapability:
|
|
def list_write_targets(
|
|
self,
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
operation: str = "create_contact",
|
|
) -> tuple[AddressBookWriteDecision, ...]:
|
|
books = list_address_books(session, principal)
|
|
return tuple(_address_book_write_decision(principal, book, operation=operation) for book in books)
|
|
|
|
def can_write_to_address_book(
|
|
self,
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
address_book_id: str,
|
|
operation: str = "create_contact",
|
|
) -> AddressBookWriteDecision:
|
|
try:
|
|
book = get_visible_address_book(session, principal, address_book_id, include_deleted=True)
|
|
except AddressBookError:
|
|
return _blocked_write_decision(
|
|
address_book_id=address_book_id,
|
|
operation=operation,
|
|
reason="not_visible",
|
|
message="Address book is not visible to the current principal.",
|
|
)
|
|
return _address_book_write_decision(principal, book, operation=operation)
|
|
|
|
def create_contact(
|
|
self,
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
address_book_id: str,
|
|
payload: ContactCreateRequest | dict[str, Any],
|
|
provenance: dict[str, Any] | None = None,
|
|
) -> ContactWriteResult:
|
|
decision = self.can_write_to_address_book(session, principal, address_book_id=address_book_id, operation="create_contact")
|
|
if not decision.allowed:
|
|
raise AddressWriterError(decision)
|
|
request = payload if isinstance(payload, ContactCreateRequest) else ContactCreateRequest.model_validate(payload)
|
|
if provenance:
|
|
request = request.model_copy(update={"provenance": {**request.provenance, **provenance}})
|
|
contact = create_contact(session, principal, address_book_id, request)
|
|
return _contact_write_result(contact)
|
|
|
|
|
|
class AddressesRecipientSourceCapability:
|
|
def list_sources(
|
|
self,
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
) -> tuple[RecipientSourceRef, ...]:
|
|
from govoplan_addresses.backend.service import address_book_contact_counts, address_list_entry_counts
|
|
|
|
books = list_address_books(session, principal)
|
|
counts = address_book_contact_counts(session, [book.id for book in books])
|
|
contact_updated_at = _address_book_contact_updated_at(session, [book.id for book in books])
|
|
book_sources = tuple(
|
|
_recipient_source_ref(
|
|
book,
|
|
recipient_count=counts.get(book.id, 0),
|
|
latest_contact_updated_at=contact_updated_at.get(book.id),
|
|
)
|
|
for book in books
|
|
)
|
|
address_lists = list_address_lists(session, principal)
|
|
address_list_counts = address_list_entry_counts(session, [address_list.id for address_list in address_lists])
|
|
address_list_updated_at = _address_list_updated_at(session, [address_list.id for address_list in address_lists])
|
|
list_sources = tuple(
|
|
_address_list_source_ref(
|
|
address_list,
|
|
recipient_count=address_list_counts.get(address_list.id, 0),
|
|
latest_entry_updated_at=address_list_updated_at.get(address_list.id),
|
|
)
|
|
for address_list in address_lists
|
|
)
|
|
return book_sources + list_sources
|
|
|
|
def snapshot_address_book(
|
|
self,
|
|
session: Any,
|
|
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] = []
|
|
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}",
|
|
source_label=book.name,
|
|
source_kind=book.source_kind,
|
|
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),
|
|
},
|
|
)
|
|
|
|
def snapshot_address_list(
|
|
self,
|
|
session: Any,
|
|
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
|
|
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}",
|
|
source_label=address_list.name,
|
|
source_kind="address_list",
|
|
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,
|
|
"address_list_id": address_list.id,
|
|
"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),
|
|
},
|
|
)
|
|
|
|
def snapshot(
|
|
self,
|
|
session: Any,
|
|
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),
|
|
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),
|
|
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)
|
|
quality = current_contact_quality(contact, effective_at=effective_at)
|
|
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."
|
|
)
|
|
)
|
|
quality_decision = quality.get((channel, point_id)) or quality.get(
|
|
(channel, None)
|
|
)
|
|
if quality_decision is not None and quality_decision.state != "valid":
|
|
status = (
|
|
"stale"
|
|
if quality_decision.state == "stale"
|
|
else "invalid"
|
|
)
|
|
reason_code = quality_decision.reason_code
|
|
explanation = quality_decision.reason or (
|
|
f"This {channel.replace('_', ' ')} contact point is marked "
|
|
f"{quality_decision.state}."
|
|
)
|
|
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,
|
|
"quality_decision_id": (
|
|
quality_decision.id if quality_decision is not None else None
|
|
),
|
|
"quality_state": (
|
|
quality_decision.state if quality_decision is not None else "valid"
|
|
),
|
|
"quality_evidence_ref": (
|
|
quality_decision.evidence_ref
|
|
if quality_decision 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,
|
|
},
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _SourceContactContext:
|
|
contact: Contact
|
|
allowed_contact_point_ids: frozenset[str] | None = None
|
|
address_list_entry_ids: tuple[str, ...] = ()
|
|
|
|
|
|
class AddressesContactPointResolutionCapability(ContactPointResolutionProvider):
|
|
"""Resolve and freeze channel-neutral contact points without policy imports."""
|
|
|
|
def resolve_contact_points(
|
|
self,
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
request: ContactPointResolutionRequest,
|
|
) -> ContactPointResolution:
|
|
self._assert_tenant(principal, request.tenant_id)
|
|
contacts = _contacts_for_subject(session, principal, request.subject)
|
|
if not contacts:
|
|
return _unresolved_contact_point_resolution(
|
|
request,
|
|
code="addresses.subject.unresolved",
|
|
message="No visible Addresses contact is linked to this subject reference.",
|
|
)
|
|
if len(contacts) > 1:
|
|
return _unresolved_contact_point_resolution(
|
|
request,
|
|
code="addresses.subject.ambiguous",
|
|
message="More than one visible Addresses contact is linked to this subject reference.",
|
|
status="ambiguous",
|
|
provenance={"contact_ids": [item.id for item in contacts]},
|
|
)
|
|
return _resolve_contact_points_for_contact(
|
|
session,
|
|
principal,
|
|
request=request,
|
|
context=_SourceContactContext(contact=contacts[0]),
|
|
)
|
|
|
|
def preview_source(
|
|
self,
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
request: ContactPointSourceRequest,
|
|
offset: int = 0,
|
|
limit: int = 100,
|
|
) -> ContactPointSourcePreview:
|
|
self._assert_tenant(principal, request.tenant_id)
|
|
bounded_offset = max(0, offset)
|
|
bounded_limit = max(1, min(limit, 500))
|
|
source, source_revision, contexts = _contact_point_source_contexts(
|
|
session,
|
|
principal,
|
|
request.source_id,
|
|
)
|
|
if len(contexts) > max(1, min(request.max_items, 20_000)):
|
|
raise ValueError(
|
|
f"Contact-point source exceeds the configured {request.max_items} item limit."
|
|
)
|
|
selected = contexts[bounded_offset : bounded_offset + bounded_limit]
|
|
resolutions = tuple(
|
|
_resolve_contact_points_for_contact(
|
|
session,
|
|
principal,
|
|
request=_contact_point_resolution_request(request, item.contact),
|
|
context=item,
|
|
)
|
|
for item in selected
|
|
)
|
|
source_fingerprint = _source_fingerprint(request.source_id, source_revision)
|
|
return ContactPointSourcePreview(
|
|
contract_version=CONTACT_POINT_CONTRACT_VERSION,
|
|
source=source,
|
|
request=request,
|
|
resolutions=resolutions,
|
|
total_count=len(contexts),
|
|
usable_count=sum(len(item.candidates) for item in resolutions),
|
|
excluded_count=sum(
|
|
len(item.excluded) + int(not item.candidates and not item.excluded)
|
|
for item in resolutions
|
|
),
|
|
offset=bounded_offset,
|
|
limit=bounded_limit,
|
|
has_more=bounded_offset + len(resolutions) < len(contexts),
|
|
source_revision=source_revision,
|
|
source_fingerprint=source_fingerprint,
|
|
generated_at=utcnow(),
|
|
provenance={
|
|
"module": "addresses",
|
|
"contract_version": CONTACT_POINT_CONTRACT_VERSION,
|
|
"bounded": True,
|
|
},
|
|
)
|
|
|
|
def freeze_source(
|
|
self,
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
request: ContactPointSourceRequest,
|
|
) -> ContactPointSnapshotRef:
|
|
self._assert_tenant(principal, request.tenant_id)
|
|
source, source_revision, contexts = _contact_point_source_contexts(
|
|
session,
|
|
principal,
|
|
request.source_id,
|
|
)
|
|
max_items = max(1, min(request.max_items, 20_000))
|
|
if len(contexts) > max_items:
|
|
raise ValueError(
|
|
f"Contact-point source exceeds the configured {request.max_items} item limit."
|
|
)
|
|
resolutions = tuple(
|
|
_resolve_contact_points_for_contact(
|
|
session,
|
|
principal,
|
|
request=_contact_point_resolution_request(request, item.contact),
|
|
context=item,
|
|
)
|
|
for item in contexts
|
|
)
|
|
generated_at = utcnow()
|
|
source_fingerprint = _source_fingerprint(request.source_id, source_revision)
|
|
request_payload = _json_value(asdict(request))
|
|
resolution_payload = [_json_value(asdict(item)) for item in resolutions]
|
|
snapshot_hash = hashlib.sha256(
|
|
json.dumps(
|
|
{
|
|
"contract_version": CONTACT_POINT_CONTRACT_VERSION,
|
|
"source": _json_value(asdict(source)),
|
|
"request": request_payload,
|
|
"resolutions": resolution_payload,
|
|
"source_revision": source_revision,
|
|
"source_fingerprint": source_fingerprint,
|
|
},
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
).hexdigest()
|
|
snapshot = ContactPointSnapshot(
|
|
tenant_id=request.tenant_id,
|
|
source_id=request.source_id,
|
|
contract_version=CONTACT_POINT_CONTRACT_VERSION,
|
|
source_revision=source_revision,
|
|
source_fingerprint=source_fingerprint,
|
|
purpose=request.purpose,
|
|
effective_at=request.effective_at,
|
|
generated_at=generated_at,
|
|
request_payload=request_payload,
|
|
resolution_payload=resolution_payload,
|
|
recipient_count=sum(len(item.candidates) for item in resolutions),
|
|
excluded_count=sum(
|
|
len(item.excluded) + int(not item.candidates and not item.excluded)
|
|
for item in resolutions
|
|
),
|
|
snapshot_hash=snapshot_hash,
|
|
created_by_account_id=principal.account_id,
|
|
provenance={
|
|
"module": "addresses",
|
|
"source": _json_value(asdict(source)),
|
|
"immutable": True,
|
|
},
|
|
)
|
|
session.add(snapshot)
|
|
session.flush()
|
|
return _contact_point_snapshot_ref(snapshot)
|
|
|
|
def get_snapshot(
|
|
self,
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
snapshot_id: str,
|
|
) -> ContactPointSnapshotRef | None:
|
|
snapshot = (
|
|
session.query(ContactPointSnapshot)
|
|
.filter(
|
|
ContactPointSnapshot.id == snapshot_id,
|
|
ContactPointSnapshot.tenant_id == principal.tenant_id,
|
|
)
|
|
.one_or_none()
|
|
)
|
|
return _contact_point_snapshot_ref(snapshot) if snapshot is not None else None
|
|
|
|
@staticmethod
|
|
def _assert_tenant(principal: ApiPrincipal, tenant_id: str) -> None:
|
|
if tenant_id != principal.tenant_id:
|
|
raise PermissionError("Contact points cannot be resolved across tenants.")
|
|
|
|
|
|
def lookup_capability(_context: Any) -> AddressesLookupCapability:
|
|
return AddressesLookupCapability()
|
|
|
|
|
|
def people_search_capability(_context: Any) -> AddressesPeopleSearchProvider:
|
|
return AddressesPeopleSearchProvider()
|
|
|
|
|
|
def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability:
|
|
return AddressesRecipientSourceCapability()
|
|
|
|
|
|
def channel_facts_capability(_context: Any) -> AddressesChannelFactsCapability:
|
|
return AddressesChannelFactsCapability()
|
|
|
|
|
|
def contact_point_resolution_capability(
|
|
_context: Any,
|
|
) -> AddressesContactPointResolutionCapability:
|
|
return AddressesContactPointResolutionCapability()
|
|
|
|
|
|
def contact_writer_capability(_context: Any) -> AddressesContactWriterCapability:
|
|
return AddressesContactWriterCapability()
|
|
|
|
|
|
def _operation_required_scopes(operation: str) -> tuple[str, ...] | None:
|
|
if operation in ("create_contact", "update_contact"):
|
|
return ("addresses:address_book:read", "addresses:contact:write")
|
|
if operation == "delete_contact":
|
|
return ("addresses:address_book:read", "addresses:contact:delete")
|
|
return None
|
|
|
|
|
|
def _address_book_write_decision(principal: ApiPrincipal, book: AddressBook, *, operation: str) -> AddressBookWriteDecision:
|
|
required_scopes = _operation_required_scopes(operation)
|
|
if required_scopes is None:
|
|
return _blocked_write_decision(
|
|
address_book_id=book.id,
|
|
operation=operation,
|
|
reason="unsupported_operation",
|
|
message=f"Unsupported address-book write operation: {operation}",
|
|
book=book,
|
|
)
|
|
missing_scopes = tuple(scope for scope in required_scopes if not principal.has(scope))
|
|
if missing_scopes:
|
|
return _blocked_write_decision(
|
|
address_book_id=book.id,
|
|
operation=operation,
|
|
reason="missing_scope",
|
|
message=f"Missing scope: {missing_scopes[0]}",
|
|
book=book,
|
|
required_scopes=required_scopes,
|
|
)
|
|
if book.deleted_at is not None:
|
|
return _blocked_write_decision(
|
|
address_book_id=book.id,
|
|
operation=operation,
|
|
reason="address_book_deleted",
|
|
message="Address book is deleted.",
|
|
book=book,
|
|
required_scopes=required_scopes,
|
|
)
|
|
if book.read_only:
|
|
return _blocked_write_decision(
|
|
address_book_id=book.id,
|
|
operation=operation,
|
|
reason="address_book_read_only",
|
|
message="Address book is read-only.",
|
|
book=book,
|
|
required_scopes=required_scopes,
|
|
)
|
|
return AddressBookWriteDecision(
|
|
address_book_id=book.id,
|
|
address_book_label=book.name,
|
|
operation=operation,
|
|
allowed=True,
|
|
reason="allowed",
|
|
message="Address book accepts this write operation.",
|
|
scope_type=book.scope_type,
|
|
scope_id=book.scope_id,
|
|
tenant_id=book.tenant_id,
|
|
source_kind=book.source_kind,
|
|
read_only=book.read_only,
|
|
required_scopes=required_scopes,
|
|
provenance=_address_book_provenance(book),
|
|
)
|
|
|
|
|
|
def _blocked_write_decision(
|
|
*,
|
|
address_book_id: str,
|
|
operation: str,
|
|
reason: str,
|
|
message: str,
|
|
book: AddressBook | None = None,
|
|
required_scopes: tuple[str, ...] = (),
|
|
) -> AddressBookWriteDecision:
|
|
return AddressBookWriteDecision(
|
|
address_book_id=address_book_id,
|
|
address_book_label=book.name if book else None,
|
|
operation=operation,
|
|
allowed=False,
|
|
reason=reason,
|
|
message=message,
|
|
scope_type=book.scope_type if book else None,
|
|
scope_id=book.scope_id if book else None,
|
|
tenant_id=book.tenant_id if book else None,
|
|
source_kind=book.source_kind if book else None,
|
|
read_only=book.read_only if book else False,
|
|
required_scopes=required_scopes,
|
|
provenance=_address_book_provenance(book) if book else {"module": "addresses", "address_book_id": address_book_id},
|
|
)
|
|
|
|
|
|
def _lookup_candidate(contact: Contact, email: ContactEmail | None) -> AddressLookupCandidate:
|
|
return AddressLookupCandidate(
|
|
contact_id=contact.id,
|
|
address_book_id=contact.address_book_id,
|
|
display_name=contact.display_name,
|
|
email=email.email if email is not None else None,
|
|
email_label=email.label if email is not None else None,
|
|
organization=contact.organization,
|
|
role_title=contact.role_title,
|
|
tags=tuple(contact.tags or ()),
|
|
source_kind=contact.source_kind,
|
|
source_ref=contact.source_ref,
|
|
source_revision=contact.source_revision,
|
|
provenance=_contact_provenance(contact),
|
|
)
|
|
|
|
|
|
def _recipient_snapshot_item(
|
|
contact: Contact,
|
|
email: ContactEmail,
|
|
*,
|
|
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 = {
|
|
**_contact_provenance(contact),
|
|
"email_id": email.id,
|
|
"email_label": email.label,
|
|
"email_primary": email.is_primary,
|
|
}
|
|
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,
|
|
"address_list_name": address_list.name,
|
|
"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,
|
|
email=email.email,
|
|
email_label=email.label,
|
|
fields={
|
|
"given_name": contact.given_name,
|
|
"family_name": contact.family_name,
|
|
"organization": contact.organization,
|
|
"role_title": contact.role_title,
|
|
"phone": primary_phone,
|
|
"tags": list(contact.tags or ()),
|
|
},
|
|
provenance=provenance,
|
|
)
|
|
|
|
|
|
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",
|
|
"contact_id": contact.id,
|
|
"address_book_id": contact.address_book_id,
|
|
"source_kind": contact.source_kind,
|
|
"source_ref": contact.source_ref,
|
|
"source_revision": contact.source_revision,
|
|
}
|
|
|
|
|
|
def _address_book_provenance(book: AddressBook) -> dict[str, Any]:
|
|
return {
|
|
"module": "addresses",
|
|
"address_book_id": book.id,
|
|
"scope_type": book.scope_type,
|
|
"scope_id": book.scope_id,
|
|
"tenant_id": book.tenant_id,
|
|
"source_kind": book.source_kind,
|
|
"source_ref": book.source_ref,
|
|
}
|
|
|
|
|
|
def _entry_email(entry: AddressListEntry) -> ContactEmail | None:
|
|
if entry.contact_postal_address_id is not None:
|
|
return None
|
|
if entry.contact_email is not None:
|
|
return entry.contact_email
|
|
return next((email for email in entry.contact.emails if email.is_primary), entry.contact.emails[0] if entry.contact.emails else None)
|
|
|
|
|
|
def _contact_write_result(contact: Contact) -> ContactWriteResult:
|
|
primary_email = next((email.email for email in contact.emails if email.is_primary), contact.emails[0].email if contact.emails else None)
|
|
return ContactWriteResult(
|
|
contact_id=contact.id,
|
|
address_book_id=contact.address_book_id,
|
|
display_name=contact.display_name,
|
|
email=primary_email,
|
|
source_kind=contact.source_kind,
|
|
provenance={
|
|
**_contact_provenance(contact),
|
|
"contact_provenance": contact.provenance,
|
|
},
|
|
)
|
|
|
|
|
|
def _recipient_source_ref(book: AddressBook, *, recipient_count: int, latest_contact_updated_at: datetime | None = None) -> RecipientSourceRef:
|
|
return RecipientSourceRef(
|
|
source_id=f"addresses:address_book:{book.id}",
|
|
source_label=book.name,
|
|
source_kind=book.source_kind,
|
|
source_revision=_recipient_source_revision(book, latest_contact_updated_at),
|
|
recipient_count=recipient_count,
|
|
provenance={
|
|
"module": "addresses",
|
|
"address_book_id": book.id,
|
|
"scope_type": book.scope_type,
|
|
"scope_id": book.scope_id,
|
|
"tenant_id": book.tenant_id,
|
|
},
|
|
)
|
|
|
|
|
|
def _address_list_source_ref(address_list: AddressList, *, recipient_count: int, latest_entry_updated_at: datetime | None = None) -> RecipientSourceRef:
|
|
return RecipientSourceRef(
|
|
source_id=f"addresses:address_list:{address_list.id}",
|
|
source_label=address_list.name,
|
|
source_kind="address_list",
|
|
source_revision=_address_list_source_revision(address_list, latest_entry_updated_at),
|
|
recipient_count=recipient_count,
|
|
provenance={
|
|
"module": "addresses",
|
|
"address_book_id": address_list.address_book_id,
|
|
"address_list_id": address_list.id,
|
|
"scope_type": address_list.address_book.scope_type,
|
|
"scope_id": address_list.address_book.scope_id,
|
|
"tenant_id": address_list.tenant_id,
|
|
},
|
|
)
|
|
|
|
|
|
def _active_address_book_contacts(session: Any, address_book_id: str) -> list[Contact]:
|
|
return (
|
|
session.query(Contact)
|
|
.options(
|
|
selectinload(Contact.emails),
|
|
selectinload(Contact.phones),
|
|
selectinload(Contact.postal_addresses),
|
|
selectinload(Contact.channel_rules),
|
|
)
|
|
.filter(Contact.address_book_id == address_book_id, Contact.deleted_at.is_(None))
|
|
.order_by(Contact.display_name.asc(), Contact.id.asc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def _address_book_contact_updated_at(session: Any, address_book_ids: list[str]) -> dict[str, datetime]:
|
|
if not address_book_ids:
|
|
return {}
|
|
revisions: dict[str, datetime] = {}
|
|
contact_rows = (
|
|
session.query(Contact.address_book_id, func.max(Contact.updated_at))
|
|
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_(None))
|
|
.group_by(Contact.address_book_id)
|
|
.all()
|
|
)
|
|
deletion_rows = (
|
|
session.query(Contact.address_book_id, func.max(Contact.deleted_at))
|
|
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_not(None))
|
|
.group_by(Contact.address_book_id)
|
|
.all()
|
|
)
|
|
email_rows = (
|
|
session.query(Contact.address_book_id, func.max(ContactEmail.updated_at))
|
|
.join(Contact, ContactEmail.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()
|
|
)
|
|
phone_rows = (
|
|
session.query(Contact.address_book_id, func.max(ContactPhone.updated_at))
|
|
.join(Contact, ContactPhone.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()
|
|
)
|
|
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()
|
|
)
|
|
quality_rows = (
|
|
session.query(
|
|
Contact.address_book_id,
|
|
func.max(ContactPointQualityDecision.updated_at),
|
|
)
|
|
.join(Contact, ContactPointQualityDecision.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,
|
|
*quality_rows,
|
|
]:
|
|
if updated_at is None:
|
|
continue
|
|
key = str(address_book_id)
|
|
revisions[key] = max(
|
|
_aware_datetime(revisions.get(key, updated_at)),
|
|
_aware_datetime(updated_at),
|
|
)
|
|
return revisions
|
|
|
|
|
|
def _address_list_updated_at(session: Any, address_list_ids: list[str]) -> dict[str, datetime]:
|
|
if not address_list_ids:
|
|
return {}
|
|
revisions: dict[str, datetime] = {}
|
|
entry_rows = (
|
|
session.query(AddressListEntry.address_list_id, func.max(AddressListEntry.updated_at))
|
|
.filter(AddressListEntry.address_list_id.in_(address_list_ids))
|
|
.group_by(AddressListEntry.address_list_id)
|
|
.all()
|
|
)
|
|
contact_rows = (
|
|
session.query(AddressListEntry.address_list_id, func.max(Contact.updated_at))
|
|
.join(Contact, AddressListEntry.contact_id == Contact.id)
|
|
.filter(AddressListEntry.address_list_id.in_(address_list_ids), Contact.deleted_at.is_(None))
|
|
.group_by(AddressListEntry.address_list_id)
|
|
.all()
|
|
)
|
|
email_rows = (
|
|
session.query(AddressListEntry.address_list_id, func.max(ContactEmail.updated_at))
|
|
.join(Contact, AddressListEntry.contact_id == Contact.id)
|
|
.join(ContactEmail, ContactEmail.contact_id == Contact.id)
|
|
.filter(
|
|
AddressListEntry.address_list_id.in_(address_list_ids),
|
|
Contact.deleted_at.is_(None),
|
|
)
|
|
.group_by(AddressListEntry.address_list_id)
|
|
.all()
|
|
)
|
|
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()
|
|
)
|
|
quality_rows = (
|
|
session.query(
|
|
AddressListEntry.address_list_id,
|
|
func.max(ContactPointQualityDecision.updated_at),
|
|
)
|
|
.join(ContactPointQualityDecision, AddressListEntry.contact_id == ContactPointQualityDecision.contact_id)
|
|
.filter(AddressListEntry.address_list_id.in_(address_list_ids))
|
|
.group_by(AddressListEntry.address_list_id)
|
|
.all()
|
|
)
|
|
postal_rows = (
|
|
session.query(
|
|
AddressListEntry.address_list_id,
|
|
func.max(ContactPostalAddress.updated_at),
|
|
)
|
|
.join(Contact, AddressListEntry.contact_id == Contact.id)
|
|
.join(ContactPostalAddress, ContactPostalAddress.contact_id == Contact.id)
|
|
.filter(
|
|
AddressListEntry.address_list_id.in_(address_list_ids),
|
|
Contact.deleted_at.is_(None),
|
|
)
|
|
.group_by(AddressListEntry.address_list_id)
|
|
.all()
|
|
)
|
|
for address_list_id, updated_at in [
|
|
*entry_rows,
|
|
*contact_rows,
|
|
*email_rows,
|
|
*postal_rows,
|
|
*rule_rows,
|
|
*quality_rows,
|
|
]:
|
|
if updated_at is None:
|
|
continue
|
|
key = str(address_list_id)
|
|
revisions[key] = max(
|
|
_aware_datetime(revisions.get(key, updated_at)),
|
|
_aware_datetime(updated_at),
|
|
)
|
|
return revisions
|
|
|
|
|
|
def _recipient_source_revision(book: AddressBook, latest_contact_updated_at: datetime | None = None) -> str:
|
|
stamps: list[datetime] = [book.updated_at]
|
|
if latest_contact_updated_at is not None:
|
|
stamps.append(latest_contact_updated_at)
|
|
return max(_aware_datetime(item) for item in stamps).isoformat()
|
|
|
|
|
|
def _address_list_source_revision(address_list: AddressList, latest_entry_updated_at: datetime | None = None) -> str:
|
|
stamps: list[datetime] = [address_list.updated_at]
|
|
if latest_entry_updated_at is not None:
|
|
stamps.append(latest_entry_updated_at)
|
|
return max(_aware_datetime(item) for item in 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,
|
|
*contact.quality_decisions,
|
|
)
|
|
)
|
|
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)
|
|
],
|
|
"quality": [
|
|
{
|
|
"id": item.id,
|
|
"channel": item.channel,
|
|
"point": item.contact_point_id,
|
|
"state": item.state,
|
|
"reason_code": item.reason_code,
|
|
"evidence": item.evidence_ref,
|
|
"from": item.effective_from.isoformat(),
|
|
"until": item.effective_until.isoformat() if item.effective_until else None,
|
|
"updated": item.updated_at.isoformat(),
|
|
}
|
|
for item in sorted(contact.quality_decisions, key=lambda row: row.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
|
|
|
|
|
|
def _contacts_for_subject(
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
subject: DistributionSourceReference,
|
|
) -> list[Contact]:
|
|
direct_id = subject.metadata.get("contact_id")
|
|
if direct_id is None and subject.provider == "addresses" and subject.resource_type in {
|
|
"contact",
|
|
"address_contact",
|
|
"address_email",
|
|
}:
|
|
direct_id = subject.resource_id
|
|
if direct_id is not None:
|
|
try:
|
|
return [get_visible_contact(session, principal, str(direct_id))]
|
|
except AddressBookError:
|
|
try:
|
|
resolution = resolve_contact_redirect(
|
|
session,
|
|
principal,
|
|
str(direct_id),
|
|
)
|
|
return [
|
|
get_visible_contact(
|
|
session,
|
|
principal,
|
|
resolution.resolved_contact_id,
|
|
)
|
|
]
|
|
except AddressBookError:
|
|
return []
|
|
|
|
explicit_ref = subject.metadata.get("source_ref")
|
|
source_refs = {
|
|
str(explicit_ref).strip() if explicit_ref else "",
|
|
f"{subject.provider}:{subject.resource_type}:{subject.resource_id}",
|
|
}
|
|
source_refs.discard("")
|
|
rows = (
|
|
session.query(Contact)
|
|
.options(
|
|
selectinload(Contact.emails),
|
|
selectinload(Contact.phones),
|
|
selectinload(Contact.postal_addresses),
|
|
selectinload(Contact.channel_rules),
|
|
)
|
|
.filter(
|
|
Contact.source_ref.in_(source_refs),
|
|
or_(Contact.tenant_id == principal.tenant_id, Contact.tenant_id.is_(None)),
|
|
)
|
|
.order_by(Contact.id.asc())
|
|
.limit(3)
|
|
.all()
|
|
)
|
|
visible: list[Contact] = []
|
|
visible_ids: set[str] = set()
|
|
for row in rows:
|
|
try:
|
|
resolution = resolve_contact_redirect(session, principal, row.id)
|
|
contact = get_visible_contact(
|
|
session,
|
|
principal,
|
|
resolution.resolved_contact_id,
|
|
)
|
|
if contact.id not in visible_ids:
|
|
visible.append(contact)
|
|
visible_ids.add(contact.id)
|
|
except AddressBookError:
|
|
continue
|
|
return visible
|
|
|
|
|
|
def _unresolved_contact_point_resolution(
|
|
request: ContactPointResolutionRequest,
|
|
*,
|
|
code: str,
|
|
message: str,
|
|
status: str = "unresolved",
|
|
provenance: dict[str, Any] | None = None,
|
|
) -> ContactPointResolution:
|
|
return ContactPointResolution(
|
|
contract_version=CONTACT_POINT_CONTRACT_VERSION,
|
|
subject=request.subject,
|
|
status=status, # type: ignore[arg-type]
|
|
explanations=(
|
|
DistributionExplanation(
|
|
code=code,
|
|
message=message,
|
|
severity="warning",
|
|
provider="addresses",
|
|
source=request.subject,
|
|
),
|
|
),
|
|
provenance={
|
|
"module": "addresses",
|
|
"effective_at": request.effective_at.isoformat(),
|
|
"purpose": request.purpose,
|
|
**(provenance or {}),
|
|
},
|
|
)
|
|
|
|
|
|
def _resolve_contact_points_for_contact(
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
request: ContactPointResolutionRequest,
|
|
context: _SourceContactContext,
|
|
) -> ContactPointResolution:
|
|
contact = context.contact
|
|
facts = AddressesChannelFactsCapability().resolve_channel_facts(
|
|
session,
|
|
principal,
|
|
request=RecipientChannelFactsRequest(
|
|
tenant_id=request.tenant_id,
|
|
source=DistributionSourceReference(
|
|
provider="addresses",
|
|
resource_type="contact",
|
|
resource_id=contact.id,
|
|
revision=contact.source_revision,
|
|
label=contact.display_name,
|
|
metadata={
|
|
"contact_id": contact.id,
|
|
"source_ref": contact.source_ref,
|
|
},
|
|
),
|
|
recipient_key=f"contact:{contact.id}",
|
|
effective_at=request.effective_at,
|
|
purpose=request.purpose,
|
|
requested_channels=request.requested_channels,
|
|
context=request.context,
|
|
),
|
|
)
|
|
preference_revision = _rule_revision(
|
|
contact,
|
|
decisions={"preferred"},
|
|
)
|
|
consent_revision = _rule_revision(
|
|
contact,
|
|
decisions={
|
|
"allowed",
|
|
"opted_in",
|
|
"opted_out",
|
|
"suppressed",
|
|
"invalid",
|
|
"returned",
|
|
"temporarily_unavailable",
|
|
},
|
|
)
|
|
candidates: list[ContactPointCandidate] = []
|
|
for fact in facts.candidates:
|
|
if (
|
|
context.allowed_contact_point_ids is not None
|
|
and (
|
|
fact.contact_point_id is None
|
|
or fact.contact_point_id not in context.allowed_contact_point_ids
|
|
)
|
|
):
|
|
continue
|
|
details = _contact_point_details(
|
|
contact,
|
|
channel=fact.channel,
|
|
contact_point_id=fact.contact_point_id,
|
|
fallback_target=fact.target,
|
|
fallback_target_key=fact.target_key,
|
|
postal_format=request.postal_format,
|
|
)
|
|
if details is None:
|
|
continue
|
|
address_purpose, primary, target, target_key, value, order_index = details
|
|
preference_rank = fact.decision_provenance.get("preference_rank")
|
|
candidates.append(
|
|
ContactPointCandidate(
|
|
channel=fact.channel,
|
|
target=target,
|
|
target_key=target_key,
|
|
status=fact.status,
|
|
contact_point_id=fact.contact_point_id,
|
|
address_purpose=address_purpose,
|
|
locale=fact.locale or request.locale,
|
|
preferred=fact.preferred or primary,
|
|
preference_rank=(
|
|
int(preference_rank)
|
|
if isinstance(preference_rank, int)
|
|
else None
|
|
),
|
|
reason_code=fact.reason_code,
|
|
explanation=fact.explanation,
|
|
source=fact.source,
|
|
source_revision=facts.source_revision,
|
|
preference_revision=preference_revision,
|
|
consent_revision=consent_revision,
|
|
value=value,
|
|
provenance={
|
|
**dict(fact.decision_provenance),
|
|
"contact_id": contact.id,
|
|
"address_book_id": contact.address_book_id,
|
|
"source_kind": contact.source_kind,
|
|
"source_ref": contact.source_ref,
|
|
"source_revision": contact.source_revision,
|
|
"order_index": order_index,
|
|
"address_list_entry_ids": context.address_list_entry_ids,
|
|
},
|
|
)
|
|
)
|
|
|
|
selected, excluded = _select_contact_point_candidates(candidates, request)
|
|
if selected:
|
|
result_status = "usable"
|
|
elif excluded:
|
|
result_status = _resolution_status(excluded)
|
|
else:
|
|
result_status = "unresolved"
|
|
explanations = list(facts.explanations)
|
|
if not selected and not excluded:
|
|
explanations.append(
|
|
DistributionExplanation(
|
|
code="addresses.contact_point.none",
|
|
message="The contact has no contact point for the requested channels.",
|
|
severity="warning",
|
|
provider="addresses",
|
|
source=request.subject,
|
|
)
|
|
)
|
|
return ContactPointResolution(
|
|
contract_version=CONTACT_POINT_CONTRACT_VERSION,
|
|
subject=request.subject,
|
|
status=result_status, # type: ignore[arg-type]
|
|
contact_id=contact.id,
|
|
display_name=contact.display_name,
|
|
candidates=tuple(selected),
|
|
excluded=tuple(excluded),
|
|
explanations=tuple(explanations),
|
|
source_revision=facts.source_revision,
|
|
source_fingerprint=facts.source_fingerprint,
|
|
provenance={
|
|
**dict(facts.provenance),
|
|
"provider_subject": _json_value(asdict(request.subject)),
|
|
"address_purpose": request.address_purpose,
|
|
"fallback_rule": request.fallback_rule,
|
|
"postal_format": request.postal_format,
|
|
"preference_revision": preference_revision,
|
|
"consent_revision": consent_revision,
|
|
"address_list_entry_ids": context.address_list_entry_ids,
|
|
},
|
|
)
|
|
|
|
|
|
def _contact_point_details(
|
|
contact: Contact,
|
|
*,
|
|
channel: str,
|
|
contact_point_id: str | None,
|
|
fallback_target: str,
|
|
fallback_target_key: str,
|
|
postal_format: str,
|
|
) -> tuple[str | None, bool, str, str, dict[str, Any], int] | None:
|
|
if channel == "email":
|
|
point = next((item for item in contact.emails if item.id == contact_point_id), None)
|
|
if point is None:
|
|
return None
|
|
target = point.email.strip()
|
|
return (
|
|
point.label,
|
|
point.is_primary,
|
|
target,
|
|
f"email:{target.casefold()}",
|
|
{"email": target, "label": point.label},
|
|
point.order_index,
|
|
)
|
|
if channel == "postal":
|
|
point = next(
|
|
(item for item in contact.postal_addresses if item.id == contact_point_id),
|
|
None,
|
|
)
|
|
if point is None:
|
|
return None
|
|
lines = [
|
|
contact.display_name,
|
|
point.street,
|
|
" ".join(part for part in (point.postal_code, point.locality) if part),
|
|
point.region,
|
|
]
|
|
if postal_format == "international":
|
|
lines.append(point.country)
|
|
normalized_lines = [str(item).strip() for item in lines if item and str(item).strip()]
|
|
canonical = "|".join(
|
|
str(item or "").strip().casefold()
|
|
for item in (
|
|
point.street,
|
|
point.postal_code,
|
|
point.locality,
|
|
point.region,
|
|
point.country,
|
|
)
|
|
)
|
|
return (
|
|
point.label,
|
|
point.is_primary,
|
|
"\n".join(normalized_lines),
|
|
f"postal:{canonical}",
|
|
{
|
|
"label": point.label,
|
|
"street": point.street,
|
|
"postal_code": point.postal_code,
|
|
"locality": point.locality,
|
|
"region": point.region,
|
|
"country": point.country,
|
|
"formatted_lines": normalized_lines,
|
|
"format": postal_format,
|
|
},
|
|
point.order_index,
|
|
)
|
|
if channel in {"internal_mail", "portal"}:
|
|
purpose = (contact.provenance or {}).get(f"{channel}_purpose")
|
|
return (
|
|
str(purpose) if purpose else None,
|
|
True,
|
|
fallback_target,
|
|
fallback_target_key,
|
|
{"reference": fallback_target},
|
|
0,
|
|
)
|
|
return None
|
|
|
|
|
|
def _select_contact_point_candidates(
|
|
candidates: list[ContactPointCandidate],
|
|
request: ContactPointResolutionRequest,
|
|
) -> tuple[list[ContactPointCandidate], list[ContactPointCandidate]]:
|
|
candidates = sorted(candidates, key=_contact_point_sort_key)
|
|
accepted: list[ContactPointCandidate] = []
|
|
excluded: list[ContactPointCandidate] = []
|
|
requested_purpose = (request.address_purpose or "").strip().casefold()
|
|
by_channel: dict[str, list[ContactPointCandidate]] = {}
|
|
for item in candidates:
|
|
by_channel.setdefault(item.channel, []).append(item)
|
|
for channel_items in by_channel.values():
|
|
selected_ids: set[int]
|
|
if not requested_purpose:
|
|
selected_ids = {id(item) for item in channel_items}
|
|
else:
|
|
exact = [
|
|
item
|
|
for item in channel_items
|
|
if (item.address_purpose or "").strip().casefold() == requested_purpose
|
|
]
|
|
if exact:
|
|
selected_ids = {id(item) for item in exact}
|
|
elif request.fallback_rule == "primary":
|
|
primary = [item for item in channel_items if item.preferred]
|
|
selected_ids = {id(item) for item in primary[:1]}
|
|
elif request.fallback_rule == "any":
|
|
selected_ids = {id(channel_items[0])} if channel_items else set()
|
|
else:
|
|
selected_ids = set()
|
|
for item in channel_items:
|
|
if id(item) in selected_ids:
|
|
accepted.append(item)
|
|
else:
|
|
excluded.append(
|
|
item
|
|
if item.status not in {"usable", "stale"}
|
|
else replace(
|
|
item,
|
|
status="unresolved",
|
|
reason_code="addresses.address_purpose.not_selected",
|
|
explanation=(
|
|
"This contact point did not match the requested address purpose "
|
|
"or its configured fallback."
|
|
),
|
|
)
|
|
)
|
|
deduplicated: list[ContactPointCandidate] = []
|
|
seen: set[tuple[str, str]] = set()
|
|
for item in accepted:
|
|
key = (item.channel, item.target_key)
|
|
if key in seen and item.status in {"usable", "stale"}:
|
|
excluded.append(
|
|
replace(
|
|
item,
|
|
status="duplicate",
|
|
reason_code="addresses.contact_point.duplicate",
|
|
explanation="An earlier contact point resolves to the same channel target.",
|
|
)
|
|
)
|
|
continue
|
|
seen.add(key)
|
|
if item.status in {"usable", "stale"}:
|
|
deduplicated.append(item)
|
|
else:
|
|
excluded.append(item)
|
|
return deduplicated, sorted(excluded, key=_contact_point_sort_key)
|
|
|
|
|
|
def _contact_point_sort_key(item: ContactPointCandidate) -> tuple[Any, ...]:
|
|
order = item.provenance.get("order_index")
|
|
return (
|
|
{"email": 0, "postal": 1, "internal_mail": 2, "portal": 3}.get(
|
|
item.channel,
|
|
9,
|
|
),
|
|
0 if item.preferred else 1,
|
|
item.preference_rank if item.preference_rank is not None else 10001,
|
|
int(order) if isinstance(order, int) else 0,
|
|
item.contact_point_id or "",
|
|
item.target_key,
|
|
)
|
|
|
|
|
|
def _resolution_status(excluded: list[ContactPointCandidate]) -> str:
|
|
for status in (
|
|
"invalid",
|
|
"suppressed",
|
|
"ambiguous",
|
|
"duplicate",
|
|
"stale",
|
|
"unresolved",
|
|
):
|
|
if any(item.status == status for item in excluded):
|
|
return status
|
|
return "unresolved"
|
|
|
|
|
|
def _rule_revision(contact: Contact, *, decisions: set[str]) -> str | None:
|
|
stamps = [
|
|
_aware_datetime(item.updated_at)
|
|
for item in contact.channel_rules
|
|
if item.decision in decisions
|
|
]
|
|
return max(stamps).isoformat() if stamps else None
|
|
|
|
|
|
def _contact_point_source_contexts(
|
|
session: Any,
|
|
principal: ApiPrincipal,
|
|
source_id: str,
|
|
) -> tuple[DistributionSourceReference, str, list[_SourceContactContext]]:
|
|
book_prefix = "addresses:address_book:"
|
|
list_prefix = "addresses:address_list:"
|
|
if source_id.startswith(book_prefix):
|
|
book = get_visible_address_book(
|
|
session,
|
|
principal,
|
|
source_id.removeprefix(book_prefix),
|
|
)
|
|
contexts = [
|
|
_SourceContactContext(contact=item)
|
|
for item in _active_address_book_contacts(session, book.id)
|
|
]
|
|
revision = _recipient_source_revision(
|
|
book,
|
|
_address_book_contact_updated_at(session, [book.id]).get(book.id),
|
|
)
|
|
return (
|
|
DistributionSourceReference(
|
|
provider="addresses",
|
|
resource_type="address_book",
|
|
resource_id=book.id,
|
|
revision=revision,
|
|
label=book.name,
|
|
metadata={
|
|
"source_id": source_id,
|
|
"scope_type": book.scope_type,
|
|
"scope_id": book.scope_id,
|
|
"source_kind": book.source_kind,
|
|
"source_ref": book.source_ref,
|
|
},
|
|
),
|
|
revision,
|
|
contexts,
|
|
)
|
|
if source_id.startswith(list_prefix):
|
|
address_list = get_visible_address_list(
|
|
session,
|
|
principal,
|
|
source_id.removeprefix(list_prefix),
|
|
)
|
|
entries = list_address_list_entries(session, principal, address_list.id)
|
|
grouped: dict[str, dict[str, Any]] = {}
|
|
for entry in entries:
|
|
item = grouped.setdefault(
|
|
entry.contact_id,
|
|
{"contact": entry.contact, "allowed": set(), "all": False, "entry_ids": []},
|
|
)
|
|
item["entry_ids"].append(entry.id)
|
|
point_id = entry.contact_email_id or entry.contact_postal_address_id
|
|
if point_id:
|
|
item["allowed"].add(point_id)
|
|
else:
|
|
item["all"] = True
|
|
contexts = [
|
|
_SourceContactContext(
|
|
contact=item["contact"],
|
|
allowed_contact_point_ids=(
|
|
None if item["all"] else frozenset(item["allowed"])
|
|
),
|
|
address_list_entry_ids=tuple(item["entry_ids"]),
|
|
)
|
|
for item in grouped.values()
|
|
]
|
|
revision = _address_list_source_revision(
|
|
address_list,
|
|
_address_list_updated_at(session, [address_list.id]).get(address_list.id),
|
|
)
|
|
return (
|
|
DistributionSourceReference(
|
|
provider="addresses",
|
|
resource_type="address_list",
|
|
resource_id=address_list.id,
|
|
revision=revision,
|
|
label=address_list.name,
|
|
metadata={
|
|
"source_id": source_id,
|
|
"address_book_id": address_list.address_book_id,
|
|
"scope_type": address_list.address_book.scope_type,
|
|
"scope_id": address_list.address_book.scope_id,
|
|
},
|
|
),
|
|
revision,
|
|
contexts,
|
|
)
|
|
raise ValueError(f"Unsupported Addresses contact-point source id: {source_id}")
|
|
|
|
|
|
def _contact_point_resolution_request(
|
|
request: ContactPointSourceRequest,
|
|
contact: Contact,
|
|
) -> ContactPointResolutionRequest:
|
|
return ContactPointResolutionRequest(
|
|
tenant_id=request.tenant_id,
|
|
subject=DistributionSourceReference(
|
|
provider="addresses",
|
|
resource_type="contact",
|
|
resource_id=contact.id,
|
|
revision=contact.source_revision,
|
|
label=contact.display_name,
|
|
metadata={
|
|
"contact_id": contact.id,
|
|
"source_kind": contact.source_kind,
|
|
"source_ref": contact.source_ref,
|
|
},
|
|
),
|
|
effective_at=request.effective_at,
|
|
purpose=request.purpose,
|
|
requested_channels=request.requested_channels,
|
|
address_purpose=request.address_purpose,
|
|
fallback_rule=request.fallback_rule,
|
|
locale=request.locale,
|
|
postal_format=request.postal_format,
|
|
context=request.context,
|
|
)
|
|
|
|
|
|
def _source_fingerprint(source_id: str, revision: str) -> str:
|
|
return hashlib.sha256(f"{source_id}\0{revision}".encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _json_value(value: Any) -> Any:
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
if isinstance(value, dict):
|
|
return {str(key): _json_value(item) for key, item in value.items()}
|
|
if isinstance(value, (list, tuple, set, frozenset)):
|
|
return [_json_value(item) for item in value]
|
|
return value
|
|
|
|
|
|
def _source_reference_from_payload(payload: dict[str, Any]) -> DistributionSourceReference:
|
|
return DistributionSourceReference(
|
|
provider=str(payload.get("provider") or "addresses"),
|
|
resource_type=str(payload.get("resource_type") or "unknown"),
|
|
resource_id=str(payload.get("resource_id") or ""),
|
|
revision=str(payload["revision"]) if payload.get("revision") else None,
|
|
fingerprint=str(payload["fingerprint"]) if payload.get("fingerprint") else None,
|
|
label=str(payload["label"]) if payload.get("label") else None,
|
|
metadata=dict(payload.get("metadata") or {}),
|
|
)
|
|
|
|
|
|
def _candidate_from_payload(payload: dict[str, Any]) -> ContactPointCandidate:
|
|
source_payload = payload.get("source")
|
|
return ContactPointCandidate(
|
|
channel=str(payload.get("channel") or "email"), # type: ignore[arg-type]
|
|
target=str(payload.get("target") or ""),
|
|
target_key=str(payload.get("target_key") or ""),
|
|
status=str(payload.get("status") or "unresolved"), # type: ignore[arg-type]
|
|
contact_point_id=(
|
|
str(payload["contact_point_id"])
|
|
if payload.get("contact_point_id")
|
|
else None
|
|
),
|
|
address_purpose=(
|
|
str(payload["address_purpose"])
|
|
if payload.get("address_purpose")
|
|
else None
|
|
),
|
|
locale=str(payload["locale"]) if payload.get("locale") else None,
|
|
preferred=bool(payload.get("preferred")),
|
|
preference_rank=(
|
|
int(payload["preference_rank"])
|
|
if isinstance(payload.get("preference_rank"), int)
|
|
else None
|
|
),
|
|
reason_code=(
|
|
str(payload["reason_code"]) if payload.get("reason_code") else None
|
|
),
|
|
explanation=(
|
|
str(payload["explanation"]) if payload.get("explanation") else None
|
|
),
|
|
source=(
|
|
_source_reference_from_payload(dict(source_payload))
|
|
if isinstance(source_payload, dict)
|
|
else None
|
|
),
|
|
source_revision=(
|
|
str(payload["source_revision"])
|
|
if payload.get("source_revision")
|
|
else None
|
|
),
|
|
preference_revision=(
|
|
str(payload["preference_revision"])
|
|
if payload.get("preference_revision")
|
|
else None
|
|
),
|
|
consent_revision=(
|
|
str(payload["consent_revision"])
|
|
if payload.get("consent_revision")
|
|
else None
|
|
),
|
|
value=dict(payload.get("value") or {}),
|
|
provenance=dict(payload.get("provenance") or {}),
|
|
)
|
|
|
|
|
|
def _explanation_from_payload(payload: dict[str, Any]) -> DistributionExplanation:
|
|
source_payload = payload.get("source")
|
|
return DistributionExplanation(
|
|
code=str(payload.get("code") or "addresses.snapshot"),
|
|
message=str(payload.get("message") or ""),
|
|
severity=str(payload.get("severity") or "warning"), # type: ignore[arg-type]
|
|
provider=str(payload["provider"]) if payload.get("provider") else None,
|
|
source=(
|
|
_source_reference_from_payload(dict(source_payload))
|
|
if isinstance(source_payload, dict)
|
|
else None
|
|
),
|
|
provenance=dict(payload.get("provenance") or {}),
|
|
)
|
|
|
|
|
|
def _resolution_from_payload(payload: dict[str, Any]) -> ContactPointResolution:
|
|
return ContactPointResolution(
|
|
contract_version=str(
|
|
payload.get("contract_version") or CONTACT_POINT_CONTRACT_VERSION
|
|
),
|
|
subject=_source_reference_from_payload(dict(payload.get("subject") or {})),
|
|
status=str(payload.get("status") or "unresolved"), # type: ignore[arg-type]
|
|
contact_id=str(payload["contact_id"]) if payload.get("contact_id") else None,
|
|
display_name=(
|
|
str(payload["display_name"]) if payload.get("display_name") else None
|
|
),
|
|
candidates=tuple(
|
|
_candidate_from_payload(dict(item))
|
|
for item in payload.get("candidates") or []
|
|
if isinstance(item, dict)
|
|
),
|
|
excluded=tuple(
|
|
_candidate_from_payload(dict(item))
|
|
for item in payload.get("excluded") or []
|
|
if isinstance(item, dict)
|
|
),
|
|
explanations=tuple(
|
|
_explanation_from_payload(dict(item))
|
|
for item in payload.get("explanations") or []
|
|
if isinstance(item, dict)
|
|
),
|
|
source_revision=(
|
|
str(payload["source_revision"])
|
|
if payload.get("source_revision")
|
|
else None
|
|
),
|
|
source_fingerprint=(
|
|
str(payload["source_fingerprint"])
|
|
if payload.get("source_fingerprint")
|
|
else None
|
|
),
|
|
provenance=dict(payload.get("provenance") or {}),
|
|
)
|
|
|
|
|
|
def _source_request_from_payload(payload: dict[str, Any]) -> ContactPointSourceRequest:
|
|
effective_at = datetime.fromisoformat(str(payload["effective_at"]))
|
|
return ContactPointSourceRequest(
|
|
tenant_id=str(payload.get("tenant_id") or ""),
|
|
source_id=str(payload.get("source_id") or ""),
|
|
effective_at=effective_at,
|
|
purpose=str(payload["purpose"]) if payload.get("purpose") else None,
|
|
requested_channels=tuple(payload.get("requested_channels") or ()), # type: ignore[arg-type]
|
|
address_purpose=(
|
|
str(payload["address_purpose"])
|
|
if payload.get("address_purpose")
|
|
else None
|
|
),
|
|
fallback_rule=str(payload.get("fallback_rule") or "primary"), # type: ignore[arg-type]
|
|
locale=str(payload["locale"]) if payload.get("locale") else None,
|
|
postal_format=str(payload.get("postal_format") or "domestic"), # type: ignore[arg-type]
|
|
max_items=int(payload.get("max_items") or 5000),
|
|
context=dict(payload.get("context") or {}),
|
|
)
|
|
|
|
|
|
def _contact_point_snapshot_ref(snapshot: ContactPointSnapshot) -> ContactPointSnapshotRef:
|
|
provenance = dict(snapshot.provenance or {})
|
|
source_payload = provenance.get("source")
|
|
source = _source_reference_from_payload(
|
|
dict(source_payload) if isinstance(source_payload, dict) else {}
|
|
)
|
|
return ContactPointSnapshotRef(
|
|
id=snapshot.id,
|
|
tenant_id=snapshot.tenant_id,
|
|
contract_version=snapshot.contract_version,
|
|
source=source,
|
|
request=_source_request_from_payload(dict(snapshot.request_payload or {})),
|
|
resolutions=tuple(
|
|
_resolution_from_payload(dict(item))
|
|
for item in snapshot.resolution_payload or []
|
|
),
|
|
recipient_count=snapshot.recipient_count,
|
|
excluded_count=snapshot.excluded_count,
|
|
source_revision=snapshot.source_revision,
|
|
source_fingerprint=snapshot.source_fingerprint,
|
|
snapshot_hash=snapshot.snapshot_hash,
|
|
generated_at=_aware_datetime(snapshot.generated_at),
|
|
provenance=provenance,
|
|
)
|