Implement address quality and reversible contact merges

This commit is contained in:
2026-08-02 07:03:27 +02:00
parent 2e78b9ae50
commit 19e9096572
15 changed files with 4369 additions and 31 deletions
+110 -5
View File
@@ -41,6 +41,7 @@ from govoplan_addresses.backend.db.models import (
ContactChannelRule,
ContactEmail,
ContactPhone,
ContactPointQualityDecision,
ContactPointSnapshot,
ContactPostalAddress,
)
@@ -48,6 +49,7 @@ from govoplan_addresses.backend.schemas import ContactCreateRequest
from govoplan_addresses.backend.service import (
AddressBookError,
create_contact,
current_contact_quality,
get_visible_address_book,
get_visible_address_list,
get_visible_contact,
@@ -55,6 +57,7 @@ from govoplan_addresses.backend.service import (
list_address_list_entries,
list_address_lists,
list_contacts,
resolve_contact_redirect,
)
@@ -488,6 +491,7 @@ class AddressesChannelFactsCapability:
and _aware_datetime(rule.effective_until) <= effective_at
]
revision, fingerprint = _contact_channel_revision(contact)
quality = current_contact_quality(contact, effective_at=effective_at)
source = DistributionSourceReference(
provider="addresses",
resource_type="contact",
@@ -529,6 +533,20 @@ class AddressesChannelFactsCapability:
else f"The {channel.replace('_', ' ')} contact point is incomplete or invalid."
)
)
quality_decision = quality.get((channel, point_id)) or quality.get(
(channel, None)
)
if quality_decision is not None and quality_decision.state != "valid":
status = (
"stale"
if quality_decision.state == "stale"
else "invalid"
)
reason_code = quality_decision.reason_code
explanation = quality_decision.reason or (
f"This {channel.replace('_', ' ')} contact point is marked "
f"{quality_decision.state}."
)
candidates.append(
DistributionChannelCandidate(
channel=channel,
@@ -551,6 +569,17 @@ class AddressesChannelFactsCapability:
"legal_basis": selected.legal_basis if selected is not None else None,
"evidence_ref": selected.evidence_ref if selected is not None else None,
"preference_rank": selected.preference_rank if selected is not None else None,
"quality_decision_id": (
quality_decision.id if quality_decision is not None else None
),
"quality_state": (
quality_decision.state if quality_decision is not None else "valid"
),
"quality_evidence_ref": (
quality_decision.evidence_ref
if quality_decision is not None
else None
),
},
)
)
@@ -1178,7 +1207,28 @@ def _address_book_contact_updated_at(session: Any, address_book_ids: list[str])
.group_by(Contact.address_book_id)
.all()
)
for address_book_id, updated_at in [*contact_rows, *deletion_rows, *email_rows, *phone_rows, *postal_rows, *rule_rows]:
quality_rows = (
session.query(
Contact.address_book_id,
func.max(ContactPointQualityDecision.updated_at),
)
.join(Contact, ContactPointQualityDecision.contact_id == Contact.id)
.filter(
Contact.address_book_id.in_(address_book_ids),
Contact.deleted_at.is_(None),
)
.group_by(Contact.address_book_id)
.all()
)
for address_book_id, updated_at in [
*contact_rows,
*deletion_rows,
*email_rows,
*phone_rows,
*postal_rows,
*rule_rows,
*quality_rows,
]:
if updated_at is None:
continue
key = str(address_book_id)
@@ -1221,6 +1271,16 @@ def _address_list_updated_at(session: Any, address_list_ids: list[str]) -> dict[
.group_by(AddressListEntry.address_list_id)
.all()
)
quality_rows = (
session.query(
AddressListEntry.address_list_id,
func.max(ContactPointQualityDecision.updated_at),
)
.join(ContactPointQualityDecision, AddressListEntry.contact_id == ContactPointQualityDecision.contact_id)
.filter(AddressListEntry.address_list_id.in_(address_list_ids))
.group_by(AddressListEntry.address_list_id)
.all()
)
postal_rows = (
session.query(
AddressListEntry.address_list_id,
@@ -1241,6 +1301,7 @@ def _address_list_updated_at(session: Any, address_list_ids: list[str]) -> dict[
*email_rows,
*postal_rows,
*rule_rows,
*quality_rows,
]:
if updated_at is None:
continue
@@ -1359,7 +1420,15 @@ def _channel_rule_explanation(rule: ContactChannelRule) -> str:
def _contact_channel_revision(contact: Contact) -> tuple[str, str]:
stamps = [contact.updated_at]
stamps.extend(item.updated_at for item in (*contact.emails, *contact.postal_addresses, *contact.channel_rules))
stamps.extend(
item.updated_at
for item in (
*contact.emails,
*contact.postal_addresses,
*contact.channel_rules,
*contact.quality_decisions,
)
)
revision = max(_aware_datetime(item) for item in stamps).isoformat()
payload = {
"contact_id": contact.id,
@@ -1397,6 +1466,20 @@ def _contact_channel_revision(contact: Contact) -> tuple[str, str]:
}
for rule in sorted(contact.channel_rules, key=lambda item: item.id)
],
"quality": [
{
"id": item.id,
"channel": item.channel,
"point": item.contact_point_id,
"state": item.state,
"reason_code": item.reason_code,
"evidence": item.evidence_ref,
"from": item.effective_from.isoformat(),
"until": item.effective_until.isoformat() if item.effective_until else None,
"updated": item.updated_at.isoformat(),
}
for item in sorted(contact.quality_decisions, key=lambda row: row.id)
],
}
fingerprint = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
@@ -1426,7 +1509,21 @@ def _contacts_for_subject(
try:
return [get_visible_contact(session, principal, str(direct_id))]
except AddressBookError:
return []
try:
resolution = resolve_contact_redirect(
session,
principal,
str(direct_id),
)
return [
get_visible_contact(
session,
principal,
resolution.resolved_contact_id,
)
]
except AddressBookError:
return []
explicit_ref = subject.metadata.get("source_ref")
source_refs = {
@@ -1445,16 +1542,24 @@ def _contacts_for_subject(
.filter(
Contact.source_ref.in_(source_refs),
or_(Contact.tenant_id == principal.tenant_id, Contact.tenant_id.is_(None)),
Contact.deleted_at.is_(None),
)
.order_by(Contact.id.asc())
.limit(3)
.all()
)
visible: list[Contact] = []
visible_ids: set[str] = set()
for row in rows:
try:
visible.append(get_visible_contact(session, principal, row.id))
resolution = resolve_contact_redirect(session, principal, row.id)
contact = get_visible_contact(
session,
principal,
resolution.resolved_contact_id,
)
if contact.id not in visible_ids:
visible.append(contact)
visible_ids.add(contact.id)
except AddressBookError:
continue
return visible
+163
View File
@@ -102,6 +102,16 @@ class Contact(Base, TimestampMixin):
cascade="all, delete-orphan",
order_by="ContactChannelRule.created_at",
)
quality_decisions: Mapped[list["ContactPointQualityDecision"]] = relationship(
back_populates="contact",
cascade="all, delete-orphan",
order_by="ContactPointQualityDecision.created_at",
)
field_provenance: Mapped[list["ContactFieldProvenance"]] = relationship(
back_populates="contact",
cascade="all, delete-orphan",
order_by="ContactFieldProvenance.created_at",
)
class ContactEmail(Base, TimestampMixin):
@@ -115,6 +125,9 @@ class ContactEmail(Base, TimestampMixin):
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
label: Mapped[str | None] = mapped_column(String(80))
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
original_email: Mapped[str] = mapped_column(String(320), nullable=False, default="")
normalized_email: Mapped[str] = mapped_column(String(320), nullable=False, default="", index=True)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
@@ -130,6 +143,9 @@ class ContactPhone(Base, TimestampMixin):
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
label: Mapped[str | None] = mapped_column(String(80))
phone: Mapped[str] = mapped_column(String(100), nullable=False)
original_phone: Mapped[str] = mapped_column(String(100), nullable=False, default="")
normalized_phone: Mapped[str] = mapped_column(String(100), nullable=False, default="", index=True)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
@@ -148,6 +164,9 @@ class ContactPostalAddress(Base, TimestampMixin):
locality: Mapped[str | None] = mapped_column(String(255))
region: Mapped[str | None] = mapped_column(String(255))
country: Mapped[str | None] = mapped_column(String(255))
original_value: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
normalized_value: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
@@ -221,6 +240,146 @@ class ContactPointSnapshot(Base, TimestampMixin):
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class ContactPointQualityDecision(Base, TimestampMixin):
__tablename__ = "addresses_contact_point_quality_decisions"
__table_args__ = (
Index(
"ix_addresses_quality_current",
"tenant_id",
"contact_id",
"channel",
"contact_point_id",
"effective_until",
),
Index("ix_addresses_quality_state", "tenant_id", "state", "effective_until"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
channel: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
contact_point_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
reason_code: Mapped[str] = mapped_column(String(120), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
effective_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
effective_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
contact: Mapped[Contact] = relationship(back_populates="quality_decisions")
class ContactMergeRecord(Base, TimestampMixin):
__tablename__ = "addresses_contact_merge_records"
__table_args__ = (
Index("ix_addresses_merge_winner", "tenant_id", "winner_contact_id", "created_at"),
Index("ix_addresses_merge_status", "tenant_id", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
address_book_id: Mapped[str] = mapped_column(
ForeignKey("addresses_address_books.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
winner_contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
loser_contact_ids: Mapped[list[str]] = mapped_column(JSON, nullable=False)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
reason: Mapped[str] = mapped_column(Text, nullable=False)
survivorship: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
before_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
after_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
before_hash: Mapped[str] = mapped_column(String(64), nullable=False)
after_hash: Mapped[str] = mapped_column(String(64), nullable=False)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
recovered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
recovered_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
recovery_action: Mapped[str | None] = mapped_column(String(30), nullable=True)
recovery_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class ContactRedirect(Base, TimestampMixin):
__tablename__ = "addresses_contact_redirects"
__table_args__ = (
Index(
"uq_addresses_contact_redirects_active_source",
"tenant_id",
"source_contact_id",
unique=True,
sqlite_where=text("ended_at IS NULL"),
postgresql_where=text("ended_at IS NULL"),
),
Index("ix_addresses_contact_redirects_target", "tenant_id", "target_contact_id", "ended_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
source_contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
target_contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
merge_record_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contact_merge_records.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class ContactFieldProvenance(Base, TimestampMixin):
__tablename__ = "addresses_contact_field_provenance"
__table_args__ = (
Index("ix_addresses_field_provenance_contact", "contact_id", "field_path", "created_at"),
Index("ix_addresses_field_provenance_selected", "tenant_id", "contact_id", "selected"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
field_path: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
value: Mapped[Any] = mapped_column(JSON, nullable=True)
source_kind: Mapped[str] = mapped_column(String(40), nullable=False)
source_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
precedence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
selected: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
reason_code: Mapped[str] = mapped_column(String(120), nullable=False)
explanation: Mapped[str | None] = mapped_column(Text, nullable=True)
visibility: Mapped[str] = mapped_column(String(30), nullable=False, default="inherit")
merge_record_id: Mapped[str | None] = mapped_column(
ForeignKey("addresses_contact_merge_records.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
contact: Mapped[Contact] = relationship(back_populates="field_provenance")
class AddressList(Base, TimestampMixin):
__tablename__ = "addresses_address_lists"
__table_args__ = (
@@ -401,7 +560,11 @@ __all__ = [
"Contact",
"ContactEmail",
"ContactPhone",
"ContactFieldProvenance",
"ContactMergeRecord",
"ContactPointQualityDecision",
"ContactPointSnapshot",
"ContactPostalAddress",
"ContactRedirect",
"new_uuid",
]
@@ -43,6 +43,10 @@ from govoplan_addresses.backend.provider_state import (
_addresses_table_retirement_provider = drop_table_retirement_provider(
addresses_models.ContactFieldProvenance,
addresses_models.ContactRedirect,
addresses_models.ContactMergeRecord,
addresses_models.ContactPointQualityDecision,
addresses_models.ContactPointSnapshot,
addresses_models.AddressSyncDiagnostic,
addresses_models.AddressSyncConflict,
@@ -154,6 +158,8 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
AddressList,
AddressSyncSource,
Contact,
ContactMergeRecord,
ContactPointQualityDecision,
ContactPointSnapshot,
)
@@ -161,6 +167,8 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
"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(),
"active_contact_merges": session.query(ContactMergeRecord).filter(ContactMergeRecord.tenant_id == tenant_id, ContactMergeRecord.status == "active").count(),
"contact_quality_decisions": session.query(ContactPointQualityDecision).filter(ContactPointQualityDecision.tenant_id == tenant_id).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(),
}
@@ -286,6 +294,10 @@ manifest = ModuleManifest(
addresses_models.AddressSyncSource,
addresses_models.AddressListEntry,
addresses_models.AddressList,
addresses_models.ContactFieldProvenance,
addresses_models.ContactRedirect,
addresses_models.ContactMergeRecord,
addresses_models.ContactPointQualityDecision,
addresses_models.ContactPointSnapshot,
addresses_models.AddressBook,
addresses_models.Contact,
@@ -328,6 +340,25 @@ manifest = ModuleManifest(
related_modules=("dist_lists", "campaigns", "policy", "templates"),
order=31,
),
DocumentationTopic(
id="addresses.quality-and-merge",
title="Contact quality, duplicates, and reversible merges",
summary="Review address quality and duplicate suggestions without losing source evidence.",
body=(
"Addresses preserves original and normalized contact-point values, records field-level provenance, "
"and projects invalid, returned, stale, or undeliverable states into recipient resolution with stable "
"reason codes. Duplicate suggestions are bounded and explain their matching features. An operator can "
"choose the surviving values, merge contact points, and later undo or split the merge while the recorded "
"post-merge evidence still matches. Contact redirects keep stored references resolvable, and address-list "
"memberships are repaired transactionally. Audit remains an optional integration; the Addresses change "
"sequence and merge evidence are always retained."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("campaigns", "dist_lists", "policy", "audit"),
order=32,
),
),
external_providers=(CARDDAV_PROVIDER,),
external_provider_state_providers=(
@@ -0,0 +1,281 @@
"""Add address quality, provenance, merge evidence, and redirects.
Revision ID: b4c6d7e8f9a0
Revises: a3b5c6d7e8f9
"""
from __future__ import annotations
import re
from alembic import op
import sqlalchemy as sa
revision = "b4c6d7e8f9a0"
down_revision = "a3b5c6d7e8f9"
branch_labels = None
depends_on = None
_JSON_OBJECT = sa.text("'{}'")
def upgrade() -> None:
with op.batch_alter_table("addresses_contact_emails") as batch:
batch.add_column(sa.Column("original_email", sa.String(length=320), nullable=False, server_default=""))
batch.add_column(sa.Column("normalized_email", sa.String(length=320), nullable=False, server_default=""))
batch.add_column(sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
with op.batch_alter_table("addresses_contact_phones") as batch:
batch.add_column(sa.Column("original_phone", sa.String(length=100), nullable=False, server_default=""))
batch.add_column(sa.Column("normalized_phone", sa.String(length=100), nullable=False, server_default=""))
batch.add_column(sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
with op.batch_alter_table("addresses_contact_postal_addresses") as batch:
batch.add_column(sa.Column("original_value", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
batch.add_column(sa.Column("normalized_value", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
batch.add_column(sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
bind = op.get_bind()
bind.execute(
sa.text(
"UPDATE addresses_contact_emails "
"SET original_email = email, normalized_email = lower(trim(email))"
)
)
phone_rows = bind.execute(
sa.text("SELECT id, phone FROM addresses_contact_phones")
).mappings().all()
for row in phone_rows:
bind.execute(
sa.text(
"UPDATE addresses_contact_phones "
"SET original_phone = :original, normalized_phone = :normalized "
"WHERE id = :id"
),
{
"id": row["id"],
"original": row["phone"],
"normalized": _normalized_phone(str(row["phone"] or "")),
},
)
postal = sa.table(
"addresses_contact_postal_addresses",
sa.column("id", sa.String()),
sa.column("label", sa.String()),
sa.column("street", sa.String()),
sa.column("postal_code", sa.String()),
sa.column("locality", sa.String()),
sa.column("region", sa.String()),
sa.column("country", sa.String()),
sa.column("original_value", sa.JSON()),
sa.column("normalized_value", sa.JSON()),
)
postal_rows = bind.execute(
sa.select(
postal.c.id,
postal.c.label,
postal.c.street,
postal.c.postal_code,
postal.c.locality,
postal.c.region,
postal.c.country,
)
).mappings().all()
for row in postal_rows:
original = {
key: row[key]
for key in ("label", "street", "postal_code", "locality", "region", "country")
}
normalized = {
key: _normalized_text(row[key])
for key in ("label", "street", "postal_code", "locality", "region", "country")
}
bind.execute(
postal.update()
.where(postal.c.id == row["id"])
.values(original_value=original, normalized_value=normalized)
)
op.create_index(
"ix_addresses_contact_emails_normalized_email",
"addresses_contact_emails",
["normalized_email"],
)
op.create_index(
"ix_addresses_contact_phones_normalized_phone",
"addresses_contact_phones",
["normalized_phone"],
)
op.create_table(
"addresses_contact_point_quality_decisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("contact_id", sa.String(length=36), nullable=False),
sa.Column("channel", sa.String(length=30), nullable=False),
sa.Column("contact_point_id", sa.String(length=36), nullable=True),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("reason_code", sa.String(length=120), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("evidence_ref", sa.String(length=1000), nullable=True),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("effective_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["contact_id"], ["addresses_contacts.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_point_quality_decisions_tenant_id", ["tenant_id"]),
("ix_addresses_contact_point_quality_decisions_contact_id", ["contact_id"]),
("ix_addresses_contact_point_quality_decisions_channel", ["channel"]),
("ix_addresses_contact_point_quality_decisions_contact_point_id", ["contact_point_id"]),
("ix_addresses_contact_point_quality_decisions_state", ["state"]),
("ix_addresses_contact_point_quality_decisions_effective_from", ["effective_from"]),
("ix_addresses_contact_point_quality_decisions_effective_until", ["effective_until"]),
("ix_addresses_contact_point_quality_decisions_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_quality_current", ["tenant_id", "contact_id", "channel", "contact_point_id", "effective_until"]),
("ix_addresses_quality_state", ["tenant_id", "state", "effective_until"]),
):
op.create_index(name, "addresses_contact_point_quality_decisions", columns)
op.create_table(
"addresses_contact_merge_records",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("address_book_id", sa.String(length=36), nullable=False),
sa.Column("winner_contact_id", sa.String(length=36), nullable=False),
sa.Column("loser_contact_ids", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("survivorship", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
sa.Column("decisions", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("before_payload", sa.JSON(), nullable=False),
sa.Column("after_payload", sa.JSON(), nullable=False),
sa.Column("before_hash", sa.String(length=64), nullable=False),
sa.Column("after_hash", sa.String(length=64), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("recovered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recovered_by_account_id", sa.String(length=36), nullable=True),
sa.Column("recovery_action", sa.String(length=30), nullable=True),
sa.Column("recovery_reason", sa.Text(), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["address_book_id"], ["addresses_address_books.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["winner_contact_id"], ["addresses_contacts.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_merge_records_tenant_id", ["tenant_id"]),
("ix_addresses_contact_merge_records_address_book_id", ["address_book_id"]),
("ix_addresses_contact_merge_records_winner_contact_id", ["winner_contact_id"]),
("ix_addresses_contact_merge_records_status", ["status"]),
("ix_addresses_contact_merge_records_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_merge_winner", ["tenant_id", "winner_contact_id", "created_at"]),
("ix_addresses_merge_status", ["tenant_id", "status", "created_at"]),
):
op.create_index(name, "addresses_contact_merge_records", columns)
op.create_table(
"addresses_contact_redirects",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("source_contact_id", sa.String(length=36), nullable=False),
sa.Column("target_contact_id", sa.String(length=36), nullable=False),
sa.Column("merge_record_id", sa.String(length=36), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["merge_record_id"], ["addresses_contact_merge_records.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["source_contact_id"], ["addresses_contacts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["target_contact_id"], ["addresses_contacts.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_redirects_tenant_id", ["tenant_id"]),
("ix_addresses_contact_redirects_source_contact_id", ["source_contact_id"]),
("ix_addresses_contact_redirects_target_contact_id", ["target_contact_id"]),
("ix_addresses_contact_redirects_merge_record_id", ["merge_record_id"]),
("ix_addresses_contact_redirects_ended_at", ["ended_at"]),
("ix_addresses_contact_redirects_target", ["tenant_id", "target_contact_id", "ended_at"]),
):
op.create_index(name, "addresses_contact_redirects", columns)
op.create_index(
"uq_addresses_contact_redirects_active_source",
"addresses_contact_redirects",
["tenant_id", "source_contact_id"],
unique=True,
sqlite_where=sa.text("ended_at IS NULL"),
postgresql_where=sa.text("ended_at IS NULL"),
)
op.create_table(
"addresses_contact_field_provenance",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("contact_id", sa.String(length=36), nullable=False),
sa.Column("field_path", sa.String(length=255), nullable=False),
sa.Column("value", sa.JSON(), nullable=True),
sa.Column("source_kind", sa.String(length=40), nullable=False),
sa.Column("source_ref", sa.String(length=1000), nullable=True),
sa.Column("source_revision", sa.String(length=255), nullable=True),
sa.Column("precedence", sa.Integer(), nullable=False),
sa.Column("selected", sa.Boolean(), nullable=False),
sa.Column("reason_code", sa.String(length=120), nullable=False),
sa.Column("explanation", sa.Text(), nullable=True),
sa.Column("visibility", sa.String(length=30), nullable=False),
sa.Column("merge_record_id", sa.String(length=36), nullable=True),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["contact_id"], ["addresses_contacts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["merge_record_id"], ["addresses_contact_merge_records.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_field_provenance_tenant_id", ["tenant_id"]),
("ix_addresses_contact_field_provenance_contact_id", ["contact_id"]),
("ix_addresses_contact_field_provenance_field_path", ["field_path"]),
("ix_addresses_contact_field_provenance_selected", ["selected"]),
("ix_addresses_contact_field_provenance_merge_record_id", ["merge_record_id"]),
("ix_addresses_contact_field_provenance_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_field_provenance_contact", ["contact_id", "field_path", "created_at"]),
("ix_addresses_field_provenance_selected", ["tenant_id", "contact_id", "selected"]),
):
op.create_index(name, "addresses_contact_field_provenance", columns)
def downgrade() -> None:
op.drop_table("addresses_contact_field_provenance")
op.drop_table("addresses_contact_redirects")
op.drop_table("addresses_contact_merge_records")
op.drop_table("addresses_contact_point_quality_decisions")
with op.batch_alter_table("addresses_contact_postal_addresses") as batch:
batch.drop_column("provenance")
batch.drop_column("normalized_value")
batch.drop_column("original_value")
with op.batch_alter_table("addresses_contact_phones") as batch:
batch.drop_index("ix_addresses_contact_phones_normalized_phone")
batch.drop_column("provenance")
batch.drop_column("normalized_phone")
batch.drop_column("original_phone")
with op.batch_alter_table("addresses_contact_emails") as batch:
batch.drop_index("ix_addresses_contact_emails_normalized_email")
batch.drop_column("provenance")
batch.drop_column("normalized_email")
batch.drop_column("original_email")
def _normalized_text(value: object) -> str | None:
if value is None:
return None
normalized = " ".join(str(value).strip().casefold().split())
return normalized or None
def _normalized_phone(value: str) -> str:
prefix = "+" if value.strip().startswith("+") else ""
return prefix + re.sub(r"\D", "", value)
+566 -2
View File
@@ -25,6 +25,8 @@ from govoplan_addresses.backend.db.models import (
AddressSyncTombstone,
Contact,
ContactChannelRule,
ContactMergeRecord,
ContactPointQualityDecision,
ContactPostalAddress,
)
from govoplan_addresses.backend.capabilities import (
@@ -74,6 +76,18 @@ from govoplan_addresses.backend.schemas import (
ContactChannelRuleCreateRequest,
ContactChannelRuleListResponse,
ContactChannelRuleResponse,
ContactDuplicateFeatureResponse,
ContactDuplicateSuggestionListResponse,
ContactDuplicateSuggestionResponse,
ContactFieldProvenanceResponse,
ContactMergeRecordListResponse,
ContactMergeRecordResponse,
ContactMergeRecoveryRequest,
ContactMergeRequest,
ContactPointQualityDecisionCreateRequest,
ContactPointQualityDecisionListResponse,
ContactPointQualityDecisionResponse,
ContactRedirectResponse,
ContactPointResolveRequest,
ContactPointResolutionResponse,
ContactPointSnapshotResponse,
@@ -82,6 +96,8 @@ from govoplan_addresses.backend.schemas import (
ContactListResponse,
ContactResponse,
ContactUpdateRequest,
AddressQualityCorrectionResponse,
AddressQualitySummaryResponse,
VCardImportIssue,
VCardImportRequest,
VCardImportResponse,
@@ -90,6 +106,7 @@ from govoplan_addresses.backend.service import (
AddressBookError,
available_address_credentials,
address_book_contact_counts,
address_quality_summary,
address_list_entry_counts,
create_address_book,
create_address_list,
@@ -97,6 +114,8 @@ from govoplan_addresses.backend.service import (
create_carddav_sync_source,
create_contact,
create_contact_channel_rule,
create_contact_quality_decision,
current_contact_quality,
create_sync_source,
count_contacts,
delete_address_book,
@@ -115,6 +134,9 @@ from govoplan_addresses.backend.service import (
list_address_books,
list_contacts,
list_contact_channel_rules,
list_contact_field_provenance,
list_contact_merges,
list_contact_quality_decisions,
list_sync_conflicts,
list_sync_diagnostics,
list_sync_sources,
@@ -122,9 +144,12 @@ from govoplan_addresses.backend.service import (
record_sync_conflict,
record_sync_diagnostic,
record_sync_tombstone,
merge_contacts,
recover_contact_merge,
restore_address_book,
restore_address_list,
restore_contact,
resolve_contact_redirect,
resolve_sync_conflict,
preview_sync_source,
public_address_sync_metadata,
@@ -133,6 +158,7 @@ from govoplan_addresses.backend.service import (
update_address_book,
update_address_list,
update_contact,
suggest_duplicate_contacts,
update_sync_source,
)
@@ -172,14 +198,123 @@ def _book_response(book: AddressBook, *, contact_count: int = 0) -> AddressBookR
)
def _contact_response(contact: Contact) -> ContactResponse:
return ContactResponse.model_validate(contact)
def _contact_response(
contact: Contact,
*,
field_provenance: list | None = None,
) -> ContactResponse:
quality = current_contact_quality(contact)
def quality_payload(channel: str, point_id: str) -> dict:
decision = quality.get((channel, point_id)) or quality.get((channel, None))
return {
"quality_state": decision.state if decision is not None else "valid",
"quality_reason_code": (
decision.reason_code if decision is not None else None
),
}
return ContactResponse.model_validate(
{
"id": contact.id,
"tenant_id": contact.tenant_id,
"address_book_id": contact.address_book_id,
"display_name": contact.display_name,
"given_name": contact.given_name,
"family_name": contact.family_name,
"organization": contact.organization,
"role_title": contact.role_title,
"note": contact.note,
"tags": list(contact.tags or []),
"source_kind": contact.source_kind,
"source_ref": contact.source_ref,
"source_payload_kind": contact.source_payload_kind,
"source_revision": contact.source_revision,
"provenance": dict(contact.provenance or {}),
"emails": [
{
"id": item.id,
"label": item.label,
"email": item.email,
"original_email": item.original_email or item.email,
"normalized_email": item.normalized_email or item.email.casefold(),
"provenance": dict(item.provenance or {}),
"is_primary": item.is_primary,
**quality_payload("email", item.id),
}
for item in contact.emails
],
"phones": [
{
"id": item.id,
"label": item.label,
"phone": item.phone,
"original_phone": item.original_phone or item.phone,
"normalized_phone": item.normalized_phone or item.phone,
"provenance": dict(item.provenance or {}),
"is_primary": item.is_primary,
**quality_payload("phone", item.id),
}
for item in contact.phones
],
"postal_addresses": [
{
"id": item.id,
"label": item.label,
"street": item.street,
"postal_code": item.postal_code,
"locality": item.locality,
"region": item.region,
"country": item.country,
"original_value": dict(item.original_value or {}),
"normalized_value": dict(item.normalized_value or {}),
"provenance": dict(item.provenance or {}),
"is_primary": item.is_primary,
**quality_payload("postal", item.id),
}
for item in contact.postal_addresses
],
"field_provenance": field_provenance or [],
"deleted_at": contact.deleted_at,
"created_at": contact.created_at,
"updated_at": contact.updated_at,
}
)
def _contact_point_audit_details(
contact: Contact,
*,
prefix: str = "",
) -> dict[str, object]:
key_prefix = f"{prefix}_" if prefix else ""
point_ids = {
"email": [item.id for item in contact.emails],
"phone": [item.id for item in contact.phones],
"postal": [item.id for item in contact.postal_addresses],
}
return {
f"{key_prefix}contact_point_counts": {
channel: len(ids) for channel, ids in point_ids.items()
},
f"{key_prefix}contact_point_ids": point_ids,
}
def _channel_rule_response(rule: ContactChannelRule) -> ContactChannelRuleResponse:
return ContactChannelRuleResponse.model_validate(rule)
def _quality_decision_response(
decision: ContactPointQualityDecision,
) -> ContactPointQualityDecisionResponse:
return ContactPointQualityDecisionResponse.model_validate(decision)
def _merge_response(record: ContactMergeRecord) -> ContactMergeRecordResponse:
return ContactMergeRecordResponse.model_validate(record)
def _address_list_response(address_list: AddressList, *, entry_count: int = 0) -> AddressListResponse:
return AddressListResponse.model_validate(
{
@@ -543,6 +678,339 @@ def api_lookup_addresses(
return AddressLookupResponse(contacts=[_contact_response(contact) for contact in contacts])
@router.get(
"/address-books/{book_id}/duplicate-suggestions",
response_model=ContactDuplicateSuggestionListResponse,
)
def api_suggest_duplicate_contacts(
book_id: str,
contact_id: str | None = Query(default=None),
minimum_score: int = Query(default=40, ge=1, le=100),
limit: int = Query(default=100, ge=1, le=100),
scan_limit: int = Query(default=500, ge=2, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
try:
scan = suggest_duplicate_contacts(
session,
principal,
address_book_id=book_id,
contact_id=contact_id,
minimum_score=minimum_score,
limit=limit,
scan_limit=scan_limit,
)
return ContactDuplicateSuggestionListResponse(
suggestions=[
ContactDuplicateSuggestionResponse(
left=_contact_response(item.left),
right=_contact_response(item.right),
score=item.score,
confidence=item.confidence,
features=[
ContactDuplicateFeatureResponse(**asdict(feature))
for feature in item.features
],
)
for item in scan.suggestions
],
scanned_contacts=scan.scanned_contacts,
candidate_pairs=scan.candidate_pairs,
truncated=scan.truncated,
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.get(
"/address-books/{book_id}/quality-summary",
response_model=AddressQualitySummaryResponse,
)
def api_address_quality_summary(
book_id: str,
correction_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:
summary = address_quality_summary(
session,
principal,
address_book_id=book_id,
correction_limit=correction_limit,
)
return AddressQualitySummaryResponse(
contact_count=summary.contact_count,
contact_point_count=summary.contact_point_count,
quality_counts=summary.quality_counts,
duplicate_suggestion_count=summary.duplicate_suggestion_count,
correction_count=summary.correction_count,
corrections=[
AddressQualityCorrectionResponse(**asdict(item))
for item in summary.corrections
],
truncated=summary.truncated,
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.get(
"/contacts/{contact_id}/quality-decisions",
response_model=ContactPointQualityDecisionListResponse,
)
def api_list_contact_quality_decisions(
contact_id: str,
include_ended: bool = Query(default=True),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:governance:read")
try:
return ContactPointQualityDecisionListResponse(
decisions=[
_quality_decision_response(item)
for item in list_contact_quality_decisions(
session,
principal,
contact_id,
include_ended=include_ended,
)
]
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.post(
"/contacts/{contact_id}/quality-decisions",
response_model=ContactPointQualityDecisionResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_contact_quality_decision(
contact_id: str,
payload: ContactPointQualityDecisionCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:governance:write")
try:
decision = create_contact_quality_decision(
session,
principal,
contact_id,
payload,
)
audit_from_principal(
session,
principal,
action="addresses.contact_quality_changed",
object_type="address_contact_quality_decision",
object_id=decision.id,
details={
"contact_id": contact_id,
"channel": decision.channel,
"contact_point_id": decision.contact_point_id,
"state": decision.state,
"reason_code": decision.reason_code,
"evidence_ref": decision.evidence_ref,
},
)
session.commit()
session.refresh(decision)
return _quality_decision_response(decision)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.get(
"/contacts/{contact_id}/provenance",
response_model=list[ContactFieldProvenanceResponse],
)
def api_list_contact_provenance(
contact_id: str,
current_only: bool = Query(default=False),
limit: int = Query(default=500, ge=1, le=2000),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
try:
return [
ContactFieldProvenanceResponse.model_validate(item)
for item in list_contact_field_provenance(
session,
principal,
contact_id,
current_only=current_only,
limit=limit,
)
]
except AddressBookError as exc:
raise _error(exc) from exc
@router.get(
"/contacts/{contact_id}/redirect",
response_model=ContactRedirectResponse,
)
def api_resolve_contact_redirect(
contact_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
try:
return ContactRedirectResponse.model_validate(
asdict(resolve_contact_redirect(session, principal, contact_id))
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.get("/contact-merges", response_model=ContactMergeRecordListResponse)
def api_list_contact_merges(
address_book_id: str | None = Query(default=None),
contact_id: str | None = Query(default=None),
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")
try:
return ContactMergeRecordListResponse(
merges=[
_merge_response(item)
for item in list_contact_merges(
session,
principal,
address_book_id=address_book_id,
contact_id=contact_id,
limit=limit,
)
]
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.post(
"/contact-merges",
response_model=ContactMergeRecordResponse,
status_code=status.HTTP_201_CREATED,
)
def api_merge_contacts(
payload: ContactMergeRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:write")
_require_scope(principal, "addresses:contact:delete")
try:
record = merge_contacts(session, principal, payload)
audit_from_principal(
session,
principal,
action="addresses.contacts_merged",
object_type="address_contact_merge",
object_id=record.id,
details={
"winner_contact_id": record.winner_contact_id,
"loser_contact_ids": list(record.loser_contact_ids),
"before_hash": record.before_hash,
"after_hash": record.after_hash,
"reason": record.reason,
},
)
session.commit()
session.refresh(record)
return _merge_response(record)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
def _recover_contact_merge_api(
merge_id: str,
payload: ContactMergeRecoveryRequest,
principal: ApiPrincipal,
session: Session,
*,
action: str,
) -> ContactMergeRecordResponse:
_require_scope(principal, "addresses:contact:write")
try:
record = recover_contact_merge(
session,
principal,
merge_id,
payload,
action=action,
)
audit_from_principal(
session,
principal,
action=f"addresses.contact_merge_{action}",
object_type="address_contact_merge",
object_id=record.id,
details={
"winner_contact_id": record.winner_contact_id,
"loser_contact_ids": list(record.loser_contact_ids),
"expected_after_hash": payload.expected_after_hash,
"reason": payload.reason,
},
)
session.commit()
session.refresh(record)
return _merge_response(record)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.post(
"/contact-merges/{merge_id}/undo",
response_model=ContactMergeRecordResponse,
)
def api_undo_contact_merge(
merge_id: str,
payload: ContactMergeRecoveryRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
return _recover_contact_merge_api(
merge_id,
payload,
principal,
session,
action="undo",
)
@router.post(
"/contact-merges/{merge_id}/split",
response_model=ContactMergeRecordResponse,
)
def api_split_contact_merge(
merge_id: str,
payload: ContactMergeRecoveryRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
return _recover_contact_merge_api(
merge_id,
payload,
principal,
session,
action="split",
)
@router.post("/contact-points/resolve", response_model=ContactPointResolutionResponse)
def api_resolve_contact_points(
payload: ContactPointResolveRequest,
@@ -774,6 +1242,19 @@ def api_create_address_list_entry(
_require_scope(principal, "addresses:address_list:write")
try:
entry = create_address_list_entry(session, principal, address_list_id, payload)
session.flush()
audit_from_principal(
session,
principal,
action="addresses.address_list_entry_created",
object_type="address_list_entry",
object_id=entry.id,
details={
"address_list_id": entry.address_list_id,
"contact_id": entry.contact_id,
"target_kind": entry.target_kind,
},
)
session.commit()
session.refresh(entry)
return _address_list_entry_response(entry)
@@ -790,7 +1271,20 @@ def api_delete_address_list_entry(
):
_require_scope(principal, "addresses:address_list:write")
try:
entry = session.get(AddressListEntry, entry_id)
delete_address_list_entry(session, principal, entry_id)
audit_from_principal(
session,
principal,
action="addresses.address_list_entry_deleted",
object_type="address_list_entry",
object_id=entry_id,
details={
"address_list_id": entry.address_list_id if entry is not None else None,
"contact_id": entry.contact_id if entry is not None else None,
"target_kind": entry.target_kind if entry is not None else None,
},
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except AddressBookError as exc:
@@ -1234,6 +1728,19 @@ def api_create_contact(
_require_scope(principal, "addresses:contact:write")
try:
contact = create_contact(session, principal, book_id, payload)
audit_from_principal(
session,
principal,
action="addresses.contact_created",
object_type="address_contact",
object_id=contact.id,
details={
"address_book_id": contact.address_book_id,
"source_kind": contact.source_kind,
"field_names": sorted(payload.model_fields_set),
**_contact_point_audit_details(contact),
},
)
session.commit()
session.refresh(contact)
return _contact_response(contact)
@@ -1251,7 +1758,27 @@ def api_update_contact(
):
_require_scope(principal, "addresses:contact:write")
try:
previous_contact = session.get(Contact, contact_id)
previous_point_details = (
_contact_point_audit_details(previous_contact, prefix="previous")
if previous_contact is not None
else {}
)
contact = update_contact(session, principal, contact_id, payload)
audit_from_principal(
session,
principal,
action="addresses.contact_updated",
object_type="address_contact",
object_id=contact.id,
details={
"address_book_id": contact.address_book_id,
"source_kind": contact.source_kind,
"field_names": sorted(payload.model_fields_set),
**previous_point_details,
**_contact_point_audit_details(contact),
},
)
session.commit()
session.refresh(contact)
return _contact_response(contact)
@@ -1360,6 +1887,18 @@ def api_delete_contact(
_require_scope(principal, "addresses:contact:delete")
try:
delete_contact(session, principal, contact_id)
contact = session.get(Contact, contact_id)
audit_from_principal(
session,
principal,
action="addresses.contact_deleted",
object_type="address_contact",
object_id=contact_id,
details={
"address_book_id": contact.address_book_id if contact is not None else None,
"source_kind": contact.source_kind if contact is not None else None,
},
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except AddressBookError as exc:
@@ -1376,6 +1915,17 @@ def api_restore_contact(
_require_scope(principal, "addresses:contact:write")
try:
contact = restore_contact(session, principal, contact_id)
audit_from_principal(
session,
principal,
action="addresses.contact_restored",
object_type="address_contact",
object_id=contact.id,
details={
"address_book_id": contact.address_book_id,
"source_kind": contact.source_kind,
},
)
session.commit()
session.refresh(contact)
return _contact_response(contact)
@@ -1394,6 +1944,20 @@ def api_import_address_book_vcards(
_require_scope(principal, "addresses:contact:write")
try:
result = import_vcards(session, principal, book_id, payload.content)
for contact in result.contacts:
audit_from_principal(
session,
principal,
action="addresses.contact_imported",
object_type="address_contact",
object_id=contact.id,
details={
"address_book_id": book_id,
"source_kind": contact.source_kind,
"source_revision": contact.source_revision,
**_contact_point_audit_details(contact),
},
)
session.commit()
for contact in result.contacts:
session.refresh(contact)
+185
View File
@@ -15,6 +15,13 @@ AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "ma
AddressCardDavAuthType = Literal["none", "basic", "bearer"]
AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"]
AddressDistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
AddressContactPointChannel = Literal[
"email",
"phone",
"postal",
"internal_mail",
"portal",
]
AddressChannelDecision = Literal[
"allowed",
"opted_in",
@@ -38,6 +45,13 @@ AddressDistributionOutcome = Literal[
]
AddressContactPointFallbackRule = Literal["none", "primary", "any"]
AddressPostalFormat = Literal["domestic", "international"]
ContactPointQualityState = Literal[
"valid",
"invalid",
"returned",
"stale",
"undeliverable",
]
class ContactEmailPayload(BaseModel):
@@ -186,12 +200,75 @@ class ContactUpdateRequest(BaseModel):
provenance: dict[str, Any] | None = None
class ContactFieldProvenanceResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
contact_id: str
field_path: str
value: Any = None
source_kind: str
source_ref: str | None = None
source_revision: str | None = None
precedence: int
selected: bool
reason_code: str
explanation: str | None = None
visibility: str
merge_record_id: str | None = None
created_by_account_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
created_at: datetime
class ContactPointQualityDecisionCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
channel: AddressContactPointChannel
contact_point_id: str | None = Field(default=None, max_length=36)
state: ContactPointQualityState
reason_code: str | None = Field(default=None, max_length=120)
reason: str | None = None
evidence_ref: str | None = Field(default=None, max_length=1000)
effective_from: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ContactPointQualityDecisionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
contact_id: str
channel: AddressContactPointChannel
contact_point_id: str | None = None
state: ContactPointQualityState
reason_code: str
reason: str | None = None
evidence_ref: str | None = None
effective_from: datetime
effective_until: datetime | None = None
created_by_account_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
created_at: datetime
updated_at: datetime
class ContactPointQualityDecisionListResponse(BaseModel):
decisions: list[ContactPointQualityDecisionResponse] = Field(default_factory=list)
class ContactEmailResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
label: str | None = None
email: str
original_email: str = ""
normalized_email: str = ""
provenance: dict[str, Any] = Field(default_factory=dict)
quality_state: ContactPointQualityState = "valid"
quality_reason_code: str | None = None
is_primary: bool
@@ -201,6 +278,11 @@ class ContactPhoneResponse(BaseModel):
id: str
label: str | None = None
phone: str
original_phone: str = ""
normalized_phone: str = ""
provenance: dict[str, Any] = Field(default_factory=dict)
quality_state: ContactPointQualityState = "valid"
quality_reason_code: str | None = None
is_primary: bool
@@ -214,6 +296,11 @@ class ContactPostalAddressResponse(BaseModel):
locality: str | None = None
region: str | None = None
country: str | None = None
original_value: dict[str, Any] = Field(default_factory=dict)
normalized_value: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
quality_state: ContactPointQualityState = "valid"
quality_reason_code: str | None = None
is_primary: bool
@@ -238,6 +325,7 @@ class ContactResponse(BaseModel):
emails: list[ContactEmailResponse]
phones: list[ContactPhoneResponse]
postal_addresses: list[ContactPostalAddressResponse]
field_provenance: list[ContactFieldProvenanceResponse] = Field(default_factory=list)
deleted_at: datetime | None = None
created_at: datetime
updated_at: datetime
@@ -251,6 +339,103 @@ class ContactListResponse(BaseModel):
has_more: bool
class ContactDuplicateFeatureResponse(BaseModel):
code: str
label: str
weight: int
value: str
class ContactDuplicateSuggestionResponse(BaseModel):
left: ContactResponse
right: ContactResponse
score: int
confidence: Literal["possible", "likely", "strong"]
features: list[ContactDuplicateFeatureResponse]
class ContactDuplicateSuggestionListResponse(BaseModel):
suggestions: list[ContactDuplicateSuggestionResponse] = Field(default_factory=list)
scanned_contacts: int
candidate_pairs: int
truncated: bool
class ContactMergeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
winner_contact_id: str = Field(max_length=36)
duplicate_contact_ids: list[str] = Field(min_length=1, max_length=20)
reason: str = Field(min_length=3)
field_sources: dict[str, str] = Field(default_factory=dict)
contact_point_strategy: Literal["union", "winner_only"] = "union"
source_precedence: list[str] = Field(default_factory=list, max_length=20)
class ContactMergeRecoveryRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
reason: str = Field(min_length=3)
expected_after_hash: str = Field(min_length=64, max_length=64)
class ContactMergeRecordResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
address_book_id: str
winner_contact_id: str
loser_contact_ids: list[str]
status: str
reason: str
survivorship: dict[str, Any]
decisions: list[dict[str, Any]]
before_hash: str
after_hash: str
created_by_account_id: str | None = None
recovered_at: datetime | None = None
recovered_by_account_id: str | None = None
recovery_action: str | None = None
recovery_reason: str | None = None
provenance: dict[str, Any]
created_at: datetime
updated_at: datetime
class ContactMergeRecordListResponse(BaseModel):
merges: list[ContactMergeRecordResponse] = Field(default_factory=list)
class ContactRedirectResponse(BaseModel):
requested_contact_id: str
resolved_contact_id: str
redirected: bool
redirect_chain: list[str] = Field(default_factory=list)
merge_record_ids: list[str] = Field(default_factory=list)
class AddressQualityCorrectionResponse(BaseModel):
contact_id: str
display_name: str
channel: AddressContactPointChannel
contact_point_id: str | None = None
state: ContactPointQualityState
reason_code: str
reason: str | None = None
effective_from: datetime
class AddressQualitySummaryResponse(BaseModel):
contact_count: int
contact_point_count: int
quality_counts: dict[str, int] = Field(default_factory=dict)
duplicate_suggestion_count: int
correction_count: int
corrections: list[AddressQualityCorrectionResponse] = Field(default_factory=list)
truncated: bool = False
class ContactChannelRuleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
File diff suppressed because it is too large Load Diff