Implement address quality and reversible contact merges
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user