Add governed contact point snapshots
This commit is contained in:
@@ -74,13 +74,16 @@ It must not own:
|
||||
|
||||
## 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.recipient_source`: immutable recipient snapshots for campaign,
|
||||
reporting, mail-build, forms, portal, and postbox workflows.
|
||||
- `addresses.contact_writer`: address-book-scoped write decisions and contact
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
members for later postal/document workflows.
|
||||
|
||||
Consumers must store their own immutable snapshot when they need historical
|
||||
evidence. The addresses module remains the owner of the reusable source, not of
|
||||
the consumer's historical records. Consumers must resolve these capabilities
|
||||
through the platform registry and must not import address ORM/service internals.
|
||||
Legacy `addresses.recipient_source` consumers must store their own immutable
|
||||
snapshot when they need historical evidence. Channel-neutral consumers may use
|
||||
the dedicated freeze operation described below. The addresses module remains
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
- [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md)
|
||||
|
||||
@@ -87,6 +87,8 @@ The first stable capabilities are:
|
||||
campaign, scheduling, postbox, portal, and case workflows.
|
||||
- `addresses.contact_writer`: provide address-book-scoped write target decisions
|
||||
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
|
||||
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
|
||||
address lists. Address-book sources use `addresses:address_book:<id>`.
|
||||
Address-list sources use `addresses:address_list:<id>` and include the
|
||||
address-list entry ID in each recipient's provenance. The current snapshot DTO
|
||||
is email-recipient oriented; postal-only list entries are valid address-list
|
||||
members but are skipped by the email recipient-source path until postal
|
||||
recipient DTOs are added.
|
||||
address-list entry ID in each recipient's provenance. The legacy snapshot DTO
|
||||
remains email-oriented for compatible campaign consumers.
|
||||
|
||||
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
|
||||
whether the current principal may perform an operation such as `create_contact`,
|
||||
|
||||
@@ -73,6 +73,11 @@ Tasks:
|
||||
- [x] define immutable recipient snapshot DTOs
|
||||
- [x] expose source provenance in capability responses
|
||||
- [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] document consumer rules for campaign, mail, scheduling, portal, postbox, and
|
||||
reporting
|
||||
@@ -82,6 +87,8 @@ Exit criteria:
|
||||
- [x] campaign can request a recipient source via core-mediated capability
|
||||
- [x] mail/scheduling can request autocomplete candidates via core-mediated lookup
|
||||
- [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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,11 @@ class Contact(Base, TimestampMixin):
|
||||
__table_args__ = (
|
||||
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_source_ref",
|
||||
"source_ref",
|
||||
postgresql_using="hash",
|
||||
),
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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):
|
||||
__tablename__ = "addresses_address_lists"
|
||||
__table_args__ = (
|
||||
@@ -371,6 +401,7 @@ __all__ = [
|
||||
"Contact",
|
||||
"ContactEmail",
|
||||
"ContactPhone",
|
||||
"ContactPointSnapshot",
|
||||
"ContactPostalAddress",
|
||||
"new_uuid",
|
||||
]
|
||||
|
||||
@@ -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_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.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
|
||||
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_models.ContactPointSnapshot,
|
||||
addresses_models.AddressSyncDiagnostic,
|
||||
addresses_models.AddressSyncConflict,
|
||||
addresses_models.AddressSyncTombstone,
|
||||
@@ -147,12 +149,19 @@ ROLE_TEMPLATES = (
|
||||
|
||||
|
||||
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 {
|
||||
"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(),
|
||||
"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(),
|
||||
}
|
||||
|
||||
@@ -226,6 +235,7 @@ manifest = ModuleManifest(
|
||||
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.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_RECIPIENT_CHANNEL_FACTS, version="0.1.0"),
|
||||
),
|
||||
@@ -263,6 +273,10 @@ manifest = ModuleManifest(
|
||||
"govoplan_addresses.backend.capabilities",
|
||||
fromlist=["channel_facts_capability"],
|
||||
).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=(
|
||||
persistent_table_uninstall_guard(
|
||||
@@ -272,6 +286,7 @@ manifest = ModuleManifest(
|
||||
addresses_models.AddressSyncSource,
|
||||
addresses_models.AddressListEntry,
|
||||
addresses_models.AddressList,
|
||||
addresses_models.ContactPointSnapshot,
|
||||
addresses_models.AddressBook,
|
||||
addresses_models.Contact,
|
||||
addresses_models.ContactEmail,
|
||||
@@ -297,6 +312,22 @@ manifest = ModuleManifest(
|
||||
related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
|
||||
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_provider_state_providers=(
|
||||
|
||||
+63
@@ -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")
|
||||
@@ -8,6 +8,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
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_addresses.backend.carddav import AddressCardDAVError
|
||||
from govoplan_addresses.backend.db.models import (
|
||||
@@ -22,7 +27,10 @@ from govoplan_addresses.backend.db.models import (
|
||||
ContactChannelRule,
|
||||
ContactPostalAddress,
|
||||
)
|
||||
from govoplan_addresses.backend.capabilities import AddressesContactWriterCapability
|
||||
from govoplan_addresses.backend.capabilities import (
|
||||
AddressesContactPointResolutionCapability,
|
||||
AddressesContactWriterCapability,
|
||||
)
|
||||
from govoplan_addresses.backend.schemas import (
|
||||
AddressBookCreateRequest,
|
||||
AddressBookListResponse,
|
||||
@@ -66,6 +74,11 @@ from govoplan_addresses.backend.schemas import (
|
||||
ContactChannelRuleCreateRequest,
|
||||
ContactChannelRuleListResponse,
|
||||
ContactChannelRuleResponse,
|
||||
ContactPointResolveRequest,
|
||||
ContactPointResolutionResponse,
|
||||
ContactPointSnapshotResponse,
|
||||
ContactPointSourcePreviewResponse,
|
||||
ContactPointSourceRequestPayload,
|
||||
ContactListResponse,
|
||||
ContactResponse,
|
||||
ContactUpdateRequest,
|
||||
@@ -212,6 +225,43 @@ def _write_decision_response(decision) -> AddressBookWriteDecisionResponse:
|
||||
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:
|
||||
return AddressSyncSourceResponse.model_validate(
|
||||
{
|
||||
@@ -493,6 +543,124 @@ def api_lookup_addresses(
|
||||
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)
|
||||
def api_list_address_lists(
|
||||
address_book_id: str | None = Query(default=None),
|
||||
|
||||
@@ -25,6 +25,19 @@ AddressChannelDecision = Literal[
|
||||
"returned",
|
||||
"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):
|
||||
@@ -282,6 +295,125 @@ class ContactChannelRuleListResponse(BaseModel):
|
||||
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):
|
||||
contacts: list[ContactResponse]
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import asdict
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -13,6 +14,12 @@ from govoplan_core.core.distribution_lists import (
|
||||
DistributionSourceReference,
|
||||
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.db.base import Base
|
||||
from govoplan_core.db.base import utcnow
|
||||
@@ -26,6 +33,7 @@ from govoplan_addresses.backend.capabilities import (
|
||||
CAPABILITY_ADDRESSES_LOOKUP,
|
||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
||||
AddressesChannelFactsCapability,
|
||||
AddressesContactPointResolutionCapability,
|
||||
AddressesContactWriterCapability,
|
||||
AddressesLookupCapability,
|
||||
AddressesPeopleSearchProvider,
|
||||
@@ -44,6 +52,7 @@ from govoplan_addresses.backend.db.models import (
|
||||
ContactChannelRule,
|
||||
ContactEmail,
|
||||
ContactPhone,
|
||||
ContactPointSnapshot,
|
||||
ContactPostalAddress,
|
||||
)
|
||||
from govoplan_addresses.backend.schemas import (
|
||||
@@ -63,6 +72,7 @@ from govoplan_addresses.backend.schemas import (
|
||||
ContactChannelRuleCreateRequest,
|
||||
ContactEmailPayload,
|
||||
ContactPostalAddressPayload,
|
||||
ContactPointSnapshotResponse,
|
||||
)
|
||||
from govoplan_addresses.backend.manifest import manifest
|
||||
from govoplan_addresses.backend.router import _sync_source_response
|
||||
@@ -196,6 +206,7 @@ class AddressServiceTest(unittest.TestCase):
|
||||
ContactPhone.__table__,
|
||||
ContactPostalAddress.__table__,
|
||||
ContactChannelRule.__table__,
|
||||
ContactPointSnapshot.__table__,
|
||||
AddressListEntry.__table__,
|
||||
AddressSyncSource.__table__,
|
||||
AddressSyncTombstone.__table__,
|
||||
@@ -397,6 +408,11 @@ END:VCARD
|
||||
self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided)
|
||||
self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, 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"))
|
||||
self.session.commit()
|
||||
@@ -691,6 +707,168 @@ END:VCARD
|
||||
self.session.commit()
|
||||
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:
|
||||
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="CardDAV"))
|
||||
self.session.commit()
|
||||
|
||||
Reference in New Issue
Block a user