Add governed contact point snapshots
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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=(
|
||||||
|
|||||||
+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.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),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user