1137 lines
42 KiB
Python
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
|