from __future__ import annotations from dataclasses import asdict 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.db.session import get_session from govoplan_addresses.backend.carddav import AddressCardDAVError from govoplan_addresses.backend.db.models import ( AddressBook, AddressList, AddressListEntry, AddressSyncConflict, AddressSyncDiagnostic, AddressSyncSource, AddressSyncTombstone, Contact, ContactPostalAddress, ) from govoplan_addresses.backend.capabilities import 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, AddressSyncAttemptFinishRequest, AddressSyncConflictCreateRequest, AddressSyncConflictListResponse, AddressSyncConflictResolveRequest, AddressSyncConflictResponse, AddressSyncDiagnosticCreateRequest, AddressSyncDiagnosticListResponse, AddressSyncDiagnosticResponse, AddressSyncSourceCreateRequest, AddressSyncSourceListResponse, AddressSyncSourceResponse, AddressSyncSourceUpdateRequest, AddressSyncRunRequest, AddressSyncPlanItemResponse, AddressSyncPlanResponse, AddressSyncTombstoneCreateRequest, AddressSyncTombstoneListResponse, AddressSyncTombstoneResponse, ContactCreateRequest, ContactListResponse, ContactResponse, ContactUpdateRequest, VCardImportIssue, VCardImportRequest, VCardImportResponse, ) from govoplan_addresses.backend.service import ( AddressBookError, address_book_contact_counts, address_list_entry_counts, create_address_book, create_address_list, create_address_list_entry, create_carddav_sync_source, create_contact, create_sync_source, delete_address_book, delete_address_list, delete_address_list_entry, delete_contact, delete_sync_source, discover_carddav_address_books, export_address_book_vcard, export_contact_vcard, finish_sync_attempt, import_vcards, list_address_list_entries, list_address_lists, list_address_books, list_contacts, list_sync_conflicts, list_sync_diagnostics, list_sync_sources, list_sync_tombstones, record_sync_conflict, record_sync_diagnostic, record_sync_tombstone, restore_address_book, restore_address_list, restore_contact, resolve_sync_conflict, preview_sync_source, run_sync_source, start_sync_attempt, update_address_book, update_address_list, update_contact, 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) -> ContactResponse: return ContactResponse.model_validate(contact) 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 _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": sync_source.metadata_ or {}, "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), query: str | None = Query(default=None), limit: int = Query(default=200, ge=1, le=500), 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, query=query, limit=limit, include_deleted=include_deleted) return ContactListResponse(contacts=[_contact_response(contact) for contact in contacts]) 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-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.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: delete_address_list_entry(session, principal, entry_id) 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("/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) 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) 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) as exc: session.rollback() raise _error(AddressBookError(str(exc))) 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) 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: contact = update_contact(session, principal, contact_id, payload) session.commit() session.refresh(contact) return _contact_response(contact) 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) 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) 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) 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.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.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