feat: add selective vCard batch workflows

This commit is contained in:
2026-08-20 17:24:42 +02:00
parent 147f34c5c1
commit 42fe262376
14 changed files with 2116 additions and 158 deletions
+350 -77
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import asdict
import json
import re
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
@@ -56,6 +57,23 @@ from govoplan_addresses.backend.imports import (
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,
@@ -238,9 +256,7 @@ def _contact_response(
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
),
"quality_reason_code": (decision.reason_code if decision is not None else None),
}
return ContactResponse.model_validate(
@@ -323,9 +339,7 @@ def _contact_point_audit_details(
"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_counts": {channel: len(ids) for channel, ids in point_ids.items()},
f"{key_prefix}contact_point_ids": point_ids,
}
@@ -454,7 +468,9 @@ def _sync_source_response(sync_source: AddressSyncSource) -> AddressSyncSourceRe
)
def _sync_diagnostic_response(diagnostic: AddressSyncDiagnostic) -> AddressSyncDiagnosticResponse:
def _sync_diagnostic_response(
diagnostic: AddressSyncDiagnostic,
) -> AddressSyncDiagnosticResponse:
return AddressSyncDiagnosticResponse.model_validate(
{
"id": diagnostic.id,
@@ -470,7 +486,9 @@ def _sync_diagnostic_response(diagnostic: AddressSyncDiagnostic) -> AddressSyncD
)
def _sync_tombstone_response(tombstone: AddressSyncTombstone) -> AddressSyncTombstoneResponse:
def _sync_tombstone_response(
tombstone: AddressSyncTombstone,
) -> AddressSyncTombstoneResponse:
return AddressSyncTombstoneResponse.model_validate(
{
"id": tombstone.id,
@@ -490,7 +508,9 @@ def _sync_tombstone_response(tombstone: AddressSyncTombstone) -> AddressSyncTomb
)
def _sync_conflict_response(conflict: AddressSyncConflict) -> AddressSyncConflictResponse:
def _sync_conflict_response(
conflict: AddressSyncConflict,
) -> AddressSyncConflictResponse:
return AddressSyncConflictResponse.model_validate(
{
"id": conflict.id,
@@ -583,7 +603,11 @@ def api_list_address_books(
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)
@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),
@@ -591,7 +615,12 @@ def api_create_address_book(
):
_require_scope(principal, "addresses:address_book:write")
try:
book = create_address_book(session, principal, payload, allow_system=has_scope(principal, "addresses:address_book:admin"))
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)
@@ -738,10 +767,7 @@ def api_suggest_duplicate_contacts(
right=_contact_response(item.right),
score=item.score,
confidence=item.confidence,
features=[
ContactDuplicateFeatureResponse(**asdict(feature))
for feature in item.features
],
features=[ContactDuplicateFeatureResponse(**asdict(feature)) for feature in item.features],
)
for item in scan.suggestions
],
@@ -778,10 +804,7 @@ def api_address_quality_summary(
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
],
corrections=[AddressQualityCorrectionResponse(**asdict(item)) for item in summary.corrections],
truncated=summary.truncated,
)
except AddressBookError as exc:
@@ -895,9 +918,7 @@ def api_resolve_contact_redirect(
):
_require_scope(principal, "addresses:contact:read")
try:
return ContactRedirectResponse.model_validate(
asdict(resolve_contact_redirect(session, principal, contact_id))
)
return ContactRedirectResponse.model_validate(asdict(resolve_contact_redirect(session, principal, contact_id)))
except AddressBookError as exc:
raise _error(exc) from exc
@@ -1167,16 +1188,23 @@ def api_list_address_lists(
):
_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]
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)
@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,
@@ -1247,7 +1275,10 @@ def api_restore_address_list(
raise _error(exc) from exc
@router.get("/address-lists/{address_list_id}/entries", response_model=AddressListEntryListResponse)
@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),
@@ -1261,7 +1292,11 @@ def api_list_address_list_entries(
raise _error(exc) from exc
@router.post("/address-lists/{address_list_id}/entries", response_model=AddressListEntryResponse, status_code=status.HTTP_201_CREATED)
@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,
@@ -1332,7 +1367,10 @@ def api_list_write_targets(
return AddressBookWriteTargetsResponse(targets=[_write_decision_response(decision) for decision in decisions])
@router.get("/address-books/{book_id}/write-decision", response_model=AddressBookWriteDecisionResponse)
@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"),
@@ -1378,9 +1416,7 @@ def api_discover_ldap_base_dns(
):
_require_scope(principal, "addresses:sync:write")
try:
return AddressLdapDiscoveryResponse(
base_dns=list(discover_ldap_base_dns(session, principal, payload))
)
return AddressLdapDiscoveryResponse(base_dns=list(discover_ldap_base_dns(session, principal, payload)))
except (AddressBookError, AddressLdapError) as exc:
raise _error(AddressBookError(str(exc))) from exc
@@ -1438,7 +1474,11 @@ def api_list_address_credentials(
)
@router.post("/address-books/{book_id}/carddav/sources", response_model=AddressSyncSourceResponse, status_code=status.HTTP_201_CREATED)
@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,
@@ -1454,7 +1494,11 @@ def api_create_carddav_sync_source(
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},
details={
"address_book_id": book_id,
"connector_type": "carddav",
"sync_direction": sync_source.sync_direction,
},
)
session.commit()
session.refresh(sync_source)
@@ -1473,7 +1517,12 @@ def api_list_sync_sources(
):
_require_scope(principal, "addresses:sync:read")
try:
sync_sources = list_sync_sources(session, principal, address_book_id=address_book_id, include_disabled=include_disabled)
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
@@ -1575,7 +1624,11 @@ def api_run_sync_source(
raise _error(AddressBookError(message)) from exc
@router.post("/address-books/{book_id}/sync-sources", response_model=AddressSyncSourceResponse, status_code=status.HTTP_201_CREATED)
@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,
@@ -1591,7 +1644,11 @@ def api_create_sync_source(
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},
details={
"address_book_id": book_id,
"connector_type": sync_source.connector_type,
"sync_direction": sync_source.sync_direction,
},
)
session.commit()
session.refresh(sync_source)
@@ -1617,7 +1674,11 @@ def api_update_sync_source(
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},
details={
"connector_type": sync_source.connector_type,
"sync_direction": sync_source.sync_direction,
"enabled": sync_source.enabled,
},
)
session.commit()
session.refresh(sync_source)
@@ -1651,7 +1712,10 @@ def api_delete_sync_source(
raise _error(exc) from exc
@router.post("/sync-sources/{sync_source_id}/attempts/start", response_model=AddressSyncSourceResponse)
@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),
@@ -1676,7 +1740,10 @@ def api_start_sync_attempt(
raise _error(exc) from exc
@router.post("/sync-sources/{sync_source_id}/attempts/finish", response_model=AddressSyncSourceResponse)
@router.post(
"/sync-sources/{sync_source_id}/attempts/finish",
response_model=AddressSyncSourceResponse,
)
def api_finish_sync_attempt(
sync_source_id: str,
payload: AddressSyncAttemptFinishRequest,
@@ -1692,7 +1759,11 @@ def api_finish_sync_attempt(
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},
details={
"connector_type": sync_source.connector_type,
"status": sync_source.status,
"error": sync_source.last_error,
},
)
session.commit()
session.refresh(sync_source)
@@ -1702,7 +1773,10 @@ def api_finish_sync_attempt(
raise _error(exc) from exc
@router.get("/sync-sources/{sync_source_id}/diagnostics", response_model=AddressSyncDiagnosticListResponse)
@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),
@@ -1717,7 +1791,11 @@ def api_list_sync_diagnostics(
raise _error(exc) from exc
@router.post("/sync-sources/{sync_source_id}/diagnostics", response_model=AddressSyncDiagnosticResponse, status_code=status.HTTP_201_CREATED)
@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,
@@ -1735,7 +1813,10 @@ def api_record_sync_diagnostic(
raise _error(exc) from exc
@router.get("/sync-sources/{sync_source_id}/tombstones", response_model=AddressSyncTombstoneListResponse)
@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),
@@ -1750,7 +1831,11 @@ def api_list_sync_tombstones(
raise _error(exc) from exc
@router.post("/sync-sources/{sync_source_id}/tombstones", response_model=AddressSyncTombstoneResponse, status_code=status.HTTP_201_CREATED)
@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,
@@ -1768,7 +1853,10 @@ def api_record_sync_tombstone(
raise _error(exc) from exc
@router.get("/sync-sources/{sync_source_id}/conflicts", response_model=AddressSyncConflictListResponse)
@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"),
@@ -1784,7 +1872,11 @@ def api_list_sync_conflicts(
raise _error(exc) from exc
@router.post("/sync-sources/{sync_source_id}/conflicts", response_model=AddressSyncConflictResponse, status_code=status.HTTP_201_CREATED)
@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,
@@ -1818,7 +1910,11 @@ def api_resolve_sync_conflict(
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},
details={
"sync_source_id": conflict.sync_source_id,
"status": conflict.status,
"resolution": conflict.resolution,
},
)
session.commit()
session.refresh(conflict)
@@ -1828,7 +1924,11 @@ def api_resolve_sync_conflict(
raise _error(exc) from exc
@router.post("/address-books/{book_id}/contacts", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
@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,
@@ -1869,11 +1969,7 @@ def api_update_contact(
_require_scope(principal, "addresses:contact:write")
try:
previous_contact = session.get(Contact, contact_id)
previous_point_details = (
_contact_point_audit_details(previous_contact, prefix="previous")
if previous_contact is not None
else {}
)
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,
@@ -1897,7 +1993,10 @@ def api_update_contact(
raise _error(exc) from exc
@router.get("/contacts/{contact_id}/channel-rules", response_model=ContactChannelRuleListResponse)
@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),
@@ -1905,12 +2004,7 @@ def api_list_contact_channel_rules(
):
_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)
]
)
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
@@ -2044,7 +2138,11 @@ def api_restore_contact(
raise _error(exc) from exc
@router.post("/address-books/{book_id}/vcards/import", response_model=VCardImportResponse, status_code=status.HTTP_201_CREATED)
@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,
@@ -2091,6 +2189,136 @@ def api_import_address_book_vcards(
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),
@@ -2098,15 +2326,14 @@ def api_list_address_import_profiles(
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)
]
)
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)
@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),
@@ -2122,7 +2349,11 @@ def api_create_address_import_profile(
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},
details={
"version": profile.version,
"source_format": profile.source_format,
"scope_type": profile.scope_type,
},
)
session.commit()
session.refresh(profile)
@@ -2149,7 +2380,10 @@ def api_update_address_import_profile(
action="addresses.import_profile_versioned",
object_type="address_import_profile",
object_id=profile.profile_key,
details={"version": profile.version, "source_format": profile.source_format},
details={
"version": profile.version,
"source_format": profile.source_format,
},
)
session.commit()
session.refresh(profile)
@@ -2202,7 +2436,11 @@ def api_preview_address_import(
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},
details={
"input_hash": run.input_hash,
"plan_hash": run.plan_hash,
"statistics": run.statistics,
},
)
session.commit()
session.refresh(run)
@@ -2220,9 +2458,7 @@ def api_get_address_import_run(
):
_require_scope(principal, "addresses:contact:read")
try:
return AddressImportRunResponse.model_validate(
import_run_payload(get_import_run(session, principal, run_id))
)
return AddressImportRunResponse.model_validate(import_run_payload(get_import_run(session, principal, run_id)))
except AddressBookError as exc:
raise _error(exc) from exc
@@ -2244,7 +2480,11 @@ def api_apply_address_import(
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},
details={
"input_hash": run.input_hash,
"plan_hash": run.plan_hash,
"statistics": run.statistics,
},
)
session.commit()
session.refresh(run)
@@ -2300,6 +2540,39 @@ def api_export_address_book_vcards(
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,