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