Add governed contact point snapshots

This commit is contained in:
2026-08-02 06:20:24 +02:00
parent 67392f620f
commit 2e78b9ae50
10 changed files with 1620 additions and 16 deletions
+27 -5
View File
@@ -74,13 +74,16 @@ It must not own:
## First Capabilities ## First Capabilities
The module exposes three core-mediated capabilities: The module exposes four core-mediated capabilities:
- `addresses.lookup`: read-only contact/recipient lookup for autocomplete. - `addresses.lookup`: read-only contact/recipient lookup for autocomplete.
- `addresses.recipient_source`: immutable recipient snapshots for campaign, - `addresses.recipient_source`: immutable recipient snapshots for campaign,
reporting, mail-build, forms, portal, and postbox workflows. reporting, mail-build, forms, portal, and postbox workflows.
- `addresses.contact_writer`: address-book-scoped write decisions and contact - `addresses.contact_writer`: address-book-scoped write decisions and contact
creation for local or otherwise writable sources. creation for local or otherwise writable sources.
- `addresses.contact_point_resolution`: purpose-aware, channel-neutral
resolution and immutable snapshots for email, postal, internal-mail, and
portal targets.
`addresses.recipient_source` returns: `addresses.recipient_source` returns:
@@ -98,10 +101,13 @@ address. Email-oriented consumers snapshot email targets and whole-contact
entries with a usable email address; postal-only entries remain valid list entries with a usable email address; postal-only entries remain valid list
members for later postal/document workflows. members for later postal/document workflows.
Consumers must store their own immutable snapshot when they need historical Legacy `addresses.recipient_source` consumers must store their own immutable
evidence. The addresses module remains the owner of the reusable source, not of snapshot when they need historical evidence. Channel-neutral consumers may use
the consumer's historical records. Consumers must resolve these capabilities the dedicated freeze operation described below. The addresses module remains
through the platform registry and must not import address ORM/service internals. the owner of reusable sources; domain consumers remain responsible for linking
their own records to snapshot evidence. Consumers must resolve these
capabilities through the platform registry and must not import address
ORM/service internals.
`addresses.contact_writer` returns an explicit decision before a consumer shows `addresses.contact_writer` returns an explicit decision before a consumer shows
or executes write actions: allowed/blocked, reason, user-facing message, or executes write actions: allowed/blocked, reason, user-facing message,
@@ -109,6 +115,22 @@ required scopes, source kind, read-only state, and provenance. The decision is
address-book specific; broader policy modules may later contribute to the same address-book specific; broader policy modules may later contribute to the same
decision path, but consumers should not import or duplicate policy logic. decision path, but consumers should not import or duplicate policy logic.
For channel-neutral consumers, `addresses.contact_point_resolution` supersedes
the email-only shape without removing it. It accepts local contact IDs and
stable provider references such as `idm:identity:<id>`, applies an effective
date, communication purpose, address purpose, fallback rule, locale, and
domestic/international postal formatting, and returns candidates plus excluded
targets with stable reason codes. Bounded previews are live. A frozen snapshot
stores the complete values, source and governance revisions, provenance, and a
deterministic hash in Addresses so later contact edits cannot rewrite evidence.
The corresponding HTTP API is available below `/api/v1/addresses`:
- `POST /contact-points/resolve`
- `POST /contact-point-sources/preview`
- `POST /contact-point-snapshots`
- `GET /contact-point-snapshots/{snapshot_id}`
## Design Documents ## Design Documents
- [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md) - [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md)
+20 -4
View File
@@ -87,6 +87,8 @@ The first stable capabilities are:
campaign, scheduling, postbox, portal, and case workflows. campaign, scheduling, postbox, portal, and case workflows.
- `addresses.contact_writer`: provide address-book-scoped write target decisions - `addresses.contact_writer`: provide address-book-scoped write target decisions
and contact creation for local or otherwise writable sources. and contact creation for local or otherwise writable sources.
- `addresses.contact_point_resolution` version 1.x: resolve channel-neutral
contact points and freeze immutable recipient evidence.
Capabilities use DTOs and source IDs. Consumers must not receive ORM objects or Capabilities use DTOs and source IDs. Consumers must not receive ORM objects or
write address tables directly. Consumers that need historical evidence must write address tables directly. Consumers that need historical evidence must
@@ -96,10 +98,24 @@ provenance; they must not treat live address records as historical evidence.
`addresses.recipient_source` exposes both complete address books and classical `addresses.recipient_source` exposes both complete address books and classical
address lists. Address-book sources use `addresses:address_book:<id>`. address lists. Address-book sources use `addresses:address_book:<id>`.
Address-list sources use `addresses:address_list:<id>` and include the Address-list sources use `addresses:address_list:<id>` and include the
address-list entry ID in each recipient's provenance. The current snapshot DTO address-list entry ID in each recipient's provenance. The legacy snapshot DTO
is email-recipient oriented; postal-only list entries are valid address-list remains email-oriented for compatible campaign consumers.
members but are skipped by the email recipient-source path until postal
recipient DTOs are added. Channel-neutral consumers use `addresses.contact_point_resolution`, which
supports email, postal, internal-mail, and portal targets, including postal-only
address-list entries. Requests make effective date, communication purpose,
address purpose, fallback behavior, locale, and domestic/international postal
formatting explicit. Results retain stable subject/contact/contact-point IDs,
source, preference and consent revisions, provenance, and reasons for excluded
or unresolved candidates.
Live previews are bounded to 500 rows per page and 20,000 source members per
request. Frozen snapshots persist resolved values and exclusions with a
deterministic hash; reading a snapshot never resolves the live contact again.
Mixed-audience expansion and final cross-provider Policy/channel decisions
remain owned by Distribution Lists and Policy. The contract is defined in Core,
and Addresses does not import IDM, Organizations, or Distribution Lists
implementations.
The writer capability is intentionally address-book specific. It answers The writer capability is intentionally address-book specific. It answers
whether the current principal may perform an operation such as `create_contact`, whether the current principal may perform an operation such as `create_contact`,
+7
View File
@@ -73,6 +73,11 @@ Tasks:
- [x] define immutable recipient snapshot DTOs - [x] define immutable recipient snapshot DTOs
- [x] expose source provenance in capability responses - [x] expose source provenance in capability responses
- [x] expose classical address lists as `addresses.recipient_source` sources - [x] expose classical address lists as `addresses.recipient_source` sources
- [x] expose versioned channel-neutral contact-point resolution for local and
stable provider subject references
- [x] support purpose/address-purpose selection, deterministic fallback,
locale, and domestic/international postal rendering
- [x] add bounded source previews and immutable postal/email snapshots
- [x] add module presence/capability tests - [x] add module presence/capability tests
- [x] document consumer rules for campaign, mail, scheduling, portal, postbox, and - [x] document consumer rules for campaign, mail, scheduling, portal, postbox, and
reporting reporting
@@ -82,6 +87,8 @@ Exit criteria:
- [x] campaign can request a recipient source via core-mediated capability - [x] campaign can request a recipient source via core-mediated capability
- [x] mail/scheduling can request autocomplete candidates via core-mediated lookup - [x] mail/scheduling can request autocomplete candidates via core-mediated lookup
- [x] consumers do not import `govoplan_addresses` - [x] consumers do not import `govoplan_addresses`
- [x] postal-only contacts/list entries can be resolved without changing the
legacy email recipient-source contract
## Milestone 4: Campaign Integration ## Milestone 4: Campaign Integration
File diff suppressed because it is too large Load Diff
@@ -56,6 +56,11 @@ class Contact(Base, TimestampMixin):
__table_args__ = ( __table_args__ = (
Index("ix_addresses_contacts_book_name", "address_book_id", "display_name"), Index("ix_addresses_contacts_book_name", "address_book_id", "display_name"),
Index("ix_addresses_contacts_tenant_name", "tenant_id", "display_name"), Index("ix_addresses_contacts_tenant_name", "tenant_id", "display_name"),
Index(
"ix_addresses_contacts_source_ref",
"source_ref",
postgresql_using="hash",
),
) )
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
@@ -191,6 +196,31 @@ class ContactChannelRule(Base, TimestampMixin):
contact: Mapped[Contact] = relationship(back_populates="channel_rules") contact: Mapped[Contact] = relationship(back_populates="channel_rules")
class ContactPointSnapshot(Base, TimestampMixin):
__tablename__ = "addresses_contact_point_snapshots"
__table_args__ = (
Index("ix_addresses_contact_point_snapshots_source", "tenant_id", "source_id", "created_at"),
Index("ix_addresses_contact_point_snapshots_hash", "tenant_id", "snapshot_hash"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
source_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
contract_version: Mapped[str] = mapped_column(String(20), nullable=False)
source_revision: Mapped[str] = mapped_column(String(255), nullable=False)
source_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
purpose: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
effective_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
request_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
resolution_payload: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False)
recipient_count: Mapped[int] = mapped_column(Integer, nullable=False)
excluded_count: Mapped[int] = mapped_column(Integer, nullable=False)
snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class AddressList(Base, TimestampMixin): class AddressList(Base, TimestampMixin):
__tablename__ = "addresses_address_lists" __tablename__ = "addresses_address_lists"
__table_args__ = ( __table_args__ = (
@@ -371,6 +401,7 @@ __all__ = [
"Contact", "Contact",
"ContactEmail", "ContactEmail",
"ContactPhone", "ContactPhone",
"ContactPointSnapshot",
"ContactPostalAddress", "ContactPostalAddress",
"new_uuid", "new_uuid",
] ]
+32 -1
View File
@@ -12,6 +12,7 @@ from govoplan_addresses.backend.capabilities import (
) )
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.contact_points import CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard 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.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS
@@ -42,6 +43,7 @@ from govoplan_addresses.backend.provider_state import (
_addresses_table_retirement_provider = drop_table_retirement_provider( _addresses_table_retirement_provider = drop_table_retirement_provider(
addresses_models.ContactPointSnapshot,
addresses_models.AddressSyncDiagnostic, addresses_models.AddressSyncDiagnostic,
addresses_models.AddressSyncConflict, addresses_models.AddressSyncConflict,
addresses_models.AddressSyncTombstone, addresses_models.AddressSyncTombstone,
@@ -147,12 +149,19 @@ ROLE_TEMPLATES = (
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressSyncSource, Contact from govoplan_addresses.backend.db.models import (
AddressBook,
AddressList,
AddressSyncSource,
Contact,
ContactPointSnapshot,
)
return { return {
"address_books": session.query(AddressBook).filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None)).count(), "address_books": session.query(AddressBook).filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None)).count(),
"address_lists": session.query(AddressList).filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None)).count(), "address_lists": session.query(AddressList).filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None)).count(),
"contacts": session.query(Contact).filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None)).count(), "contacts": session.query(Contact).filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None)).count(),
"contact_point_snapshots": session.query(ContactPointSnapshot).filter(ContactPointSnapshot.tenant_id == tenant_id).count(),
"sync_sources": session.query(AddressSyncSource).filter(AddressSyncSource.tenant_id == tenant_id, AddressSyncSource.enabled.is_(True)).count(), "sync_sources": session.query(AddressSyncSource).filter(AddressSyncSource.tenant_id == tenant_id, AddressSyncSource.enabled.is_(True)).count(),
} }
@@ -226,6 +235,7 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, version="1.0.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"), ModuleInterfaceProvider(name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"),
), ),
@@ -263,6 +273,10 @@ manifest = ModuleManifest(
"govoplan_addresses.backend.capabilities", "govoplan_addresses.backend.capabilities",
fromlist=["channel_facts_capability"], fromlist=["channel_facts_capability"],
).channel_facts_capability(context), ).channel_facts_capability(context),
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION: lambda context: __import__(
"govoplan_addresses.backend.capabilities",
fromlist=["contact_point_resolution_capability"],
).contact_point_resolution_capability(context),
}, },
uninstall_guard_providers=( uninstall_guard_providers=(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
@@ -272,6 +286,7 @@ manifest = ModuleManifest(
addresses_models.AddressSyncSource, addresses_models.AddressSyncSource,
addresses_models.AddressListEntry, addresses_models.AddressListEntry,
addresses_models.AddressList, addresses_models.AddressList,
addresses_models.ContactPointSnapshot,
addresses_models.AddressBook, addresses_models.AddressBook,
addresses_models.Contact, addresses_models.Contact,
addresses_models.ContactEmail, addresses_models.ContactEmail,
@@ -297,6 +312,22 @@ manifest = ModuleManifest(
related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"), related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
order=30, order=30,
), ),
DocumentationTopic(
id="addresses.contact-point-resolution",
title="Contact-point resolution and snapshots",
summary="Resolve purpose-aware channel targets and freeze immutable recipient evidence.",
body=(
"Addresses exposes a versioned contact-point capability for email, postal, internal-mail, and portal targets. "
"Callers can request an effective date, communication purpose, address purpose, fallback rule, locale, and "
"postal format. Bounded previews remain live; frozen snapshots retain the resolved values, exclusions, "
"source and governance revisions, provenance, and a deterministic evidence hash even after contacts change."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("dist_lists", "campaigns", "policy", "templates"),
order=31,
),
), ),
external_providers=(CARDDAV_PROVIDER,), external_providers=(CARDDAV_PROVIDER,),
external_provider_state_providers=( external_provider_state_providers=(
@@ -0,0 +1,63 @@
"""Add immutable contact-point snapshots.
Revision ID: a3b5c6d7e8f9
Revises: f2a4b5c6d7e
"""
from alembic import op
import sqlalchemy as sa
revision = "a3b5c6d7e8f9"
down_revision = "f2a4b5c6d7e"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"ix_addresses_contacts_source_ref",
"addresses_contacts",
["source_ref"],
unique=False,
postgresql_using="hash",
)
op.create_table(
"addresses_contact_point_snapshots",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("source_id", sa.String(length=255), nullable=False),
sa.Column("contract_version", sa.String(length=20), nullable=False),
sa.Column("source_revision", sa.String(length=255), nullable=False),
sa.Column("source_fingerprint", sa.String(length=64), nullable=False),
sa.Column("purpose", sa.String(length=120), nullable=True),
sa.Column("effective_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("request_payload", sa.JSON(), nullable=False),
sa.Column("resolution_payload", sa.JSON(), nullable=False),
sa.Column("recipient_count", sa.Integer(), nullable=False),
sa.Column("excluded_count", sa.Integer(), nullable=False),
sa.Column("snapshot_hash", sa.String(length=64), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("provenance", 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.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_point_snapshots_tenant_id", ["tenant_id"]),
("ix_addresses_contact_point_snapshots_source_id", ["source_id"]),
("ix_addresses_contact_point_snapshots_purpose", ["purpose"]),
("ix_addresses_contact_point_snapshots_effective_at", ["effective_at"]),
("ix_addresses_contact_point_snapshots_generated_at", ["generated_at"]),
("ix_addresses_contact_point_snapshots_snapshot_hash", ["snapshot_hash"]),
("ix_addresses_contact_point_snapshots_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_contact_point_snapshots_source", ["tenant_id", "source_id", "created_at"]),
("ix_addresses_contact_point_snapshots_hash", ["tenant_id", "snapshot_hash"]),
):
op.create_index(name, "addresses_contact_point_snapshots", columns, unique=False)
def downgrade() -> None:
op.drop_table("addresses_contact_point_snapshots")
op.drop_index("ix_addresses_contacts_source_ref", table_name="addresses_contacts")
+169 -1
View File
@@ -8,6 +8,11 @@ from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.contact_points import (
ContactPointResolutionRequest,
ContactPointSourceRequest,
)
from govoplan_core.core.distribution_lists import DistributionSourceReference
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_addresses.backend.carddav import AddressCardDAVError from govoplan_addresses.backend.carddav import AddressCardDAVError
from govoplan_addresses.backend.db.models import ( from govoplan_addresses.backend.db.models import (
@@ -22,7 +27,10 @@ from govoplan_addresses.backend.db.models import (
ContactChannelRule, ContactChannelRule,
ContactPostalAddress, ContactPostalAddress,
) )
from govoplan_addresses.backend.capabilities import AddressesContactWriterCapability from govoplan_addresses.backend.capabilities import (
AddressesContactPointResolutionCapability,
AddressesContactWriterCapability,
)
from govoplan_addresses.backend.schemas import ( from govoplan_addresses.backend.schemas import (
AddressBookCreateRequest, AddressBookCreateRequest,
AddressBookListResponse, AddressBookListResponse,
@@ -66,6 +74,11 @@ from govoplan_addresses.backend.schemas import (
ContactChannelRuleCreateRequest, ContactChannelRuleCreateRequest,
ContactChannelRuleListResponse, ContactChannelRuleListResponse,
ContactChannelRuleResponse, ContactChannelRuleResponse,
ContactPointResolveRequest,
ContactPointResolutionResponse,
ContactPointSnapshotResponse,
ContactPointSourcePreviewResponse,
ContactPointSourceRequestPayload,
ContactListResponse, ContactListResponse,
ContactResponse, ContactResponse,
ContactUpdateRequest, ContactUpdateRequest,
@@ -212,6 +225,43 @@ def _write_decision_response(decision) -> AddressBookWriteDecisionResponse:
return AddressBookWriteDecisionResponse.model_validate(payload) return AddressBookWriteDecisionResponse.model_validate(payload)
def _contact_point_resolution_request(
principal: ApiPrincipal,
payload: ContactPointResolveRequest,
) -> ContactPointResolutionRequest:
return ContactPointResolutionRequest(
tenant_id=principal.tenant_id,
subject=DistributionSourceReference(**payload.subject.model_dump()),
effective_at=payload.effective_at,
purpose=payload.purpose,
requested_channels=tuple(payload.requested_channels),
address_purpose=payload.address_purpose,
fallback_rule=payload.fallback_rule,
locale=payload.locale,
postal_format=payload.postal_format,
context=payload.context,
)
def _contact_point_source_request(
principal: ApiPrincipal,
payload: ContactPointSourceRequestPayload,
) -> ContactPointSourceRequest:
return ContactPointSourceRequest(
tenant_id=principal.tenant_id,
source_id=payload.source_id,
effective_at=payload.effective_at,
purpose=payload.purpose,
requested_channels=tuple(payload.requested_channels),
address_purpose=payload.address_purpose,
fallback_rule=payload.fallback_rule,
locale=payload.locale,
postal_format=payload.postal_format,
max_items=payload.max_items,
context=payload.context,
)
def _sync_source_response(sync_source: AddressSyncSource) -> AddressSyncSourceResponse: def _sync_source_response(sync_source: AddressSyncSource) -> AddressSyncSourceResponse:
return AddressSyncSourceResponse.model_validate( return AddressSyncSourceResponse.model_validate(
{ {
@@ -493,6 +543,124 @@ def api_lookup_addresses(
return AddressLookupResponse(contacts=[_contact_response(contact) for contact in contacts]) return AddressLookupResponse(contacts=[_contact_response(contact) for contact in contacts])
@router.post("/contact-points/resolve", response_model=ContactPointResolutionResponse)
def api_resolve_contact_points(
payload: ContactPointResolveRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
_require_scope(principal, "addresses:governance:read")
try:
result = AddressesContactPointResolutionCapability().resolve_contact_points(
session,
principal,
request=_contact_point_resolution_request(principal, payload),
)
return ContactPointResolutionResponse.model_validate(asdict(result))
except (AddressBookError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.post(
"/contact-point-sources/preview",
response_model=ContactPointSourcePreviewResponse,
)
def api_preview_contact_point_source(
payload: ContactPointSourceRequestPayload,
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
_require_scope(principal, "addresses:governance:read")
try:
result = AddressesContactPointResolutionCapability().preview_source(
session,
principal,
request=_contact_point_source_request(principal, payload),
offset=offset,
limit=limit,
)
return ContactPointSourcePreviewResponse.model_validate(asdict(result))
except (AddressBookError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.post(
"/contact-point-snapshots",
response_model=ContactPointSnapshotResponse,
status_code=status.HTTP_201_CREATED,
)
def api_freeze_contact_point_source(
payload: ContactPointSourceRequestPayload,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
_require_scope(principal, "addresses:governance:read")
try:
snapshot = AddressesContactPointResolutionCapability().freeze_source(
session,
principal,
request=_contact_point_source_request(principal, payload),
)
audit_from_principal(
session,
principal,
action="addresses.contact_point_snapshot_created",
object_type="address_contact_point_snapshot",
object_id=snapshot.id,
details={
"source_id": snapshot.request.source_id,
"source_revision": snapshot.source_revision,
"snapshot_hash": snapshot.snapshot_hash,
"recipient_count": snapshot.recipient_count,
"excluded_count": snapshot.excluded_count,
"purpose": snapshot.request.purpose,
},
)
session.commit()
return ContactPointSnapshotResponse.model_validate(asdict(snapshot))
except (AddressBookError, ValueError) as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.get(
"/contact-point-snapshots/{snapshot_id}",
response_model=ContactPointSnapshotResponse,
)
def api_get_contact_point_snapshot(
snapshot_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
_require_scope(principal, "addresses:governance:read")
snapshot = AddressesContactPointResolutionCapability().get_snapshot(
session,
principal,
snapshot_id=snapshot_id,
)
if snapshot is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contact-point snapshot not found.",
)
return ContactPointSnapshotResponse.model_validate(asdict(snapshot))
@router.get("/address-lists", response_model=AddressListListResponse) @router.get("/address-lists", response_model=AddressListListResponse)
def api_list_address_lists( def api_list_address_lists(
address_book_id: str | None = Query(default=None), address_book_id: str | None = Query(default=None),
+132
View File
@@ -25,6 +25,19 @@ AddressChannelDecision = Literal[
"returned", "returned",
"temporarily_unavailable", "temporarily_unavailable",
] ]
AddressDistributionOutcome = Literal[
"usable",
"unresolved",
"invalid",
"suppressed",
"ambiguous",
"duplicate",
"policy_blocked",
"provider_unavailable",
"stale",
]
AddressContactPointFallbackRule = Literal["none", "primary", "any"]
AddressPostalFormat = Literal["domestic", "international"]
class ContactEmailPayload(BaseModel): class ContactEmailPayload(BaseModel):
@@ -282,6 +295,125 @@ class ContactChannelRuleListResponse(BaseModel):
rules: list[ContactChannelRuleResponse] = Field(default_factory=list) rules: list[ContactChannelRuleResponse] = Field(default_factory=list)
class AddressSourceReferencePayload(BaseModel):
provider: str = Field(min_length=1, max_length=120)
resource_type: str = Field(min_length=1, max_length=120)
resource_id: str = Field(min_length=1, max_length=1000)
revision: str | None = Field(default=None, max_length=1000)
fingerprint: str | None = Field(default=None, max_length=255)
label: str | None = Field(default=None, max_length=500)
metadata: dict[str, Any] = Field(default_factory=dict)
class ContactPointResolveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
subject: AddressSourceReferencePayload
effective_at: datetime
purpose: str | None = Field(default=None, max_length=120)
requested_channels: list[AddressDistributionChannel] = Field(default_factory=list)
address_purpose: str | None = Field(default=None, max_length=80)
fallback_rule: AddressContactPointFallbackRule = "primary"
locale: str | None = Field(default=None, max_length=20)
postal_format: AddressPostalFormat = "domestic"
context: dict[str, Any] = Field(default_factory=dict)
class ContactPointSourceRequestPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
source_id: str = Field(min_length=1, max_length=1000)
effective_at: datetime
purpose: str | None = Field(default=None, max_length=120)
requested_channels: list[AddressDistributionChannel] = Field(default_factory=list)
address_purpose: str | None = Field(default=None, max_length=80)
fallback_rule: AddressContactPointFallbackRule = "primary"
locale: str | None = Field(default=None, max_length=20)
postal_format: AddressPostalFormat = "domestic"
max_items: int = Field(default=5000, ge=1, le=20000)
context: dict[str, Any] = Field(default_factory=dict)
class ContactPointSourceRequestResponse(ContactPointSourceRequestPayload):
tenant_id: str
class ContactPointCandidateResponse(BaseModel):
channel: AddressDistributionChannel
target: str
target_key: str
status: AddressDistributionOutcome
contact_point_id: str | None = None
address_purpose: str | None = None
locale: str | None = None
preferred: bool = False
preference_rank: int | None = None
reason_code: str | None = None
explanation: str | None = None
source: AddressSourceReferencePayload | None = None
source_revision: str | None = None
preference_revision: str | None = None
consent_revision: str | None = None
value: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
class DistributionExplanationResponse(BaseModel):
code: str
message: str
severity: Literal["info", "warning", "error"]
provider: str | None = None
source: AddressSourceReferencePayload | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class ContactPointResolutionResponse(BaseModel):
contract_version: str
subject: AddressSourceReferencePayload
status: AddressDistributionOutcome
contact_id: str | None = None
display_name: str | None = None
candidates: list[ContactPointCandidateResponse] = Field(default_factory=list)
excluded: list[ContactPointCandidateResponse] = Field(default_factory=list)
explanations: list[DistributionExplanationResponse] = Field(default_factory=list)
source_revision: str | None = None
source_fingerprint: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class ContactPointSourcePreviewResponse(BaseModel):
contract_version: str
source: AddressSourceReferencePayload
request: ContactPointSourceRequestResponse
resolutions: list[ContactPointResolutionResponse]
total_count: int
usable_count: int
excluded_count: int
offset: int
limit: int
has_more: bool
source_revision: str
source_fingerprint: str
generated_at: datetime
provenance: dict[str, Any] = Field(default_factory=dict)
class ContactPointSnapshotResponse(BaseModel):
id: str
tenant_id: str
contract_version: str
source: AddressSourceReferencePayload
request: ContactPointSourceRequestResponse
resolutions: list[ContactPointResolutionResponse]
recipient_count: int
excluded_count: int
source_revision: str
source_fingerprint: str
snapshot_hash: str
generated_at: datetime
provenance: dict[str, Any] = Field(default_factory=dict)
class AddressLookupResponse(BaseModel): class AddressLookupResponse(BaseModel):
contacts: list[ContactResponse] contacts: list[ContactResponse]
+178
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from dataclasses import asdict
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
@@ -13,6 +14,12 @@ from govoplan_core.core.distribution_lists import (
DistributionSourceReference, DistributionSourceReference,
RecipientChannelFactsRequest, RecipientChannelFactsRequest,
) )
from govoplan_core.core.contact_points import (
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
CONTACT_POINT_CONTRACT_VERSION,
ContactPointResolutionRequest,
ContactPointSourceRequest,
)
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
@@ -26,6 +33,7 @@ from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_LOOKUP,
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
AddressesChannelFactsCapability, AddressesChannelFactsCapability,
AddressesContactPointResolutionCapability,
AddressesContactWriterCapability, AddressesContactWriterCapability,
AddressesLookupCapability, AddressesLookupCapability,
AddressesPeopleSearchProvider, AddressesPeopleSearchProvider,
@@ -44,6 +52,7 @@ from govoplan_addresses.backend.db.models import (
ContactChannelRule, ContactChannelRule,
ContactEmail, ContactEmail,
ContactPhone, ContactPhone,
ContactPointSnapshot,
ContactPostalAddress, ContactPostalAddress,
) )
from govoplan_addresses.backend.schemas import ( from govoplan_addresses.backend.schemas import (
@@ -63,6 +72,7 @@ from govoplan_addresses.backend.schemas import (
ContactChannelRuleCreateRequest, ContactChannelRuleCreateRequest,
ContactEmailPayload, ContactEmailPayload,
ContactPostalAddressPayload, ContactPostalAddressPayload,
ContactPointSnapshotResponse,
) )
from govoplan_addresses.backend.manifest import manifest from govoplan_addresses.backend.manifest import manifest
from govoplan_addresses.backend.router import _sync_source_response from govoplan_addresses.backend.router import _sync_source_response
@@ -196,6 +206,7 @@ class AddressServiceTest(unittest.TestCase):
ContactPhone.__table__, ContactPhone.__table__,
ContactPostalAddress.__table__, ContactPostalAddress.__table__,
ContactChannelRule.__table__, ContactChannelRule.__table__,
ContactPointSnapshot.__table__,
AddressListEntry.__table__, AddressListEntry.__table__,
AddressSyncSource.__table__, AddressSyncSource.__table__,
AddressSyncTombstone.__table__, AddressSyncTombstone.__table__,
@@ -397,6 +408,11 @@ END:VCARD
self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided) self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided)
self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided) self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided)
self.assertIn(CAPABILITY_RECIPIENT_CHANNEL_FACTS, provided) self.assertIn(CAPABILITY_RECIPIENT_CHANNEL_FACTS, provided)
self.assertIn(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, provided)
self.assertIn(
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
manifest.capability_factories,
)
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients")) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients"))
self.session.commit() self.session.commit()
@@ -691,6 +707,168 @@ END:VCARD
self.session.commit() self.session.commit()
self.assertEqual(list_address_list_entries(self.session, self.principal, address_list.id), []) self.assertEqual(list_address_list_entries(self.session, self.principal, address_list.id), [])
def test_contact_point_resolution_supports_external_refs_and_frozen_postal_snapshots(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Official contacts"),
)
self.session.commit()
self.session.refresh(book)
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
emails=[
ContactEmailPayload(
label="private",
email="ada.private@example.local",
is_primary=True,
)
],
postal_addresses=[
ContactPostalAddressPayload(
label="official",
street="Main Street 1",
postal_code="10115",
locality="Berlin",
country="Germany",
is_primary=True,
),
ContactPostalAddressPayload(
label="private",
street="Side Street 2",
postal_code="10117",
locality="Berlin",
country="Germany",
),
],
),
)
contact.source_kind = "idm"
contact.source_ref = "idm:identity:identity-1"
self.session.flush()
create_contact_channel_rule(
self.session,
self.principal,
contact.id,
ContactChannelRuleCreateRequest(
channel="postal",
purpose="official_notice",
contact_point_id=contact.postal_addresses[0].id,
decision="preferred",
legal_basis="public_task",
evidence_ref="idm:function-assignment:17",
preference_rank=1,
locale="de-DE",
),
)
address_list = create_address_list(
self.session,
self.principal,
book.id,
AddressListCreateRequest(name="Postal recipients"),
)
self.session.flush()
postal_entry = create_address_list_entry(
self.session,
self.principal,
address_list.id,
AddressListEntryCreateRequest(
contact_id=contact.id,
contact_postal_address_id=contact.postal_addresses[0].id,
),
)
self.session.commit()
capability = AddressesContactPointResolutionCapability()
direct = capability.resolve_contact_points(
self.session,
self.principal,
request=ContactPointResolutionRequest(
tenant_id=self.principal.tenant_id,
subject=DistributionSourceReference(
provider="idm",
resource_type="identity",
resource_id="identity-1",
),
effective_at=utcnow(),
purpose="official_notice",
requested_channels=("postal",),
address_purpose="official",
fallback_rule="none",
locale="de-DE",
postal_format="international",
),
)
self.assertEqual(CONTACT_POINT_CONTRACT_VERSION, direct.contract_version)
self.assertEqual("usable", direct.status)
self.assertEqual(contact.id, direct.contact_id)
self.assertEqual(1, len(direct.candidates))
self.assertEqual(contact.postal_addresses[0].id, direct.candidates[0].contact_point_id)
self.assertEqual("official", direct.candidates[0].address_purpose)
self.assertIn("Ada Lovelace", direct.candidates[0].target)
self.assertIn("Germany", direct.candidates[0].target)
self.assertEqual("de-DE", direct.candidates[0].locale)
self.assertTrue(direct.candidates[0].preference_revision)
self.assertEqual(1, len(direct.excluded))
self.assertEqual(
"addresses.address_purpose.not_selected",
direct.excluded[0].reason_code,
)
source_request = ContactPointSourceRequest(
tenant_id=self.principal.tenant_id,
source_id=f"addresses:address_list:{address_list.id}",
effective_at=utcnow(),
purpose="official_notice",
requested_channels=("email", "postal"),
address_purpose="official",
fallback_rule="none",
locale="de-DE",
postal_format="international",
)
preview = capability.preview_source(
self.session,
self.principal,
request=source_request,
limit=1,
)
self.assertEqual(1, preview.total_count)
self.assertEqual(1, preview.usable_count)
self.assertFalse(preview.has_more)
self.assertEqual(postal_entry.id, preview.resolutions[0].provenance["address_list_entry_ids"][0])
self.assertEqual("postal", preview.resolutions[0].candidates[0].channel)
snapshot = capability.freeze_source(
self.session,
self.principal,
request=source_request,
)
self.session.commit()
original_target = snapshot.resolutions[0].candidates[0].target
contact.postal_addresses[0].street = "Changed Street 99"
contact.postal_addresses[0].country = "France"
self.session.commit()
frozen = capability.get_snapshot(
self.session,
self.principal,
snapshot_id=snapshot.id,
)
self.assertIsNotNone(frozen)
assert frozen is not None
self.assertEqual(original_target, frozen.resolutions[0].candidates[0].target)
self.assertIn("Main Street 1", frozen.resolutions[0].candidates[0].target)
self.assertNotIn("Changed Street 99", frozen.resolutions[0].candidates[0].target)
self.assertEqual(snapshot.snapshot_hash, frozen.snapshot_hash)
self.assertEqual(1, frozen.recipient_count)
response = ContactPointSnapshotResponse.model_validate(asdict(frozen))
self.assertEqual(snapshot.id, response.id)
self.assertEqual("postal", response.resolutions[0].candidates[0].channel)
def test_sync_source_marks_read_only_books_and_can_be_made_writable(self) -> None: def test_sync_source_marks_read_only_books_and_can_be_made_writable(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="CardDAV")) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="CardDAV"))
self.session.commit() self.session.commit()