Add governed contact channel facts

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent 90a507d9a4
commit 41ccd4c807
11 changed files with 1364 additions and 14 deletions
+488 -10
View File
@@ -1,25 +1,44 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from datetime import UTC, datetime
import hashlib
import json
from typing import Any
from sqlalchemy import func
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.distribution_lists import (
DistributionChannelCandidate,
DistributionExplanation,
DistributionSourceReference,
RecipientChannelFacts,
RecipientChannelFactsRequest,
)
from govoplan_core.db.base import utcnow
from govoplan_core.core.people import (
PeopleSearchGroup,
PersonSearchCandidate,
person_selection_key,
)
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressList,
AddressListEntry,
Contact,
ContactChannelRule,
ContactEmail,
ContactPhone,
ContactPostalAddress,
)
from govoplan_addresses.backend.schemas import ContactCreateRequest
from govoplan_addresses.backend.service import (
AddressBookError,
create_contact,
get_visible_address_book,
get_visible_address_list,
get_visible_contact,
list_address_books,
list_address_list_entries,
list_address_lists,
@@ -60,6 +79,19 @@ class RecipientSnapshotItem:
provenance: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientSnapshotExcludedItem:
contact_id: str
display_name: str
channel: str
target: str
contact_point_id: str | None = None
status: str = "suppressed"
reason_code: str | None = None
explanation: str | None = None
provenance: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientSourceRef:
source_id: str
@@ -78,6 +110,9 @@ class RecipientSourceSnapshot:
source_revision: str
generated_at: str
recipients: tuple[RecipientSnapshotItem, ...]
excluded: tuple[RecipientSnapshotExcludedItem, ...] = ()
purpose: str | None = None
requested_channels: tuple[str, ...] = ()
provenance: dict[str, Any] = field(default_factory=dict)
@@ -283,13 +318,27 @@ class AddressesRecipientSourceCapability:
principal: ApiPrincipal,
*,
address_book_id: str,
purpose: str | None = None,
requested_channels: tuple[str, ...] = ("email",),
) -> RecipientSourceSnapshot:
book = get_visible_address_book(session, principal, address_book_id)
contacts = _active_address_book_contacts(session, book.id)
recipients: list[RecipientSnapshotItem] = []
for contact in contacts:
for email in contact.emails:
recipients.append(_recipient_snapshot_item(contact, email))
excluded: list[RecipientSnapshotExcludedItem] = []
if purpose is None:
for contact in contacts:
for email in contact.emails:
recipients.append(_recipient_snapshot_item(contact, email))
else:
for contact in contacts:
included_rows, excluded_rows = _governed_email_snapshot_rows(
session,
principal,
contact,
purpose=purpose,
)
recipients.extend(included_rows)
excluded.extend(excluded_rows)
revision = _recipient_source_revision(book, _address_book_contact_updated_at(session, [book.id]).get(book.id))
return RecipientSourceSnapshot(
source_id=f"addresses:address_book:{book.id}",
@@ -298,12 +347,18 @@ class AddressesRecipientSourceCapability:
source_revision=revision,
generated_at=utcnow().isoformat(),
recipients=tuple(recipients),
excluded=tuple(excluded),
purpose=purpose,
requested_channels=requested_channels,
provenance={
"module": "addresses",
"address_book_id": book.id,
"scope_type": book.scope_type,
"scope_id": book.scope_id,
"tenant_id": book.tenant_id,
"governance_applied": purpose is not None,
"included_count": len(recipients),
"excluded_count": len(excluded),
},
)
@@ -313,15 +368,31 @@ class AddressesRecipientSourceCapability:
principal: ApiPrincipal,
*,
address_list_id: str,
purpose: str | None = None,
requested_channels: tuple[str, ...] = ("email",),
) -> RecipientSourceSnapshot:
address_list = get_visible_address_list(session, principal, address_list_id)
entries = list_address_list_entries(session, principal, address_list.id)
recipients: list[RecipientSnapshotItem] = []
excluded: list[RecipientSnapshotExcludedItem] = []
for entry in entries:
email = _entry_email(entry)
if email is None:
continue
recipients.append(_recipient_snapshot_item(entry.contact, email, address_list=address_list, address_list_entry=entry))
if purpose is None:
recipients.append(_recipient_snapshot_item(entry.contact, email, address_list=address_list, address_list_entry=entry))
continue
included_rows, excluded_rows = _governed_email_snapshot_rows(
session,
principal,
entry.contact,
purpose=purpose,
email_ids={email.id},
address_list=address_list,
address_list_entry=entry,
)
recipients.extend(included_rows)
excluded.extend(excluded_rows)
revision = _address_list_source_revision(address_list, _address_list_updated_at(session, [address_list.id]).get(address_list.id))
return RecipientSourceSnapshot(
source_id=f"addresses:address_list:{address_list.id}",
@@ -330,6 +401,9 @@ class AddressesRecipientSourceCapability:
source_revision=revision,
generated_at=utcnow().isoformat(),
recipients=tuple(recipients),
excluded=tuple(excluded),
purpose=purpose,
requested_channels=requested_channels,
provenance={
"module": "addresses",
"address_book_id": address_list.address_book_id,
@@ -337,6 +411,9 @@ class AddressesRecipientSourceCapability:
"scope_type": address_list.address_book.scope_type,
"scope_id": address_list.address_book.scope_id,
"tenant_id": address_list.tenant_id,
"governance_applied": purpose is not None,
"included_count": len(recipients),
"excluded_count": len(excluded),
},
)
@@ -346,16 +423,157 @@ class AddressesRecipientSourceCapability:
principal: ApiPrincipal,
*,
source_id: str,
purpose: str | None = None,
requested_channels: tuple[str, ...] = ("email",),
) -> RecipientSourceSnapshot:
book_prefix = "addresses:address_book:"
if source_id.startswith(book_prefix):
return self.snapshot_address_book(session, principal, address_book_id=source_id.removeprefix(book_prefix))
return self.snapshot_address_book(
session,
principal,
address_book_id=source_id.removeprefix(book_prefix),
purpose=purpose,
requested_channels=requested_channels,
)
list_prefix = "addresses:address_list:"
if source_id.startswith(list_prefix):
return self.snapshot_address_list(session, principal, address_list_id=source_id.removeprefix(list_prefix))
return self.snapshot_address_list(
session,
principal,
address_list_id=source_id.removeprefix(list_prefix),
purpose=purpose,
requested_channels=requested_channels,
)
raise ValueError(f"Unsupported addresses recipient source id: {source_id}")
class AddressesChannelFactsCapability:
"""Resolve effective contact-point facts without making a Policy decision."""
def resolve_channel_facts(
self,
session: Any,
principal: ApiPrincipal,
*,
request: RecipientChannelFactsRequest,
) -> RecipientChannelFacts:
if request.tenant_id != principal.tenant_id:
raise PermissionError("Channel facts cannot be resolved across tenants.")
contact_id = _channel_facts_contact_id(request.source)
contact = get_visible_contact(session, principal, contact_id)
effective_at = _aware_datetime(request.effective_at)
active_rules = [
rule
for rule in contact.channel_rules
if _rule_matches_purpose(rule, request.purpose)
and _rule_is_effective(rule, effective_at)
]
expired_rules = [
rule
for rule in contact.channel_rules
if _rule_matches_purpose(rule, request.purpose)
and rule.effective_until is not None
and _aware_datetime(rule.effective_until) <= effective_at
]
revision, fingerprint = _contact_channel_revision(contact)
source = DistributionSourceReference(
provider="addresses",
resource_type="contact",
resource_id=contact.id,
revision=revision,
fingerprint=fingerprint,
label=contact.display_name,
metadata={
"address_book_id": contact.address_book_id,
"source_kind": contact.source_kind,
"source_ref": contact.source_ref,
},
)
requested_channels = set(request.requested_channels)
candidates: list[DistributionChannelCandidate] = []
for channel, point_id, target, target_key, intrinsically_valid in _contact_channel_points(contact):
if requested_channels and channel not in requested_channels:
continue
matching = [
rule
for rule in active_rules
if rule.channel == channel
and (rule.contact_point_id is None or rule.contact_point_id == point_id)
]
selected = max(matching, key=_channel_rule_priority, default=None)
state = selected.decision if selected is not None else "unknown"
status = _channel_candidate_status(state, intrinsically_valid=intrinsically_valid)
reason_code = (
f"addresses.channel.{state}"
if intrinsically_valid
else f"addresses.{channel}.invalid"
)
explanation = (
_channel_rule_explanation(selected)
if selected is not None
else (
f"The {channel.replace('_', ' ')} contact point has no applicable governance fact."
if intrinsically_valid
else f"The {channel.replace('_', ' ')} contact point is incomplete or invalid."
)
)
candidates.append(
DistributionChannelCandidate(
channel=channel,
target=target,
target_key=target_key,
status=status,
contact_point_id=point_id,
locale=selected.locale if selected is not None else None,
preferred=any(rule.decision == "preferred" for rule in matching),
reason_code=reason_code,
explanation=explanation,
source=source,
decision_provenance={
"provider": "addresses",
"governance_state": state,
"effective_at": effective_at.isoformat(),
"purpose": request.purpose,
"selected_rule_id": selected.id if selected is not None else None,
"rule_ids": [rule.id for rule in matching],
"legal_basis": selected.legal_basis if selected is not None else None,
"evidence_ref": selected.evidence_ref if selected is not None else None,
"preference_rank": selected.preference_rank if selected is not None else None,
},
)
)
explanations = tuple(
DistributionExplanation(
code="addresses.channel_fact.expired",
message=(
f"A {rule.channel.replace('_', ' ')} governance fact expired and was not applied."
),
severity="info",
provider="addresses",
source=source,
provenance={"rule_id": rule.id, "decision": rule.decision},
)
for rule in expired_rules
)
return RecipientChannelFacts(
candidates=tuple(candidates),
explanations=explanations,
source_revision=revision,
source_fingerprint=fingerprint,
provenance={
"module": "addresses",
"contact_id": contact.id,
"address_book_id": contact.address_book_id,
"effective_at": effective_at.isoformat(),
"purpose": request.purpose,
"active_rule_ids": [rule.id for rule in active_rules],
"expired_rule_ids": [rule.id for rule in expired_rules],
"source_revision": revision,
"source_fingerprint": fingerprint,
},
)
def lookup_capability(_context: Any) -> AddressesLookupCapability:
return AddressesLookupCapability()
@@ -368,6 +586,10 @@ def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapabi
return AddressesRecipientSourceCapability()
def channel_facts_capability(_context: Any) -> AddressesChannelFactsCapability:
return AddressesChannelFactsCapability()
def contact_writer_capability(_context: Any) -> AddressesContactWriterCapability:
return AddressesContactWriterCapability()
@@ -484,6 +706,7 @@ def _recipient_snapshot_item(
*,
address_list: AddressList | None = None,
address_list_entry: AddressListEntry | None = None,
channel_decision: dict[str, Any] | None = None,
) -> RecipientSnapshotItem:
primary_phone = next((phone.phone for phone in contact.phones if phone.is_primary), contact.phones[0].phone if contact.phones else None)
provenance = {
@@ -501,6 +724,8 @@ def _recipient_snapshot_item(
"address_list_entry_kind": address_list_entry.target_kind,
}
)
if channel_decision is not None:
provenance["channel_decision"] = dict(channel_decision)
return RecipientSnapshotItem(
contact_id=contact.id,
display_name=contact.display_name,
@@ -518,6 +743,91 @@ def _recipient_snapshot_item(
)
def _governed_email_snapshot_rows(
session: Any,
principal: ApiPrincipal,
contact: Contact,
*,
purpose: str,
email_ids: set[str] | None = None,
address_list: AddressList | None = None,
address_list_entry: AddressListEntry | None = None,
) -> tuple[list[RecipientSnapshotItem], list[RecipientSnapshotExcludedItem]]:
effective_at = utcnow()
facts = AddressesChannelFactsCapability().resolve_channel_facts(
session,
principal,
request=RecipientChannelFactsRequest(
tenant_id=principal.tenant_id,
source=DistributionSourceReference(
provider="addresses",
resource_type="contact",
resource_id=contact.id,
),
recipient_key=f"contact:{contact.id}",
effective_at=effective_at,
purpose=purpose,
requested_channels=("email",),
),
)
emails_by_id = {item.id: item for item in contact.emails}
included: list[RecipientSnapshotItem] = []
excluded: list[RecipientSnapshotExcludedItem] = []
for candidate in facts.candidates:
if candidate.channel != "email":
continue
point_id = candidate.contact_point_id
if email_ids is not None and point_id not in email_ids:
continue
email = emails_by_id.get(point_id or "")
if email is None:
continue
decision = {
**dict(candidate.decision_provenance),
"status": candidate.status,
"reason_code": candidate.reason_code,
"explanation": candidate.explanation,
"source_revision": facts.source_revision,
"source_fingerprint": facts.source_fingerprint,
}
if candidate.status in {"usable", "stale"}:
included.append(
_recipient_snapshot_item(
contact,
email,
address_list=address_list,
address_list_entry=address_list_entry,
channel_decision=decision,
)
)
else:
provenance = {
**_contact_provenance(contact),
"channel_decision": decision,
}
if address_list is not None and address_list_entry is not None:
provenance.update(
{
"address_list_id": address_list.id,
"address_list_entry_id": address_list_entry.id,
}
)
excluded.append(
RecipientSnapshotExcludedItem(
contact_id=contact.id,
display_name=contact.display_name,
channel="email",
target=email.email,
contact_point_id=email.id,
status=candidate.status,
reason_code=candidate.reason_code,
explanation=candidate.explanation,
provenance=provenance,
)
)
return included, excluded
def _contact_provenance(contact: Contact) -> dict[str, Any]:
return {
"module": "addresses",
@@ -638,7 +948,21 @@ def _address_book_contact_updated_at(session: Any, address_book_ids: list[str])
.group_by(Contact.address_book_id)
.all()
)
for address_book_id, updated_at in [*contact_rows, *deletion_rows, *email_rows, *phone_rows]:
postal_rows = (
session.query(Contact.address_book_id, func.max(ContactPostalAddress.updated_at))
.join(Contact, ContactPostalAddress.contact_id == Contact.id)
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_(None))
.group_by(Contact.address_book_id)
.all()
)
rule_rows = (
session.query(Contact.address_book_id, func.max(ContactChannelRule.updated_at))
.join(Contact, ContactChannelRule.contact_id == Contact.id)
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_(None))
.group_by(Contact.address_book_id)
.all()
)
for address_book_id, updated_at in [*contact_rows, *deletion_rows, *email_rows, *phone_rows, *postal_rows, *rule_rows]:
if updated_at is None:
continue
key = str(address_book_id)
@@ -670,7 +994,14 @@ def _address_list_updated_at(session: Any, address_list_ids: list[str]) -> dict[
.group_by(AddressListEntry.address_list_id)
.all()
)
for address_list_id, updated_at in [*entry_rows, *contact_rows, *email_rows]:
rule_rows = (
session.query(AddressListEntry.address_list_id, func.max(ContactChannelRule.updated_at))
.join(ContactChannelRule, AddressListEntry.contact_id == ContactChannelRule.contact_id)
.filter(AddressListEntry.address_list_id.in_(address_list_ids))
.group_by(AddressListEntry.address_list_id)
.all()
)
for address_list_id, updated_at in [*entry_rows, *contact_rows, *email_rows, *rule_rows]:
if updated_at is None:
continue
key = str(address_list_id)
@@ -690,3 +1021,150 @@ def _address_list_source_revision(address_list: AddressList, latest_entry_update
if latest_entry_updated_at is not None:
stamps.append(latest_entry_updated_at)
return max(stamps).isoformat()
def _channel_facts_contact_id(source: DistributionSourceReference) -> str:
metadata_contact_id = source.metadata.get("contact_id")
if metadata_contact_id:
return str(metadata_contact_id)
if source.resource_type not in {"contact", "address_contact", "address_email"}:
raise ValueError(f"Unsupported Addresses channel-facts source: {source.resource_type}.")
return source.resource_id
def _contact_channel_points(
contact: Contact,
) -> tuple[tuple[str, str | None, str, str, bool], ...]:
points: list[tuple[str, str | None, str, str, bool]] = []
for email in contact.emails:
target = email.email.strip()
valid = bool(target and "@" in target and len(target) <= 320)
points.append(("email", email.id, target, f"email:{target.casefold()}", valid))
for address in contact.postal_addresses:
target = ", ".join(
item
for item in (
address.street,
" ".join(part for part in (address.postal_code, address.locality) if part),
address.region,
address.country,
)
if item
)
points.append(("postal", address.id, target, f"postal:{' '.join(target.casefold().split())}", bool(target)))
provenance = dict(contact.provenance or {})
internal_target = provenance.get("internal_mail_account_id") or provenance.get("account_id")
if internal_target:
target = str(internal_target).strip()
points.append(("internal_mail", None, target, f"internal_mail:{target.casefold()}", bool(target)))
portal_target = provenance.get("portal_target") or provenance.get("portal_account_id")
if portal_target:
target = str(portal_target).strip()
points.append(("portal", None, target, f"portal:{target.casefold()}", bool(target)))
return tuple(points)
def _rule_matches_purpose(rule: ContactChannelRule, purpose: str | None) -> bool:
return rule.purpose is None or (purpose is not None and rule.purpose == purpose)
def _rule_is_effective(rule: ContactChannelRule, effective_at: datetime) -> bool:
return (
(rule.effective_from is None or _aware_datetime(rule.effective_from) <= effective_at)
and (rule.effective_until is None or _aware_datetime(rule.effective_until) > effective_at)
)
def _channel_rule_priority(rule: ContactChannelRule) -> tuple[int, int, int, datetime]:
decision_priority = {
"invalid": 90,
"returned": 80,
"suppressed": 70,
"opted_out": 60,
"temporarily_unavailable": 50,
"preferred": 30,
"opted_in": 20,
"allowed": 10,
}.get(rule.decision, 0)
return (
decision_priority,
int(rule.contact_point_id is not None),
int(rule.purpose is not None),
_aware_datetime(rule.updated_at),
)
def _channel_candidate_status(state: str, *, intrinsically_valid: bool) -> str:
if not intrinsically_valid or state in {"invalid", "returned"}:
return "invalid"
if state in {"suppressed", "opted_out", "temporarily_unavailable"}:
return "suppressed"
return "usable"
def _channel_rule_explanation(rule: ContactChannelRule) -> str:
if rule.reason:
return rule.reason
return {
"allowed": "Use of this contact point is explicitly allowed.",
"opted_in": "The contact opted in to this communication channel.",
"preferred": "This is a preferred communication channel.",
"opted_out": "The contact opted out of this communication channel.",
"suppressed": "This contact point is suppressed.",
"invalid": "This contact point is marked invalid.",
"returned": "Delivery to this contact point was returned.",
"temporarily_unavailable": "This contact point is temporarily unavailable.",
}.get(rule.decision, "An Addresses governance fact was applied.")
def _contact_channel_revision(contact: Contact) -> tuple[str, str]:
stamps = [contact.updated_at]
stamps.extend(item.updated_at for item in (*contact.emails, *contact.postal_addresses, *contact.channel_rules))
revision = max(_aware_datetime(item) for item in stamps).isoformat()
payload = {
"contact_id": contact.id,
"revision": revision,
"emails": [
{"id": item.id, "email": item.email, "primary": item.is_primary}
for item in contact.emails
],
"postal": [
{
"id": item.id,
"street": item.street,
"postal_code": item.postal_code,
"locality": item.locality,
"region": item.region,
"country": item.country,
"primary": item.is_primary,
}
for item in contact.postal_addresses
],
"rules": [
{
"id": rule.id,
"channel": rule.channel,
"purpose": rule.purpose,
"point": rule.contact_point_id,
"decision": rule.decision,
"basis": rule.legal_basis,
"evidence": rule.evidence_ref,
"rank": rule.preference_rank,
"locale": rule.locale,
"from": rule.effective_from.isoformat() if rule.effective_from else None,
"until": rule.effective_until.isoformat() if rule.effective_until else None,
"updated": rule.updated_at.isoformat(),
}
for rule in sorted(contact.channel_rules, key=lambda item: item.id)
],
}
fingerprint = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
return revision, fingerprint
def _aware_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value
@@ -92,6 +92,11 @@ class Contact(Base, TimestampMixin):
order_by="ContactPostalAddress.order_index",
)
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact", cascade="all, delete-orphan")
channel_rules: Mapped[list["ContactChannelRule"]] = relationship(
back_populates="contact",
cascade="all, delete-orphan",
order_by="ContactChannelRule.created_at",
)
class ContactEmail(Base, TimestampMixin):
@@ -145,6 +150,47 @@ class ContactPostalAddress(Base, TimestampMixin):
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_postal_address")
class ContactChannelRule(Base, TimestampMixin):
__tablename__ = "addresses_contact_channel_rules"
__table_args__ = (
Index(
"ix_addresses_channel_rules_resolution",
"tenant_id",
"contact_id",
"channel",
"purpose",
),
Index(
"ix_addresses_channel_rules_effective",
"effective_from",
"effective_until",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
channel: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
purpose: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
contact_point_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
decision: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
legal_basis: Mapped[str | None] = mapped_column(String(255), nullable=True)
evidence_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
preference_rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
locale: Mapped[str | None] = mapped_column(String(20), nullable=True)
effective_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
effective_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
contact: Mapped[Contact] = relationship(back_populates="channel_rules")
class AddressList(Base, TimestampMixin):
__tablename__ = "addresses_address_lists"
__table_args__ = (
+14 -2
View File
@@ -14,6 +14,7 @@ from govoplan_addresses.backend.db import models as addresses_models # noqa: F4
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
@@ -39,6 +40,7 @@ _addresses_table_retirement_provider = drop_table_retirement_provider(
addresses_models.ContactPostalAddress,
addresses_models.ContactPhone,
addresses_models.ContactEmail,
addresses_models.ContactChannelRule,
addresses_models.Contact,
addresses_models.AddressBook,
label="Addresses",
@@ -95,6 +97,8 @@ PERMISSIONS = (
_permission("addresses:contact:read", "View contacts", "List and lookup contacts in visible address books."),
_permission("addresses:contact:write", "Manage contacts", "Create and edit local contacts."),
_permission("addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."),
_permission("addresses:governance:read", "View communication governance", "Inspect effective-dated consent, suppression, and channel-preference facts."),
_permission("addresses:governance:write", "Manage communication governance", "Record and end consent, suppression, and channel-preference facts."),
_permission("addresses:sync:read", "View address sync", "Inspect address sync sources, conflicts, tombstones, and diagnostics."),
_permission("addresses:sync:write", "Manage address sync", "Bind address books to external sources and record sync state."),
_permission("addresses:sync:admin", "Administer address sync", "Administer address sync connectors and future destructive sync operations."),
@@ -116,6 +120,8 @@ ROLE_TEMPLATES = (
"addresses:contact:read",
"addresses:contact:write",
"addresses:contact:delete",
"addresses:governance:read",
"addresses:governance:write",
"addresses:sync:read",
"addresses:sync:write",
),
@@ -124,7 +130,7 @@ ROLE_TEMPLATES = (
slug="address_book_reader",
name="Address book reader",
description="Read visible address books and contacts.",
permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:sync:read"),
permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:governance:read", "addresses:sync:read"),
),
)
@@ -155,8 +161,9 @@ manifest = ModuleManifest(
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"),
),
permissions=PERMISSIONS,
route_factory=_addresses_router,
@@ -188,6 +195,10 @@ manifest = ModuleManifest(
"govoplan_addresses.backend.capabilities",
fromlist=["contact_writer_capability"],
).contact_writer_capability(context),
CAPABILITY_RECIPIENT_CHANNEL_FACTS: lambda context: __import__(
"govoplan_addresses.backend.capabilities",
fromlist=["channel_facts_capability"],
).channel_facts_capability(context),
},
uninstall_guard_providers=(
persistent_table_uninstall_guard(
@@ -202,6 +213,7 @@ manifest = ModuleManifest(
addresses_models.ContactEmail,
addresses_models.ContactPhone,
addresses_models.ContactPostalAddress,
addresses_models.ContactChannelRule,
label="Addresses",
),
),
@@ -0,0 +1,58 @@
"""Add effective-dated contact channel governance.
Revision ID: f2a4b5c6d7e
Revises: e1f2a4b5c6d
"""
from alembic import op
import sqlalchemy as sa
revision = "f2a4b5c6d7e"
down_revision = "e1f2a4b5c6d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"addresses_contact_channel_rules",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("contact_id", sa.String(length=36), nullable=False),
sa.Column("channel", sa.String(length=30), nullable=False),
sa.Column("purpose", sa.String(length=120), nullable=True),
sa.Column("contact_point_id", sa.String(length=36), nullable=True),
sa.Column("decision", sa.String(length=40), nullable=False),
sa.Column("legal_basis", sa.String(length=255), nullable=True),
sa.Column("evidence_ref", sa.String(length=1000), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("preference_rank", sa.Integer(), nullable=True),
sa.Column("locale", sa.String(length=20), nullable=True),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("effective_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["contact_id"], ["addresses_contacts.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_channel_rules_tenant_id", ["tenant_id"]),
("ix_addresses_contact_channel_rules_contact_id", ["contact_id"]),
("ix_addresses_contact_channel_rules_channel", ["channel"]),
("ix_addresses_contact_channel_rules_purpose", ["purpose"]),
("ix_addresses_contact_channel_rules_contact_point_id", ["contact_point_id"]),
("ix_addresses_contact_channel_rules_decision", ["decision"]),
("ix_addresses_contact_channel_rules_effective_from", ["effective_from"]),
("ix_addresses_contact_channel_rules_effective_until", ["effective_until"]),
("ix_addresses_contact_channel_rules_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_channel_rules_resolution", ["tenant_id", "contact_id", "channel", "purpose"]),
("ix_addresses_channel_rules_effective", ["effective_from", "effective_until"]),
):
op.create_index(name, "addresses_contact_channel_rules", columns, unique=False)
def downgrade() -> None:
op.drop_table("addresses_contact_channel_rules")
+102
View File
@@ -19,6 +19,7 @@ from govoplan_addresses.backend.db.models import (
AddressSyncSource,
AddressSyncTombstone,
Contact,
ContactChannelRule,
ContactPostalAddress,
)
from govoplan_addresses.backend.capabilities import AddressesContactWriterCapability
@@ -62,6 +63,9 @@ from govoplan_addresses.backend.schemas import (
AddressSyncTombstoneListResponse,
AddressSyncTombstoneResponse,
ContactCreateRequest,
ContactChannelRuleCreateRequest,
ContactChannelRuleListResponse,
ContactChannelRuleResponse,
ContactListResponse,
ContactResponse,
ContactUpdateRequest,
@@ -79,6 +83,7 @@ from govoplan_addresses.backend.service import (
create_address_list_entry,
create_carddav_sync_source,
create_contact,
create_contact_channel_rule,
create_sync_source,
count_contacts,
delete_address_book,
@@ -89,12 +94,14 @@ from govoplan_addresses.backend.service import (
discover_carddav_address_books,
export_address_book_vcard,
export_contact_vcard,
end_contact_channel_rule,
finish_sync_attempt,
import_vcards,
list_address_list_entries,
list_address_lists,
list_address_books,
list_contacts,
list_contact_channel_rules,
list_sync_conflicts,
list_sync_diagnostics,
list_sync_sources,
@@ -156,6 +163,10 @@ def _contact_response(contact: Contact) -> ContactResponse:
return ContactResponse.model_validate(contact)
def _channel_rule_response(rule: ContactChannelRule) -> ContactChannelRuleResponse:
return ContactChannelRuleResponse.model_validate(rule)
def _address_list_response(address_list: AddressList, *, entry_count: int = 0) -> AddressListResponse:
return AddressListResponse.model_validate(
{
@@ -1081,6 +1092,97 @@ def api_update_contact(
raise _error(exc) from exc
@router.get("/contacts/{contact_id}/channel-rules", response_model=ContactChannelRuleListResponse)
def api_list_contact_channel_rules(
contact_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:governance:read")
try:
return ContactChannelRuleListResponse(
rules=[
_channel_rule_response(rule)
for rule in list_contact_channel_rules(session, principal, contact_id)
]
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.post(
"/contacts/{contact_id}/channel-rules",
response_model=ContactChannelRuleResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_contact_channel_rule(
contact_id: str,
payload: ContactChannelRuleCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:governance:write")
try:
rule = create_contact_channel_rule(session, principal, contact_id, payload)
_audit_address_sync(
session,
principal,
action="addresses.channel_rule_created",
object_type="address_contact_channel_rule",
object_id=rule.id,
details={
"contact_id": contact_id,
"channel": rule.channel,
"purpose": rule.purpose,
"contact_point_id": rule.contact_point_id,
"decision": rule.decision,
"effective_from": rule.effective_from.isoformat() if rule.effective_from else None,
"effective_until": rule.effective_until.isoformat() if rule.effective_until else None,
"evidence_ref": rule.evidence_ref,
},
)
session.commit()
session.refresh(rule)
return _channel_rule_response(rule)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.delete(
"/contact-channel-rules/{rule_id}",
response_model=ContactChannelRuleResponse,
)
def api_end_contact_channel_rule(
rule_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:governance:write")
try:
rule = end_contact_channel_rule(session, principal, rule_id)
_audit_address_sync(
session,
principal,
action="addresses.channel_rule_ended",
object_type="address_contact_channel_rule",
object_id=rule.id,
details={
"contact_id": rule.contact_id,
"channel": rule.channel,
"purpose": rule.purpose,
"decision": rule.decision,
"effective_until": rule.effective_until.isoformat() if rule.effective_until else None,
},
)
session.commit()
session.refresh(rule)
return _channel_rule_response(rule)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_contact(
contact_id: str,
+55
View File
@@ -14,6 +14,17 @@ AddressSyncConflictStatus = Literal["open", "resolved", "ignored"]
AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "manual", "ignored"]
AddressCardDavAuthType = Literal["none", "basic", "bearer"]
AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"]
AddressDistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
AddressChannelDecision = Literal[
"allowed",
"opted_in",
"preferred",
"opted_out",
"suppressed",
"invalid",
"returned",
"temporarily_unavailable",
]
class ContactEmailPayload(BaseModel):
@@ -227,6 +238,50 @@ class ContactListResponse(BaseModel):
has_more: bool
class ContactChannelRuleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
channel: AddressDistributionChannel
purpose: str | None = Field(default=None, max_length=120)
contact_point_id: str | None = Field(default=None, max_length=36)
decision: AddressChannelDecision
legal_basis: str | None = Field(default=None, max_length=255)
evidence_ref: str | None = Field(default=None, max_length=1000)
reason: str | None = None
preference_rank: int | None = Field(default=None, ge=0, le=10000)
locale: str | None = Field(default=None, max_length=20)
effective_from: datetime | None = None
effective_until: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ContactChannelRuleResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
contact_id: str
channel: AddressDistributionChannel
purpose: str | None = None
contact_point_id: str | None = None
decision: AddressChannelDecision
legal_basis: str | None = None
evidence_ref: str | None = None
reason: str | None = None
preference_rank: int | None = None
locale: str | None = None
effective_from: datetime | None = None
effective_until: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
created_by_account_id: str | None = None
created_at: datetime
updated_at: datetime
class ContactChannelRuleListResponse(BaseModel):
rules: list[ContactChannelRuleResponse] = Field(default_factory=list)
class AddressLookupResponse(BaseModel):
contacts: list[ContactResponse]
+76
View File
@@ -43,6 +43,7 @@ from govoplan_addresses.backend.db.models import (
AddressSyncSource,
AddressSyncTombstone,
Contact,
ContactChannelRule,
ContactEmail,
ContactPhone,
ContactPostalAddress,
@@ -68,6 +69,7 @@ from govoplan_addresses.backend.schemas import (
ContactPhonePayload,
ContactPostalAddressPayload,
ContactUpdateRequest,
ContactChannelRuleCreateRequest,
)
from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues
@@ -2514,6 +2516,80 @@ def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: s
return contact
def list_contact_channel_rules(
session: Session,
principal: ApiPrincipal,
contact_id: str,
) -> list[ContactChannelRule]:
contact = get_visible_contact(session, principal, contact_id)
return (
session.query(ContactChannelRule)
.filter(ContactChannelRule.contact_id == contact.id)
.order_by(ContactChannelRule.created_at.desc(), ContactChannelRule.id.asc())
.all()
)
def create_contact_channel_rule(
session: Session,
principal: ApiPrincipal,
contact_id: str,
payload: ContactChannelRuleCreateRequest,
) -> ContactChannelRule:
contact = get_visible_contact(session, principal, contact_id)
if (
payload.effective_from is not None
and payload.effective_until is not None
and payload.effective_until <= payload.effective_from
):
raise AddressBookError("Channel-rule end must be after its start.")
point_ids: set[str] = set()
if payload.channel == "email":
point_ids = {item.id for item in contact.emails}
elif payload.channel == "postal":
point_ids = {item.id for item in contact.postal_addresses}
if payload.contact_point_id and payload.contact_point_id not in point_ids:
raise AddressBookError(
"The selected contact point does not belong to this contact and channel."
)
rule = ContactChannelRule(
tenant_id=contact.tenant_id,
contact_id=contact.id,
channel=payload.channel,
purpose=_trim(payload.purpose),
contact_point_id=payload.contact_point_id,
decision=payload.decision,
legal_basis=_trim(payload.legal_basis),
evidence_ref=_trim(payload.evidence_ref),
reason=_trim(payload.reason),
preference_rank=payload.preference_rank,
locale=_trim(payload.locale),
effective_from=payload.effective_from,
effective_until=payload.effective_until,
created_by_account_id=_account_id(principal),
metadata_=dict(payload.metadata),
)
session.add(rule)
session.flush()
return rule
def end_contact_channel_rule(
session: Session,
principal: ApiPrincipal,
rule_id: str,
) -> ContactChannelRule:
rule = session.get(ContactChannelRule, rule_id)
if rule is None:
raise AddressBookError("Contact channel rule not found.")
get_visible_contact(session, principal, rule.contact_id)
now = utcnow()
if rule.effective_until is None or rule.effective_until > now:
rule.effective_until = now
session.add(rule)
return rule
def update_contact(session: Session, principal: ApiPrincipal, contact_id: str, payload: ContactUpdateRequest) -> Contact:
contact = get_visible_contact(session, principal, contact_id)
_require_mutable_book(contact.address_book)