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