Files
govoplan-addresses/src/govoplan_addresses/backend/router.py
T

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