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