Add governed contact point snapshots

This commit is contained in:
2026-08-02 06:20:24 +02:00
parent 67392f620f
commit 2e78b9ae50
10 changed files with 1620 additions and 16 deletions
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",
]
+32 -1
View File
@@ -12,6 +12,7 @@ from govoplan_addresses.backend.capabilities import (
)
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
from govoplan_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=(
@@ -0,0 +1,63 @@
"""Add immutable contact-point snapshots.
Revision ID: a3b5c6d7e8f9
Revises: f2a4b5c6d7e
"""
from alembic import op
import sqlalchemy as sa
revision = "a3b5c6d7e8f9"
down_revision = "f2a4b5c6d7e"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"ix_addresses_contacts_source_ref",
"addresses_contacts",
["source_ref"],
unique=False,
postgresql_using="hash",
)
op.create_table(
"addresses_contact_point_snapshots",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("source_id", sa.String(length=255), nullable=False),
sa.Column("contract_version", sa.String(length=20), nullable=False),
sa.Column("source_revision", sa.String(length=255), nullable=False),
sa.Column("source_fingerprint", sa.String(length=64), nullable=False),
sa.Column("purpose", sa.String(length=120), nullable=True),
sa.Column("effective_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("request_payload", sa.JSON(), nullable=False),
sa.Column("resolution_payload", sa.JSON(), nullable=False),
sa.Column("recipient_count", sa.Integer(), nullable=False),
sa.Column("excluded_count", sa.Integer(), nullable=False),
sa.Column("snapshot_hash", sa.String(length=64), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_point_snapshots_tenant_id", ["tenant_id"]),
("ix_addresses_contact_point_snapshots_source_id", ["source_id"]),
("ix_addresses_contact_point_snapshots_purpose", ["purpose"]),
("ix_addresses_contact_point_snapshots_effective_at", ["effective_at"]),
("ix_addresses_contact_point_snapshots_generated_at", ["generated_at"]),
("ix_addresses_contact_point_snapshots_snapshot_hash", ["snapshot_hash"]),
("ix_addresses_contact_point_snapshots_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_contact_point_snapshots_source", ["tenant_id", "source_id", "created_at"]),
("ix_addresses_contact_point_snapshots_hash", ["tenant_id", "snapshot_hash"]),
):
op.create_index(name, "addresses_contact_point_snapshots", columns, unique=False)
def downgrade() -> None:
op.drop_table("addresses_contact_point_snapshots")
op.drop_index("ix_addresses_contacts_source_ref", table_name="addresses_contacts")
+169 -1
View File
@@ -8,6 +8,11 @@ from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.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),
+132
View File
@@ -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]