2593 lines
89 KiB
Python
2593 lines
89 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import asdict
|
|
import json
|
|
import re
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.audit.logging import audit_from_principal
|
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
|
from govoplan_core.core.contact_points import (
|
|
ContactPointResolutionRequest,
|
|
ContactPointSourceRequest,
|
|
)
|
|
from govoplan_core.core.distribution_lists import DistributionSourceReference
|
|
from govoplan_core.db.session import get_session
|
|
from govoplan_addresses.backend.carddav import AddressCardDAVError
|
|
from govoplan_addresses.backend.ldap import AddressLdapError
|
|
from govoplan_addresses.backend.ldap_schemas import (
|
|
AddressLdapConnectionRequest,
|
|
AddressLdapDiscoveryResponse,
|
|
AddressLdapSourceCreateRequest,
|
|
)
|
|
from govoplan_addresses.backend.db.models import (
|
|
AddressBook,
|
|
AddressList,
|
|
AddressListEntry,
|
|
AddressSyncConflict,
|
|
AddressSyncDiagnostic,
|
|
AddressSyncSource,
|
|
AddressSyncTombstone,
|
|
Contact,
|
|
ContactChannelRule,
|
|
ContactMergeRecord,
|
|
ContactPointQualityDecision,
|
|
ContactPostalAddress,
|
|
)
|
|
from govoplan_addresses.backend.import_schemas import (
|
|
AddressImportCommitRequest,
|
|
AddressImportPreviewRequest,
|
|
AddressImportProfileCreateRequest,
|
|
AddressImportProfileListResponse,
|
|
AddressImportProfileResponse,
|
|
AddressImportProfileUpdateRequest,
|
|
AddressImportRollbackRequest,
|
|
AddressImportRunResponse,
|
|
)
|
|
from govoplan_addresses.backend.imports import (
|
|
apply_address_import,
|
|
create_import_profile,
|
|
get_import_run,
|
|
import_run_payload,
|
|
list_import_profiles,
|
|
preview_address_import,
|
|
retire_import_profile,
|
|
rollback_address_import,
|
|
update_import_profile,
|
|
)
|
|
from govoplan_addresses.backend.vcard_batch_schemas import (
|
|
VCardBatchCancelRequest,
|
|
VCardBatchCommitRequest,
|
|
VCardBatchPreviewRequest,
|
|
VCardBatchRunResponse,
|
|
VCardExportRequest,
|
|
VCardExportResponse,
|
|
)
|
|
from govoplan_addresses.backend.vcard_batches import (
|
|
apply_vcard_batch,
|
|
cancel_vcard_batch,
|
|
export_vcards,
|
|
get_vcard_batch_run,
|
|
preview_vcard_batch,
|
|
vcard_batch_payload,
|
|
vcard_diagnostics_payload,
|
|
)
|
|
from govoplan_addresses.backend.capabilities import (
|
|
AddressesContactPointResolutionCapability,
|
|
AddressesContactWriterCapability,
|
|
)
|
|
from govoplan_addresses.backend.schemas import (
|
|
AddressBookCreateRequest,
|
|
AddressBookListResponse,
|
|
AddressBookResponse,
|
|
AddressBookUpdateRequest,
|
|
AddressBookWriteDecisionResponse,
|
|
AddressBookWriteTargetsResponse,
|
|
AddressListCreateRequest,
|
|
AddressListEntryCreateRequest,
|
|
AddressListEntryListResponse,
|
|
AddressListEntryResponse,
|
|
AddressListListResponse,
|
|
AddressListResponse,
|
|
AddressListUpdateRequest,
|
|
AddressLookupResponse,
|
|
AddressCardDavAddressBookResponse,
|
|
AddressCardDavDiscoveryRequest,
|
|
AddressCardDavDiscoveryResponse,
|
|
AddressCardDavSourceCreateRequest,
|
|
AddressCredentialEnvelopeListResponse,
|
|
AddressCredentialEnvelopeResponse,
|
|
AddressSyncAttemptFinishRequest,
|
|
AddressSyncConflictCreateRequest,
|
|
AddressSyncConflictListResponse,
|
|
AddressSyncConflictResolveRequest,
|
|
AddressSyncConflictResponse,
|
|
AddressSyncDiagnosticCreateRequest,
|
|
AddressSyncDiagnosticListResponse,
|
|
AddressSyncDiagnosticResponse,
|
|
AddressSyncSourceCreateRequest,
|
|
AddressSyncSourceListResponse,
|
|
AddressSyncSourceResponse,
|
|
AddressSyncSourceUpdateRequest,
|
|
AddressSyncRunRequest,
|
|
AddressSyncPlanItemResponse,
|
|
AddressSyncPlanResponse,
|
|
AddressSyncTombstoneCreateRequest,
|
|
AddressSyncTombstoneListResponse,
|
|
AddressSyncTombstoneResponse,
|
|
ContactCreateRequest,
|
|
ContactChannelRuleCreateRequest,
|
|
ContactChannelRuleListResponse,
|
|
ContactChannelRuleResponse,
|
|
ContactDuplicateFeatureResponse,
|
|
ContactDuplicateSuggestionListResponse,
|
|
ContactDuplicateSuggestionResponse,
|
|
ContactFieldProvenanceResponse,
|
|
ContactMergeRecordListResponse,
|
|
ContactMergeRecordResponse,
|
|
ContactMergeRecoveryRequest,
|
|
ContactMergeRequest,
|
|
ContactPointQualityDecisionCreateRequest,
|
|
ContactPointQualityDecisionListResponse,
|
|
ContactPointQualityDecisionResponse,
|
|
ContactRedirectResponse,
|
|
ContactPointResolveRequest,
|
|
ContactPointResolutionResponse,
|
|
ContactPointSnapshotResponse,
|
|
ContactPointSourcePreviewResponse,
|
|
ContactPointSourceRequestPayload,
|
|
ContactListResponse,
|
|
ContactResponse,
|
|
ContactUpdateRequest,
|
|
AddressQualityCorrectionResponse,
|
|
AddressQualitySummaryResponse,
|
|
VCardImportIssue,
|
|
VCardImportRequest,
|
|
VCardImportResponse,
|
|
)
|
|
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,
|
|
create_address_list_entry,
|
|
create_carddav_sync_source,
|
|
create_ldap_sync_source,
|
|
create_contact,
|
|
create_contact_channel_rule,
|
|
create_contact_quality_decision,
|
|
current_contact_quality,
|
|
create_sync_source,
|
|
count_contacts,
|
|
delete_address_book,
|
|
delete_address_list,
|
|
delete_address_list_entry,
|
|
delete_contact,
|
|
delete_sync_source,
|
|
discover_carddav_address_books,
|
|
discover_ldap_base_dns,
|
|
export_address_book_vcard,
|
|
export_contact_vcard,
|
|
end_contact_channel_rule,
|
|
finish_sync_attempt,
|
|
import_vcards,
|
|
list_address_list_entries,
|
|
list_address_lists,
|
|
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,
|
|
list_sync_tombstones,
|
|
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,
|
|
run_sync_source,
|
|
start_sync_attempt,
|
|
update_address_book,
|
|
update_address_list,
|
|
update_contact,
|
|
suggest_duplicate_contacts,
|
|
update_sync_source,
|
|
)
|
|
|
|
router = APIRouter(prefix="/addresses", tags=["addresses"])
|
|
|
|
|
|
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
|
if not has_scope(principal, scope):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
|
|
|
|
|
def _error(exc: AddressBookError) -> HTTPException:
|
|
message = str(exc)
|
|
status_code = status.HTTP_404_NOT_FOUND if message.endswith("not found.") else status.HTTP_422_UNPROCESSABLE_CONTENT
|
|
return HTTPException(status_code=status_code, detail=message)
|
|
|
|
|
|
def _book_response(book: AddressBook, *, contact_count: int = 0) -> AddressBookResponse:
|
|
return AddressBookResponse.model_validate(
|
|
{
|
|
"id": book.id,
|
|
"tenant_id": book.tenant_id,
|
|
"scope_type": book.scope_type,
|
|
"scope_id": book.scope_id,
|
|
"name": book.name,
|
|
"description": book.description,
|
|
"source_kind": book.source_kind,
|
|
"source_ref": book.source_ref,
|
|
"read_only": book.read_only,
|
|
"sync_status": book.sync_status,
|
|
"sync_error": book.sync_error,
|
|
"contact_count": contact_count,
|
|
"deleted_at": book.deleted_at,
|
|
"created_at": book.created_at,
|
|
"updated_at": book.updated_at,
|
|
}
|
|
)
|
|
|
|
|
|
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(
|
|
{
|
|
"id": address_list.id,
|
|
"tenant_id": address_list.tenant_id,
|
|
"address_book_id": address_list.address_book_id,
|
|
"name": address_list.name,
|
|
"description": address_list.description,
|
|
"source_kind": address_list.source_kind,
|
|
"source_ref": address_list.source_ref,
|
|
"read_only": address_list.read_only,
|
|
"entry_count": entry_count,
|
|
"deleted_at": address_list.deleted_at,
|
|
"created_at": address_list.created_at,
|
|
"updated_at": address_list.updated_at,
|
|
}
|
|
)
|
|
|
|
|
|
def _address_list_entry_response(entry: AddressListEntry) -> AddressListEntryResponse:
|
|
return AddressListEntryResponse.model_validate(
|
|
{
|
|
"id": entry.id,
|
|
"address_list_id": entry.address_list_id,
|
|
"contact_id": entry.contact_id,
|
|
"contact_email_id": entry.contact_email_id,
|
|
"contact_postal_address_id": entry.contact_postal_address_id,
|
|
"target_kind": entry.target_kind,
|
|
"label": entry.label,
|
|
"order_index": entry.order_index,
|
|
"contact_display_name": entry.contact.display_name,
|
|
"email": entry.contact_email.email if entry.contact_email is not None else None,
|
|
"postal_address": _postal_address_summary(entry.contact_postal_address),
|
|
"created_at": entry.created_at,
|
|
"updated_at": entry.updated_at,
|
|
}
|
|
)
|
|
|
|
|
|
def _write_decision_response(decision) -> AddressBookWriteDecisionResponse:
|
|
payload = asdict(decision)
|
|
payload["required_scopes"] = list(decision.required_scopes)
|
|
return AddressBookWriteDecisionResponse.model_validate(payload)
|
|
|
|
|
|
def _contact_point_resolution_request(
|
|
principal: ApiPrincipal,
|
|
payload: ContactPointResolveRequest,
|
|
) -> ContactPointResolutionRequest:
|
|
return ContactPointResolutionRequest(
|
|
tenant_id=principal.tenant_id,
|
|
subject=DistributionSourceReference(**payload.subject.model_dump()),
|
|
effective_at=payload.effective_at,
|
|
purpose=payload.purpose,
|
|
requested_channels=tuple(payload.requested_channels),
|
|
address_purpose=payload.address_purpose,
|
|
fallback_rule=payload.fallback_rule,
|
|
locale=payload.locale,
|
|
postal_format=payload.postal_format,
|
|
context=payload.context,
|
|
)
|
|
|
|
|
|
def _contact_point_source_request(
|
|
principal: ApiPrincipal,
|
|
payload: ContactPointSourceRequestPayload,
|
|
) -> ContactPointSourceRequest:
|
|
return ContactPointSourceRequest(
|
|
tenant_id=principal.tenant_id,
|
|
source_id=payload.source_id,
|
|
effective_at=payload.effective_at,
|
|
purpose=payload.purpose,
|
|
requested_channels=tuple(payload.requested_channels),
|
|
address_purpose=payload.address_purpose,
|
|
fallback_rule=payload.fallback_rule,
|
|
locale=payload.locale,
|
|
postal_format=payload.postal_format,
|
|
max_items=payload.max_items,
|
|
context=payload.context,
|
|
)
|
|
|
|
|
|
def _sync_source_response(sync_source: AddressSyncSource) -> AddressSyncSourceResponse:
|
|
return AddressSyncSourceResponse.model_validate(
|
|
{
|
|
"id": sync_source.id,
|
|
"tenant_id": sync_source.tenant_id,
|
|
"address_book_id": sync_source.address_book_id,
|
|
"connector_type": sync_source.connector_type,
|
|
"display_name": sync_source.display_name,
|
|
"external_account_ref": sync_source.external_account_ref,
|
|
"external_address_book_ref": sync_source.external_address_book_ref,
|
|
"sync_direction": sync_source.sync_direction,
|
|
"read_only": sync_source.read_only,
|
|
"enabled": sync_source.enabled,
|
|
"status": sync_source.status,
|
|
"sync_token": sync_source.sync_token,
|
|
"etag": sync_source.etag,
|
|
"remote_revision": sync_source.remote_revision,
|
|
"last_attempted_at": sync_source.last_attempted_at,
|
|
"last_success_at": sync_source.last_success_at,
|
|
"last_error": sync_source.last_error,
|
|
"last_diagnostic": sync_source.last_diagnostic,
|
|
"metadata": public_address_sync_metadata(sync_source.metadata_),
|
|
"created_at": sync_source.created_at,
|
|
"updated_at": sync_source.updated_at,
|
|
}
|
|
)
|
|
|
|
|
|
def _sync_diagnostic_response(
|
|
diagnostic: AddressSyncDiagnostic,
|
|
) -> AddressSyncDiagnosticResponse:
|
|
return AddressSyncDiagnosticResponse.model_validate(
|
|
{
|
|
"id": diagnostic.id,
|
|
"tenant_id": diagnostic.tenant_id,
|
|
"sync_source_id": diagnostic.sync_source_id,
|
|
"severity": diagnostic.severity,
|
|
"code": diagnostic.code,
|
|
"message": diagnostic.message,
|
|
"details": diagnostic.details or {},
|
|
"created_at": diagnostic.created_at,
|
|
"updated_at": diagnostic.updated_at,
|
|
}
|
|
)
|
|
|
|
|
|
def _sync_tombstone_response(
|
|
tombstone: AddressSyncTombstone,
|
|
) -> AddressSyncTombstoneResponse:
|
|
return AddressSyncTombstoneResponse.model_validate(
|
|
{
|
|
"id": tombstone.id,
|
|
"tenant_id": tombstone.tenant_id,
|
|
"sync_source_id": tombstone.sync_source_id,
|
|
"address_book_id": tombstone.address_book_id,
|
|
"contact_id": tombstone.contact_id,
|
|
"remote_uid": tombstone.remote_uid,
|
|
"resource_href": tombstone.resource_href,
|
|
"local_deleted_at": tombstone.local_deleted_at,
|
|
"remote_deleted_at": tombstone.remote_deleted_at,
|
|
"synced_at": tombstone.synced_at,
|
|
"metadata": tombstone.metadata_ or {},
|
|
"created_at": tombstone.created_at,
|
|
"updated_at": tombstone.updated_at,
|
|
}
|
|
)
|
|
|
|
|
|
def _sync_conflict_response(
|
|
conflict: AddressSyncConflict,
|
|
) -> AddressSyncConflictResponse:
|
|
return AddressSyncConflictResponse.model_validate(
|
|
{
|
|
"id": conflict.id,
|
|
"tenant_id": conflict.tenant_id,
|
|
"sync_source_id": conflict.sync_source_id,
|
|
"address_book_id": conflict.address_book_id,
|
|
"contact_id": conflict.contact_id,
|
|
"remote_uid": conflict.remote_uid,
|
|
"resource_href": conflict.resource_href,
|
|
"field_path": conflict.field_path,
|
|
"local_value": conflict.local_value,
|
|
"remote_value": conflict.remote_value,
|
|
"local_updated_at": conflict.local_updated_at,
|
|
"remote_updated_at": conflict.remote_updated_at,
|
|
"status": conflict.status,
|
|
"resolution": conflict.resolution,
|
|
"resolved_at": conflict.resolved_at,
|
|
"resolved_by_account_id": conflict.resolved_by_account_id,
|
|
"metadata": conflict.metadata_ or {},
|
|
"created_at": conflict.created_at,
|
|
"updated_at": conflict.updated_at,
|
|
}
|
|
)
|
|
|
|
|
|
def _sync_plan_response(plan) -> AddressSyncPlanResponse:
|
|
return AddressSyncPlanResponse(
|
|
sync_source=_sync_source_response(plan.sync_source),
|
|
stats=plan.stats,
|
|
items=[
|
|
AddressSyncPlanItemResponse(
|
|
action=item.action,
|
|
href=item.href,
|
|
remote_uid=item.remote_uid,
|
|
contact_id=item.contact_id,
|
|
display_name=item.display_name,
|
|
etag=item.etag,
|
|
message=item.message,
|
|
)
|
|
for item in plan.items
|
|
],
|
|
)
|
|
|
|
|
|
def _audit_address_sync(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
action: str,
|
|
object_type: str,
|
|
object_id: str,
|
|
details: dict,
|
|
) -> None:
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action=action,
|
|
object_type=object_type,
|
|
object_id=object_id,
|
|
details=details,
|
|
)
|
|
|
|
|
|
def _safe_filename(value: str) -> str:
|
|
slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip()).strip("-")
|
|
return slug or "address-book"
|
|
|
|
|
|
def _postal_address_summary(address: ContactPostalAddress | None) -> str | None:
|
|
if address is None:
|
|
return None
|
|
parts = [
|
|
address.street,
|
|
" ".join(part for part in (address.postal_code, address.locality) if part),
|
|
address.region,
|
|
address.country,
|
|
]
|
|
return ", ".join(part for part in parts if part) or None
|
|
|
|
|
|
@router.get("/address-books", response_model=AddressBookListResponse)
|
|
def api_list_address_books(
|
|
include_deleted: bool = Query(default=False),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_book:read")
|
|
books = list_address_books(session, principal, include_deleted=include_deleted)
|
|
counts = address_book_contact_counts(session, [book.id for book in books], include_deleted=include_deleted)
|
|
return AddressBookListResponse(address_books=[_book_response(book, contact_count=counts.get(book.id, 0)) for book in books])
|
|
|
|
|
|
@router.post(
|
|
"/address-books",
|
|
response_model=AddressBookResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_address_book(
|
|
payload: AddressBookCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_book:write")
|
|
try:
|
|
book = create_address_book(
|
|
session,
|
|
principal,
|
|
payload,
|
|
allow_system=has_scope(principal, "addresses:address_book:admin"),
|
|
)
|
|
session.commit()
|
|
session.refresh(book)
|
|
return _book_response(book)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.patch("/address-books/{book_id}", response_model=AddressBookResponse)
|
|
def api_update_address_book(
|
|
book_id: str,
|
|
payload: AddressBookUpdateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_book:write")
|
|
try:
|
|
book = update_address_book(session, principal, book_id, payload)
|
|
session.commit()
|
|
session.refresh(book)
|
|
counts = address_book_contact_counts(session, [book.id])
|
|
return _book_response(book, contact_count=counts.get(book.id, 0))
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.delete("/address-books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def api_delete_address_book(
|
|
book_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_book:delete")
|
|
try:
|
|
delete_address_book(session, principal, book_id)
|
|
session.commit()
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post("/address-books/{book_id}/restore", response_model=AddressBookResponse)
|
|
def api_restore_address_book(
|
|
book_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_book:write")
|
|
try:
|
|
book = restore_address_book(session, principal, book_id)
|
|
session.commit()
|
|
session.refresh(book)
|
|
counts = address_book_contact_counts(session, [book.id])
|
|
return _book_response(book, contact_count=counts.get(book.id, 0))
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get("/contacts", response_model=ContactListResponse)
|
|
def api_list_contacts(
|
|
address_book_id: str | None = Query(default=None),
|
|
address_list_id: str | None = Query(default=None),
|
|
query: str | None = Query(default=None),
|
|
limit: int = Query(default=200, ge=1, le=500),
|
|
offset: int = Query(default=0, ge=0),
|
|
include_deleted: bool = Query(default=False),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
try:
|
|
contacts = list_contacts(
|
|
session,
|
|
principal,
|
|
address_book_id=address_book_id,
|
|
address_list_id=address_list_id,
|
|
query=query,
|
|
limit=limit,
|
|
offset=offset,
|
|
include_deleted=include_deleted,
|
|
)
|
|
total = count_contacts(
|
|
session,
|
|
principal,
|
|
address_book_id=address_book_id,
|
|
address_list_id=address_list_id,
|
|
query=query,
|
|
include_deleted=include_deleted,
|
|
)
|
|
return ContactListResponse(
|
|
contacts=[_contact_response(contact) for contact in contacts],
|
|
total=total,
|
|
offset=offset,
|
|
limit=limit,
|
|
has_more=offset + len(contacts) < total,
|
|
)
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get("/lookup", response_model=AddressLookupResponse)
|
|
def api_lookup_addresses(
|
|
query: str = Query(min_length=1),
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
contacts = list_contacts(session, principal, query=query, limit=limit)
|
|
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,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
_require_scope(principal, "addresses:governance:read")
|
|
try:
|
|
result = AddressesContactPointResolutionCapability().resolve_contact_points(
|
|
session,
|
|
principal,
|
|
request=_contact_point_resolution_request(principal, payload),
|
|
)
|
|
return ContactPointResolutionResponse.model_validate(asdict(result))
|
|
except (AddressBookError, ValueError) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
@router.post(
|
|
"/contact-point-sources/preview",
|
|
response_model=ContactPointSourcePreviewResponse,
|
|
)
|
|
def api_preview_contact_point_source(
|
|
payload: ContactPointSourceRequestPayload,
|
|
offset: int = Query(default=0, ge=0),
|
|
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:
|
|
result = AddressesContactPointResolutionCapability().preview_source(
|
|
session,
|
|
principal,
|
|
request=_contact_point_source_request(principal, payload),
|
|
offset=offset,
|
|
limit=limit,
|
|
)
|
|
return ContactPointSourcePreviewResponse.model_validate(asdict(result))
|
|
except (AddressBookError, ValueError) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
@router.post(
|
|
"/contact-point-snapshots",
|
|
response_model=ContactPointSnapshotResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_freeze_contact_point_source(
|
|
payload: ContactPointSourceRequestPayload,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
_require_scope(principal, "addresses:governance:read")
|
|
try:
|
|
snapshot = AddressesContactPointResolutionCapability().freeze_source(
|
|
session,
|
|
principal,
|
|
request=_contact_point_source_request(principal, payload),
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="addresses.contact_point_snapshot_created",
|
|
object_type="address_contact_point_snapshot",
|
|
object_id=snapshot.id,
|
|
details={
|
|
"source_id": snapshot.request.source_id,
|
|
"source_revision": snapshot.source_revision,
|
|
"snapshot_hash": snapshot.snapshot_hash,
|
|
"recipient_count": snapshot.recipient_count,
|
|
"excluded_count": snapshot.excluded_count,
|
|
"purpose": snapshot.request.purpose,
|
|
},
|
|
)
|
|
session.commit()
|
|
return ContactPointSnapshotResponse.model_validate(asdict(snapshot))
|
|
except (AddressBookError, ValueError) as exc:
|
|
session.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
@router.get(
|
|
"/contact-point-snapshots/{snapshot_id}",
|
|
response_model=ContactPointSnapshotResponse,
|
|
)
|
|
def api_get_contact_point_snapshot(
|
|
snapshot_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
_require_scope(principal, "addresses:governance:read")
|
|
snapshot = AddressesContactPointResolutionCapability().get_snapshot(
|
|
session,
|
|
principal,
|
|
snapshot_id=snapshot_id,
|
|
)
|
|
if snapshot is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Contact-point snapshot not found.",
|
|
)
|
|
return ContactPointSnapshotResponse.model_validate(asdict(snapshot))
|
|
|
|
|
|
@router.get("/address-lists", response_model=AddressListListResponse)
|
|
def api_list_address_lists(
|
|
address_book_id: str | None = Query(default=None),
|
|
include_deleted: bool = Query(default=False),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_list:read")
|
|
try:
|
|
address_lists = list_address_lists(
|
|
session,
|
|
principal,
|
|
address_book_id=address_book_id,
|
|
include_deleted=include_deleted,
|
|
)
|
|
counts = address_list_entry_counts(session, [address_list.id for address_list in address_lists])
|
|
return AddressListListResponse(address_lists=[_address_list_response(address_list, entry_count=counts.get(address_list.id, 0)) for address_list in address_lists])
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/address-books/{book_id}/address-lists",
|
|
response_model=AddressListResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_address_list(
|
|
book_id: str,
|
|
payload: AddressListCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_list:write")
|
|
try:
|
|
address_list = create_address_list(session, principal, book_id, payload)
|
|
session.commit()
|
|
session.refresh(address_list)
|
|
return _address_list_response(address_list)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.patch("/address-lists/{address_list_id}", response_model=AddressListResponse)
|
|
def api_update_address_list(
|
|
address_list_id: str,
|
|
payload: AddressListUpdateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_list:write")
|
|
try:
|
|
address_list = update_address_list(session, principal, address_list_id, payload)
|
|
session.commit()
|
|
session.refresh(address_list)
|
|
counts = address_list_entry_counts(session, [address_list.id])
|
|
return _address_list_response(address_list, entry_count=counts.get(address_list.id, 0))
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.delete("/address-lists/{address_list_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def api_delete_address_list(
|
|
address_list_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_list:delete")
|
|
try:
|
|
delete_address_list(session, principal, address_list_id)
|
|
session.commit()
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post("/address-lists/{address_list_id}/restore", response_model=AddressListResponse)
|
|
def api_restore_address_list(
|
|
address_list_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_list:write")
|
|
try:
|
|
address_list = restore_address_list(session, principal, address_list_id)
|
|
session.commit()
|
|
session.refresh(address_list)
|
|
counts = address_list_entry_counts(session, [address_list.id])
|
|
return _address_list_response(address_list, entry_count=counts.get(address_list.id, 0))
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get(
|
|
"/address-lists/{address_list_id}/entries",
|
|
response_model=AddressListEntryListResponse,
|
|
)
|
|
def api_list_address_list_entries(
|
|
address_list_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_list:read")
|
|
try:
|
|
entries = list_address_list_entries(session, principal, address_list_id)
|
|
return AddressListEntryListResponse(entries=[_address_list_entry_response(entry) for entry in entries])
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/address-lists/{address_list_id}/entries",
|
|
response_model=AddressListEntryResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_address_list_entry(
|
|
address_list_id: str,
|
|
payload: AddressListEntryCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_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)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.delete("/address-list-entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def api_delete_address_list_entry(
|
|
entry_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_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:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get("/write-targets", response_model=AddressBookWriteTargetsResponse)
|
|
def api_list_write_targets(
|
|
operation: str = Query(default="create_contact"),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_book:read")
|
|
decisions = AddressesContactWriterCapability().list_write_targets(session, principal, operation=operation)
|
|
return AddressBookWriteTargetsResponse(targets=[_write_decision_response(decision) for decision in decisions])
|
|
|
|
|
|
@router.get(
|
|
"/address-books/{book_id}/write-decision",
|
|
response_model=AddressBookWriteDecisionResponse,
|
|
)
|
|
def api_get_address_book_write_decision(
|
|
book_id: str,
|
|
operation: str = Query(default="create_contact"),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:address_book:read")
|
|
decision = AddressesContactWriterCapability().can_write_to_address_book(session, principal, address_book_id=book_id, operation=operation)
|
|
return _write_decision_response(decision)
|
|
|
|
|
|
@router.post("/carddav/discover", response_model=AddressCardDavDiscoveryResponse)
|
|
def api_discover_carddav_address_books(
|
|
payload: AddressCardDavDiscoveryRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
address_books = discover_carddav_address_books(session, principal, payload)
|
|
return AddressCardDavDiscoveryResponse(
|
|
address_books=[
|
|
AddressCardDavAddressBookResponse(
|
|
collection_url=item.collection_url,
|
|
href=item.href,
|
|
display_name=item.display_name,
|
|
description=item.description,
|
|
ctag=item.ctag,
|
|
sync_token=item.sync_token,
|
|
)
|
|
for item in address_books
|
|
]
|
|
)
|
|
except (AddressBookError, AddressCardDAVError) as exc:
|
|
raise _error(AddressBookError(str(exc))) from exc
|
|
|
|
|
|
@router.post("/ldap/discover", response_model=AddressLdapDiscoveryResponse)
|
|
def api_discover_ldap_base_dns(
|
|
payload: AddressLdapConnectionRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
return AddressLdapDiscoveryResponse(base_dns=list(discover_ldap_base_dns(session, principal, payload)))
|
|
except (AddressBookError, AddressLdapError) as exc:
|
|
raise _error(AddressBookError(str(exc))) from exc
|
|
|
|
|
|
@router.post(
|
|
"/address-books/{book_id}/ldap/sources",
|
|
response_model=AddressSyncSourceResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_ldap_sync_source(
|
|
book_id: str,
|
|
payload: AddressLdapSourceCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
sync_source = create_ldap_sync_source(session, principal, book_id, payload)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.sync_source_created",
|
|
object_type="address_sync_source",
|
|
object_id=sync_source.id,
|
|
details={
|
|
"address_book_id": book_id,
|
|
"connector_type": "ldap",
|
|
"sync_direction": "read_only",
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(sync_source)
|
|
return _sync_source_response(sync_source)
|
|
except (AddressBookError, AddressLdapError) as exc:
|
|
session.rollback()
|
|
raise _error(AddressBookError(str(exc))) from exc
|
|
|
|
|
|
@router.get("/credentials", response_model=AddressCredentialEnvelopeListResponse)
|
|
def api_list_address_credentials(
|
|
source_id: str | None = Query(default=None),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
return AddressCredentialEnvelopeListResponse(
|
|
credentials=[
|
|
AddressCredentialEnvelopeResponse.model_validate(item)
|
|
for item in available_address_credentials(
|
|
session,
|
|
principal,
|
|
source_id=source_id,
|
|
)
|
|
]
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/address-books/{book_id}/carddav/sources",
|
|
response_model=AddressSyncSourceResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_carddav_sync_source(
|
|
book_id: str,
|
|
payload: AddressCardDavSourceCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
sync_source = create_carddav_sync_source(session, principal, book_id, payload)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.sync_source_created",
|
|
object_type="address_sync_source",
|
|
object_id=sync_source.id,
|
|
details={
|
|
"address_book_id": book_id,
|
|
"connector_type": "carddav",
|
|
"sync_direction": sync_source.sync_direction,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(sync_source)
|
|
return _sync_source_response(sync_source)
|
|
except (AddressBookError, AddressCardDAVError, AddressLdapError) as exc:
|
|
session.rollback()
|
|
raise _error(AddressBookError(str(exc))) from exc
|
|
|
|
|
|
@router.get("/sync-sources", response_model=AddressSyncSourceListResponse)
|
|
def api_list_sync_sources(
|
|
address_book_id: str | None = Query(default=None),
|
|
include_disabled: bool = Query(default=False),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:read")
|
|
try:
|
|
sync_sources = list_sync_sources(
|
|
session,
|
|
principal,
|
|
address_book_id=address_book_id,
|
|
include_disabled=include_disabled,
|
|
)
|
|
return AddressSyncSourceListResponse(sync_sources=[_sync_source_response(sync_source) for sync_source in sync_sources])
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post("/sync-sources/{sync_source_id}/preview", response_model=AddressSyncPlanResponse)
|
|
def api_preview_sync_source(
|
|
sync_source_id: str,
|
|
payload: AddressSyncRunRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:read")
|
|
try:
|
|
plan = preview_sync_source(
|
|
session,
|
|
principal,
|
|
sync_source_id,
|
|
force_full=payload.force_full,
|
|
password=payload.password.get_secret_value() if payload.password else None,
|
|
bearer_token=payload.bearer_token.get_secret_value() if payload.bearer_token else None,
|
|
)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.sync_previewed",
|
|
object_type="address_sync_source",
|
|
object_id=sync_source_id,
|
|
details=plan.stats.model_dump(),
|
|
)
|
|
session.commit()
|
|
return _sync_plan_response(plan)
|
|
except (AddressBookError, AddressCardDAVError, AddressLdapError) as exc:
|
|
session.rollback()
|
|
raise _error(AddressBookError(str(exc))) from exc
|
|
|
|
|
|
@router.post("/sync-sources/{sync_source_id}/run", response_model=AddressSyncPlanResponse)
|
|
def api_run_sync_source(
|
|
sync_source_id: str,
|
|
payload: AddressSyncRunRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
plan = run_sync_source(
|
|
session,
|
|
principal,
|
|
sync_source_id,
|
|
force_full=payload.force_full,
|
|
password=payload.password.get_secret_value() if payload.password else None,
|
|
bearer_token=payload.bearer_token.get_secret_value() if payload.bearer_token else None,
|
|
)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.sync_completed",
|
|
object_type="address_sync_source",
|
|
object_id=sync_source_id,
|
|
details=plan.stats.model_dump(),
|
|
)
|
|
session.commit()
|
|
return _sync_plan_response(plan)
|
|
except (AddressBookError, AddressCardDAVError, AddressLdapError) as exc:
|
|
message = str(exc)
|
|
session.rollback()
|
|
try:
|
|
finish_sync_attempt(
|
|
session,
|
|
principal,
|
|
sync_source_id,
|
|
AddressSyncAttemptFinishRequest(
|
|
status="failed",
|
|
error=message,
|
|
diagnostic={
|
|
"severity": "error",
|
|
"code": "connector_unavailable",
|
|
"message": message,
|
|
"retryable": True,
|
|
"stage": "read",
|
|
},
|
|
),
|
|
)
|
|
record_sync_diagnostic(
|
|
session,
|
|
principal,
|
|
sync_source_id,
|
|
AddressSyncDiagnosticCreateRequest(
|
|
severity="error",
|
|
code="connector_unavailable",
|
|
message=message,
|
|
details={"retryable": True, "stage": "read"},
|
|
),
|
|
)
|
|
session.commit()
|
|
except Exception:
|
|
session.rollback()
|
|
raise _error(AddressBookError(message)) from exc
|
|
|
|
|
|
@router.post(
|
|
"/address-books/{book_id}/sync-sources",
|
|
response_model=AddressSyncSourceResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_sync_source(
|
|
book_id: str,
|
|
payload: AddressSyncSourceCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
sync_source = create_sync_source(session, principal, book_id, payload)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.sync_source_created",
|
|
object_type="address_sync_source",
|
|
object_id=sync_source.id,
|
|
details={
|
|
"address_book_id": book_id,
|
|
"connector_type": sync_source.connector_type,
|
|
"sync_direction": sync_source.sync_direction,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(sync_source)
|
|
return _sync_source_response(sync_source)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.patch("/sync-sources/{sync_source_id}", response_model=AddressSyncSourceResponse)
|
|
def api_update_sync_source(
|
|
sync_source_id: str,
|
|
payload: AddressSyncSourceUpdateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
sync_source = update_sync_source(session, principal, sync_source_id, payload)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.sync_source_updated",
|
|
object_type="address_sync_source",
|
|
object_id=sync_source.id,
|
|
details={
|
|
"connector_type": sync_source.connector_type,
|
|
"sync_direction": sync_source.sync_direction,
|
|
"enabled": sync_source.enabled,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(sync_source)
|
|
return _sync_source_response(sync_source)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.delete("/sync-sources/{sync_source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def api_delete_sync_source(
|
|
sync_source_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
delete_sync_source(session, principal, sync_source_id)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.sync_source_deleted",
|
|
object_type="address_sync_source",
|
|
object_id=sync_source_id,
|
|
details={"sync_source_id": sync_source_id},
|
|
)
|
|
session.commit()
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/sync-sources/{sync_source_id}/attempts/start",
|
|
response_model=AddressSyncSourceResponse,
|
|
)
|
|
def api_start_sync_attempt(
|
|
sync_source_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
sync_source = start_sync_attempt(session, principal, sync_source_id)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.sync_started",
|
|
object_type="address_sync_source",
|
|
object_id=sync_source.id,
|
|
details={"connector_type": sync_source.connector_type},
|
|
)
|
|
session.commit()
|
|
session.refresh(sync_source)
|
|
return _sync_source_response(sync_source)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/sync-sources/{sync_source_id}/attempts/finish",
|
|
response_model=AddressSyncSourceResponse,
|
|
)
|
|
def api_finish_sync_attempt(
|
|
sync_source_id: str,
|
|
payload: AddressSyncAttemptFinishRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
sync_source = finish_sync_attempt(session, principal, sync_source_id, payload)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.sync_finished",
|
|
object_type="address_sync_source",
|
|
object_id=sync_source.id,
|
|
details={
|
|
"connector_type": sync_source.connector_type,
|
|
"status": sync_source.status,
|
|
"error": sync_source.last_error,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(sync_source)
|
|
return _sync_source_response(sync_source)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get(
|
|
"/sync-sources/{sync_source_id}/diagnostics",
|
|
response_model=AddressSyncDiagnosticListResponse,
|
|
)
|
|
def api_list_sync_diagnostics(
|
|
sync_source_id: str,
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:read")
|
|
try:
|
|
diagnostics = list_sync_diagnostics(session, principal, sync_source_id, limit=limit)
|
|
return AddressSyncDiagnosticListResponse(diagnostics=[_sync_diagnostic_response(diagnostic) for diagnostic in diagnostics])
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/sync-sources/{sync_source_id}/diagnostics",
|
|
response_model=AddressSyncDiagnosticResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_record_sync_diagnostic(
|
|
sync_source_id: str,
|
|
payload: AddressSyncDiagnosticCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
diagnostic = record_sync_diagnostic(session, principal, sync_source_id, payload)
|
|
session.commit()
|
|
session.refresh(diagnostic)
|
|
return _sync_diagnostic_response(diagnostic)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get(
|
|
"/sync-sources/{sync_source_id}/tombstones",
|
|
response_model=AddressSyncTombstoneListResponse,
|
|
)
|
|
def api_list_sync_tombstones(
|
|
sync_source_id: str,
|
|
limit: int = Query(default=200, ge=1, le=1000),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:read")
|
|
try:
|
|
tombstones = list_sync_tombstones(session, principal, sync_source_id, limit=limit)
|
|
return AddressSyncTombstoneListResponse(tombstones=[_sync_tombstone_response(tombstone) for tombstone in tombstones])
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/sync-sources/{sync_source_id}/tombstones",
|
|
response_model=AddressSyncTombstoneResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_record_sync_tombstone(
|
|
sync_source_id: str,
|
|
payload: AddressSyncTombstoneCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
tombstone = record_sync_tombstone(session, principal, sync_source_id, payload)
|
|
session.commit()
|
|
session.refresh(tombstone)
|
|
return _sync_tombstone_response(tombstone)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get(
|
|
"/sync-sources/{sync_source_id}/conflicts",
|
|
response_model=AddressSyncConflictListResponse,
|
|
)
|
|
def api_list_sync_conflicts(
|
|
sync_source_id: str,
|
|
status_filter: str | None = Query(default="open", alias="status"),
|
|
limit: int = Query(default=200, ge=1, le=1000),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:read")
|
|
try:
|
|
conflicts = list_sync_conflicts(session, principal, sync_source_id, status_filter=status_filter, limit=limit)
|
|
return AddressSyncConflictListResponse(conflicts=[_sync_conflict_response(conflict) for conflict in conflicts])
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/sync-sources/{sync_source_id}/conflicts",
|
|
response_model=AddressSyncConflictResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_record_sync_conflict(
|
|
sync_source_id: str,
|
|
payload: AddressSyncConflictCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
conflict = record_sync_conflict(session, principal, sync_source_id, payload)
|
|
session.commit()
|
|
session.refresh(conflict)
|
|
return _sync_conflict_response(conflict)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post("/sync-conflicts/{conflict_id}/resolve", response_model=AddressSyncConflictResponse)
|
|
def api_resolve_sync_conflict(
|
|
conflict_id: str,
|
|
payload: AddressSyncConflictResolveRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:sync:write")
|
|
try:
|
|
conflict = resolve_sync_conflict(session, principal, conflict_id, payload)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.sync_conflict_resolved",
|
|
object_type="address_sync_conflict",
|
|
object_id=conflict.id,
|
|
details={
|
|
"sync_source_id": conflict.sync_source_id,
|
|
"status": conflict.status,
|
|
"resolution": conflict.resolution,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(conflict)
|
|
return _sync_conflict_response(conflict)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/address-books/{book_id}/contacts",
|
|
response_model=ContactResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_contact(
|
|
book_id: str,
|
|
payload: ContactCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_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)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.patch("/contacts/{contact_id}", response_model=ContactResponse)
|
|
def api_update_contact(
|
|
contact_id: str,
|
|
payload: ContactUpdateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_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)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get(
|
|
"/contacts/{contact_id}/channel-rules",
|
|
response_model=ContactChannelRuleListResponse,
|
|
)
|
|
def api_list_contact_channel_rules(
|
|
contact_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:governance:read")
|
|
try:
|
|
return ContactChannelRuleListResponse(rules=[_channel_rule_response(rule) for rule in list_contact_channel_rules(session, principal, contact_id)])
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/contacts/{contact_id}/channel-rules",
|
|
response_model=ContactChannelRuleResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_contact_channel_rule(
|
|
contact_id: str,
|
|
payload: ContactChannelRuleCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:governance:write")
|
|
try:
|
|
rule = create_contact_channel_rule(session, principal, contact_id, payload)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.channel_rule_created",
|
|
object_type="address_contact_channel_rule",
|
|
object_id=rule.id,
|
|
details={
|
|
"contact_id": contact_id,
|
|
"channel": rule.channel,
|
|
"purpose": rule.purpose,
|
|
"contact_point_id": rule.contact_point_id,
|
|
"decision": rule.decision,
|
|
"effective_from": rule.effective_from.isoformat() if rule.effective_from else None,
|
|
"effective_until": rule.effective_until.isoformat() if rule.effective_until else None,
|
|
"evidence_ref": rule.evidence_ref,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(rule)
|
|
return _channel_rule_response(rule)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.delete(
|
|
"/contact-channel-rules/{rule_id}",
|
|
response_model=ContactChannelRuleResponse,
|
|
)
|
|
def api_end_contact_channel_rule(
|
|
rule_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:governance:write")
|
|
try:
|
|
rule = end_contact_channel_rule(session, principal, rule_id)
|
|
_audit_address_sync(
|
|
session,
|
|
principal,
|
|
action="addresses.channel_rule_ended",
|
|
object_type="address_contact_channel_rule",
|
|
object_id=rule.id,
|
|
details={
|
|
"contact_id": rule.contact_id,
|
|
"channel": rule.channel,
|
|
"purpose": rule.purpose,
|
|
"decision": rule.decision,
|
|
"effective_until": rule.effective_until.isoformat() if rule.effective_until else None,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(rule)
|
|
return _channel_rule_response(rule)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def api_delete_contact(
|
|
contact_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_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:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post("/contacts/{contact_id}/restore", response_model=ContactResponse)
|
|
def api_restore_contact(
|
|
contact_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_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)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/address-books/{book_id}/vcards/import",
|
|
response_model=VCardImportResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_import_address_book_vcards(
|
|
book_id: str,
|
|
payload: VCardImportRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_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)
|
|
return VCardImportResponse(
|
|
imported=len(result.contacts),
|
|
skipped=result.skipped,
|
|
contacts=[_contact_response(contact) for contact in result.contacts],
|
|
issues=[
|
|
VCardImportIssue(
|
|
index=issue.index,
|
|
message=issue.message,
|
|
severity=issue.severity,
|
|
field=issue.field,
|
|
line=issue.line,
|
|
)
|
|
for issue in result.issues
|
|
],
|
|
)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(AddressBookError(str(exc))) from exc
|
|
|
|
|
|
@router.post(
|
|
"/address-books/{book_id}/vcard-batches/preview",
|
|
response_model=VCardBatchRunResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_preview_vcard_batch(
|
|
book_id: str,
|
|
payload: VCardBatchPreviewRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:write")
|
|
try:
|
|
run = preview_vcard_batch(session, principal, book_id, payload)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="addresses.vcard_batch_previewed",
|
|
object_type="address_import_run",
|
|
object_id=run.id,
|
|
details={
|
|
"address_book_id": book_id,
|
|
"input_hash": run.input_hash,
|
|
"plan_hash": run.plan_hash,
|
|
"statistics": run.statistics,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(run)
|
|
return VCardBatchRunResponse.model_validate(vcard_batch_payload(run))
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get("/vcard-batches/{run_id}", response_model=VCardBatchRunResponse)
|
|
def api_get_vcard_batch(
|
|
run_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
try:
|
|
return VCardBatchRunResponse.model_validate(vcard_batch_payload(get_vcard_batch_run(session, principal, run_id)))
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post("/vcard-batches/{run_id}/apply", response_model=VCardBatchRunResponse)
|
|
def api_apply_vcard_batch(
|
|
run_id: str,
|
|
payload: VCardBatchCommitRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:write")
|
|
try:
|
|
run = apply_vcard_batch(session, principal, run_id, payload)
|
|
session.flush()
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="addresses.vcard_batch_applied",
|
|
object_type="address_import_run",
|
|
object_id=run.id,
|
|
details={
|
|
"address_book_id": run.address_book_id,
|
|
"input_hash": run.input_hash,
|
|
"plan_hash": run.plan_hash,
|
|
"statistics": run.statistics,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(run)
|
|
return VCardBatchRunResponse.model_validate(vcard_batch_payload(run))
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post("/vcard-batches/{run_id}/cancel", response_model=VCardBatchRunResponse)
|
|
def api_cancel_vcard_batch(
|
|
run_id: str,
|
|
payload: VCardBatchCancelRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:write")
|
|
try:
|
|
run = cancel_vcard_batch(session, principal, run_id, payload)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="addresses.vcard_batch_cancelled",
|
|
object_type="address_import_run",
|
|
object_id=run.id,
|
|
details={"address_book_id": run.address_book_id, "reason": payload.reason},
|
|
)
|
|
session.commit()
|
|
session.refresh(run)
|
|
return VCardBatchRunResponse.model_validate(vcard_batch_payload(run))
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get("/vcard-batches/{run_id}/diagnostics")
|
|
def api_export_vcard_batch_diagnostics(
|
|
run_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
try:
|
|
run = get_vcard_batch_run(session, principal, run_id)
|
|
content = json.dumps(
|
|
vcard_diagnostics_payload(run),
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
return Response(
|
|
content=content,
|
|
media_type="application/json",
|
|
headers={"Content-Disposition": f'attachment; filename="vcard-batch-{run.id}.json"'},
|
|
)
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get("/import-profiles", response_model=AddressImportProfileListResponse)
|
|
def api_list_address_import_profiles(
|
|
include_history: bool = Query(default=False),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
return AddressImportProfileListResponse(profiles=[AddressImportProfileResponse.model_validate(item) for item in list_import_profiles(session, principal, include_history=include_history)])
|
|
|
|
|
|
@router.post(
|
|
"/import-profiles",
|
|
response_model=AddressImportProfileResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_address_import_profile(
|
|
payload: AddressImportProfileCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:write")
|
|
try:
|
|
profile = create_import_profile(session, principal, payload)
|
|
session.flush()
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="addresses.import_profile_created",
|
|
object_type="address_import_profile",
|
|
object_id=profile.profile_key,
|
|
details={
|
|
"version": profile.version,
|
|
"source_format": profile.source_format,
|
|
"scope_type": profile.scope_type,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(profile)
|
|
return AddressImportProfileResponse.model_validate(profile)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.patch("/import-profiles/{profile_id}", response_model=AddressImportProfileResponse)
|
|
def api_update_address_import_profile(
|
|
profile_id: str,
|
|
payload: AddressImportProfileUpdateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:write")
|
|
try:
|
|
profile = update_import_profile(session, principal, profile_id, payload)
|
|
session.flush()
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="addresses.import_profile_versioned",
|
|
object_type="address_import_profile",
|
|
object_id=profile.profile_key,
|
|
details={
|
|
"version": profile.version,
|
|
"source_format": profile.source_format,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(profile)
|
|
return AddressImportProfileResponse.model_validate(profile)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.delete("/import-profiles/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def api_retire_address_import_profile(
|
|
profile_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:write")
|
|
try:
|
|
retire_import_profile(session, principal, profile_id)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="addresses.import_profile_retired",
|
|
object_type="address_import_profile",
|
|
object_id=profile_id,
|
|
)
|
|
session.commit()
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/address-books/{book_id}/imports/preview",
|
|
response_model=AddressImportRunResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_preview_address_import(
|
|
book_id: str,
|
|
payload: AddressImportPreviewRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:write")
|
|
try:
|
|
run = preview_address_import(session, principal, book_id, payload)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="addresses.import_previewed",
|
|
object_type="address_import_run",
|
|
object_id=run.id,
|
|
details={
|
|
"input_hash": run.input_hash,
|
|
"plan_hash": run.plan_hash,
|
|
"statistics": run.statistics,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(run)
|
|
return AddressImportRunResponse.model_validate(import_run_payload(run))
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get("/imports/{run_id}", response_model=AddressImportRunResponse)
|
|
def api_get_address_import_run(
|
|
run_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
try:
|
|
return AddressImportRunResponse.model_validate(import_run_payload(get_import_run(session, principal, run_id)))
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post("/imports/{run_id}/apply", response_model=AddressImportRunResponse)
|
|
def api_apply_address_import(
|
|
run_id: str,
|
|
payload: AddressImportCommitRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:write")
|
|
try:
|
|
run = apply_address_import(session, principal, run_id, expected_plan_hash=payload.expected_plan_hash)
|
|
session.flush()
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="addresses.import_applied",
|
|
object_type="address_import_run",
|
|
object_id=run.id,
|
|
details={
|
|
"input_hash": run.input_hash,
|
|
"plan_hash": run.plan_hash,
|
|
"statistics": run.statistics,
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(run)
|
|
return AddressImportRunResponse.model_validate(import_run_payload(run))
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post("/imports/{run_id}/rollback", response_model=AddressImportRunResponse)
|
|
def api_rollback_address_import(
|
|
run_id: str,
|
|
payload: AddressImportRollbackRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:write")
|
|
try:
|
|
run = rollback_address_import(session, principal, run_id, payload)
|
|
session.flush()
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="addresses.import_rolled_back",
|
|
object_type="address_import_run",
|
|
object_id=run.id,
|
|
details={"reason": payload.reason, "plan_hash": run.plan_hash},
|
|
)
|
|
session.commit()
|
|
session.refresh(run)
|
|
return AddressImportRunResponse.model_validate(import_run_payload(run))
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get("/address-books/{book_id}/vcards/export")
|
|
def api_export_address_book_vcards(
|
|
book_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
try:
|
|
book, content = export_address_book_vcard(session, principal, book_id)
|
|
filename = f"{_safe_filename(book.name)}.vcf"
|
|
return Response(
|
|
content=content,
|
|
media_type="text/vcard",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/address-books/{book_id}/vcards/export",
|
|
response_model=VCardExportResponse,
|
|
)
|
|
def api_export_selected_vcards(
|
|
book_id: str,
|
|
payload: VCardExportRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
try:
|
|
result = export_vcards(session, principal, book_id, payload)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="addresses.vcards_exported",
|
|
object_type="address_book",
|
|
object_id=book_id,
|
|
details={
|
|
"scope": result["scope"],
|
|
"version": result["version"],
|
|
"contact_count": result["contact_count"],
|
|
"content_hash": result["content_hash"],
|
|
},
|
|
)
|
|
session.commit()
|
|
return VCardExportResponse.model_validate(result)
|
|
except AddressBookError as exc:
|
|
session.rollback()
|
|
raise _error(exc) from exc
|
|
|
|
|
|
@router.get("/contacts/{contact_id}/vcard")
|
|
def api_export_contact_vcard(
|
|
contact_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "addresses:contact:read")
|
|
try:
|
|
contact, content = export_contact_vcard(session, principal, contact_id)
|
|
filename = f"{_safe_filename(contact.display_name)}.vcf"
|
|
return Response(
|
|
content=content,
|
|
media_type="text/vcard",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
except AddressBookError as exc:
|
|
raise _error(exc) from exc
|