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
+130
View File
@@ -1,12 +1,18 @@
from __future__ import annotations
import unittest
from datetime import timedelta
from unittest.mock import patch
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.distribution_lists import (
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
DistributionSourceReference,
RecipientChannelFactsRequest,
)
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
from govoplan_core.db.base import Base
from govoplan_core.db.base import utcnow
@@ -19,6 +25,7 @@ from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_CONTACT_WRITER,
CAPABILITY_ADDRESSES_LOOKUP,
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
AddressesChannelFactsCapability,
AddressesContactWriterCapability,
AddressesLookupCapability,
AddressesPeopleSearchProvider,
@@ -34,6 +41,7 @@ from govoplan_addresses.backend.db.models import (
AddressSyncSource,
AddressSyncTombstone,
Contact,
ContactChannelRule,
ContactEmail,
ContactPhone,
ContactPostalAddress,
@@ -52,6 +60,7 @@ from govoplan_addresses.backend.schemas import (
AddressSyncSourceUpdateRequest,
AddressSyncTombstoneCreateRequest,
ContactCreateRequest,
ContactChannelRuleCreateRequest,
ContactEmailPayload,
ContactPostalAddressPayload,
)
@@ -66,10 +75,12 @@ 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_list_entry,
delete_contact,
end_contact_channel_rule,
delete_sync_source,
discover_carddav_address_books,
export_address_book_vcard,
@@ -78,6 +89,7 @@ from govoplan_addresses.backend.service import (
list_address_lists,
list_address_books,
list_contacts,
list_contact_channel_rules,
list_sync_conflicts,
list_sync_diagnostics,
list_sync_sources,
@@ -147,6 +159,8 @@ class Principal:
"addresses:contact:read",
"addresses:contact:write",
"addresses:contact:delete",
"addresses:governance:read",
"addresses:governance:write",
"addresses:sync:read",
"addresses:sync:write",
}
@@ -181,6 +195,7 @@ class AddressServiceTest(unittest.TestCase):
ContactEmail.__table__,
ContactPhone.__table__,
ContactPostalAddress.__table__,
ContactChannelRule.__table__,
AddressListEntry.__table__,
AddressSyncSource.__table__,
AddressSyncTombstone.__table__,
@@ -381,6 +396,7 @@ END:VCARD
self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided)
self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided)
self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided)
self.assertIn(CAPABILITY_RECIPIENT_CHANNEL_FACTS, provided)
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients"))
self.session.commit()
@@ -459,6 +475,120 @@ END:VCARD
)
self.assertEqual(blocked.exception.decision.reason, "address_book_read_only")
def test_channel_facts_are_effective_purpose_aware_and_provenance_bearing(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Governed recipients"),
)
self.session.flush()
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
emails=[ContactEmailPayload(email="ada@example.local", is_primary=True)],
postal_addresses=[
ContactPostalAddressPayload(
street="Main Street 1",
postal_code="10115",
locality="Berlin",
country="Germany",
is_primary=True,
)
],
),
)
self.session.flush()
rule = create_contact_channel_rule(
self.session,
self.principal,
contact.id,
ContactChannelRuleCreateRequest(
channel="email",
purpose="campaign_delivery",
contact_point_id=contact.emails[0].id,
decision="opted_out",
legal_basis="consent",
evidence_ref="case:consent-42",
reason="Recipient withdrew email consent.",
),
)
self.session.commit()
source = DistributionSourceReference(
provider="addresses",
resource_type="contact",
resource_id=contact.id,
)
facts = AddressesChannelFactsCapability().resolve_channel_facts(
self.session,
self.principal,
request=RecipientChannelFactsRequest(
tenant_id=self.principal.tenant_id,
source=source,
recipient_key=f"contact:{contact.id}",
effective_at=utcnow() + timedelta(seconds=1),
purpose="campaign_delivery",
requested_channels=("email", "postal"),
),
)
candidates = {item.channel: item for item in facts.candidates}
self.assertEqual(candidates["email"].status, "suppressed")
self.assertEqual(candidates["email"].reason_code, "addresses.channel.opted_out")
self.assertEqual(candidates["email"].decision_provenance["selected_rule_id"], rule.id)
self.assertEqual(candidates["email"].decision_provenance["evidence_ref"], "case:consent-42")
self.assertEqual(candidates["postal"].status, "usable")
self.assertEqual(candidates["postal"].decision_provenance["governance_state"], "unknown")
self.assertTrue(facts.source_revision)
self.assertEqual(len(facts.source_fingerprint or ""), 64)
governed_snapshot = AddressesRecipientSourceCapability().snapshot_address_book(
self.session,
self.principal,
address_book_id=book.id,
purpose="campaign_delivery",
)
self.assertEqual(governed_snapshot.recipients, ())
self.assertEqual(len(governed_snapshot.excluded), 1)
self.assertEqual(governed_snapshot.excluded[0].reason_code, "addresses.channel.opted_out")
self.assertTrue(governed_snapshot.provenance["governance_applied"])
unrelated = AddressesChannelFactsCapability().resolve_channel_facts(
self.session,
self.principal,
request=RecipientChannelFactsRequest(
tenant_id=self.principal.tenant_id,
source=source,
recipient_key=f"contact:{contact.id}",
effective_at=utcnow() + timedelta(seconds=1),
purpose="service_notice",
requested_channels=("email",),
),
)
self.assertEqual(unrelated.candidates[0].status, "usable")
self.assertEqual(unrelated.candidates[0].decision_provenance["governance_state"], "unknown")
ended = end_contact_channel_rule(self.session, self.principal, rule.id)
self.session.commit()
expired = AddressesChannelFactsCapability().resolve_channel_facts(
self.session,
self.principal,
request=RecipientChannelFactsRequest(
tenant_id=self.principal.tenant_id,
source=source,
recipient_key=f"contact:{contact.id}",
effective_at=utcnow() + timedelta(seconds=1),
purpose="campaign_delivery",
requested_channels=("email",),
),
)
self.assertIsNotNone(ended.effective_until)
self.assertEqual(expired.candidates[0].status, "usable")
self.assertEqual(expired.explanations[0].code, "addresses.channel_fact.expired")
self.assertEqual(list_contact_channel_rules(self.session, self.principal, contact.id)[0].id, rule.id)
def test_address_lists_group_contacts_and_expose_recipient_sources(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal"))
other_book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Other"))