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