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

1137 lines
42 KiB
Python

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