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
+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)