2366 lines
91 KiB
Python
2366 lines
91 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass, field
|
|
import os
|
|
import re
|
|
import urllib.parse
|
|
from typing import Any
|
|
|
|
from sqlalchemy import and_, false, func, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.auth import ApiPrincipal
|
|
from govoplan_core.audit.logging import audit_event
|
|
from govoplan_core.core.change_sequence import record_change
|
|
from govoplan_core.db.base import utcnow
|
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
|
from govoplan_addresses.backend.carddav import (
|
|
AddressCardDAVAddressBook,
|
|
AddressCardDAVClient,
|
|
AddressCardDAVError,
|
|
AddressCardDAVObject,
|
|
AddressCardDAVPreconditionFailed,
|
|
AddressCardDAVReportResult,
|
|
AddressCardDAVSyncUnsupported,
|
|
ensure_collection_url,
|
|
)
|
|
from govoplan_addresses.backend.db.models import (
|
|
AddressBook,
|
|
AddressList,
|
|
AddressListEntry,
|
|
AddressSyncConflict,
|
|
AddressSyncDiagnostic,
|
|
AddressSyncSource,
|
|
AddressSyncTombstone,
|
|
Contact,
|
|
ContactEmail,
|
|
ContactPhone,
|
|
ContactPostalAddress,
|
|
)
|
|
from govoplan_addresses.backend.schemas import (
|
|
AddressBookCreateRequest,
|
|
AddressBookUpdateRequest,
|
|
AddressListCreateRequest,
|
|
AddressListEntryCreateRequest,
|
|
AddressListUpdateRequest,
|
|
AddressCardDavDiscoveryRequest,
|
|
AddressCardDavSourceCreateRequest,
|
|
AddressSyncPlanStats,
|
|
AddressSyncAttemptFinishRequest,
|
|
AddressSyncConflictCreateRequest,
|
|
AddressSyncConflictResolveRequest,
|
|
AddressSyncDiagnosticCreateRequest,
|
|
AddressSyncSourceCreateRequest,
|
|
AddressSyncSourceUpdateRequest,
|
|
AddressSyncTombstoneCreateRequest,
|
|
ContactCreateRequest,
|
|
ContactEmailPayload,
|
|
ContactPhonePayload,
|
|
ContactPostalAddressPayload,
|
|
ContactUpdateRequest,
|
|
)
|
|
from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues
|
|
|
|
|
|
class AddressBookError(ValueError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class VCardImportResult:
|
|
contacts: list[Contact]
|
|
issues: list[ParsedVCardIssue]
|
|
skipped: int
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AddressSyncPlanItem:
|
|
action: str
|
|
href: str | None = None
|
|
remote_uid: str | None = None
|
|
contact_id: str | None = None
|
|
display_name: str | None = None
|
|
etag: str | None = None
|
|
message: str | None = None
|
|
raw_vcard: str | None = None
|
|
parsed_payload: ContactCreateRequest | None = None
|
|
source_revision: str | None = None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AddressSyncPlan:
|
|
sync_source: AddressSyncSource
|
|
stats: AddressSyncPlanStats
|
|
items: list[AddressSyncPlanItem] = field(default_factory=list)
|
|
|
|
|
|
READ_ONLY_SYNC_DIRECTIONS = {"read_only", "import"}
|
|
OUTBOUND_SYNC_DIRECTIONS = {"export", "two_way"}
|
|
INBOUND_SYNC_DIRECTIONS = {"read_only", "import", "two_way"}
|
|
REMOTE_SYNC_ACTIONS = {"remote_create", "remote_update", "remote_delete"}
|
|
ADDRESS_MODULE_ID = "addresses"
|
|
ADDRESS_CONTACTS_COLLECTION = "addresses.contacts"
|
|
ADDRESS_CONTACT_RESOURCE = "address_contact"
|
|
CARDDAV_SECRET_ENV_PREFIX = "env:" # noqa: S105 # nosec B105 - reference prefix, not a credential.
|
|
|
|
|
|
def _trim(value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
trimmed = value.strip()
|
|
return trimmed or None
|
|
|
|
|
|
def _account_id(principal: ApiPrincipal) -> str:
|
|
return principal.account_id
|
|
|
|
|
|
def _tenant_id_or_none(principal: ApiPrincipal) -> str | None:
|
|
try:
|
|
return principal.tenant_id
|
|
except RuntimeError:
|
|
return None
|
|
|
|
|
|
def _visible_book_predicate(principal: ApiPrincipal):
|
|
tenant_id = _tenant_id_or_none(principal)
|
|
account_id = _account_id(principal)
|
|
predicates = [AddressBook.scope_type == "system"]
|
|
if tenant_id:
|
|
predicates.extend(
|
|
[
|
|
and_(AddressBook.tenant_id == tenant_id, AddressBook.scope_type == "tenant"),
|
|
and_(AddressBook.tenant_id == tenant_id, AddressBook.scope_type == "user", AddressBook.scope_id == account_id),
|
|
]
|
|
)
|
|
group_ids = tuple(principal.group_ids)
|
|
if group_ids:
|
|
predicates.append(and_(AddressBook.tenant_id == tenant_id, AddressBook.scope_type == "group", AddressBook.scope_id.in_(group_ids)))
|
|
return or_(*predicates)
|
|
|
|
|
|
def _visible_books_query(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False):
|
|
query = session.query(AddressBook).filter(_visible_book_predicate(principal))
|
|
if not include_deleted:
|
|
query = query.filter(AddressBook.deleted_at.is_(None))
|
|
return query
|
|
|
|
|
|
def _require_mutable_book(book: AddressBook) -> None:
|
|
if book.read_only:
|
|
raise AddressBookError("Address book is read-only.")
|
|
if book.deleted_at is not None:
|
|
raise AddressBookError("Address book is deleted.")
|
|
|
|
|
|
def _require_mutable_address_list(address_list: AddressList) -> None:
|
|
_require_mutable_book(address_list.address_book)
|
|
if address_list.read_only:
|
|
raise AddressBookError("Address list is read-only.")
|
|
if address_list.deleted_at is not None:
|
|
raise AddressBookError("Address list is deleted.")
|
|
|
|
|
|
def _scope_id_for_create(principal: ApiPrincipal, payload: AddressBookCreateRequest, *, allow_system: bool = False) -> tuple[str | None, str | None]:
|
|
tenant_id = _tenant_id_or_none(principal)
|
|
if payload.scope_type == "system":
|
|
if not allow_system:
|
|
raise AddressBookError("Creating system address books requires address book administration permission.")
|
|
return None, None
|
|
if not tenant_id:
|
|
raise AddressBookError("A tenant context is required for tenant, group, and user address books.")
|
|
if payload.scope_type == "tenant":
|
|
return tenant_id, tenant_id
|
|
if payload.scope_type == "user":
|
|
return tenant_id, _account_id(principal)
|
|
if payload.scope_type == "group":
|
|
group_id = _trim(payload.group_id)
|
|
if not group_id:
|
|
raise AddressBookError("Group address books require a group id.")
|
|
if group_id not in principal.group_ids and not principal.has("addresses:address_book:admin"):
|
|
raise AddressBookError("The selected group is not visible to the current principal.")
|
|
return tenant_id, group_id
|
|
raise AddressBookError("Unsupported address book scope.")
|
|
|
|
|
|
def list_address_books(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False) -> list[AddressBook]:
|
|
return _visible_books_query(session, principal, include_deleted=include_deleted).order_by(AddressBook.scope_type.asc(), AddressBook.name.asc(), AddressBook.id.asc()).all()
|
|
|
|
|
|
def address_book_contact_counts(session: Session, book_ids: Iterable[str], *, include_deleted: bool = False) -> dict[str, int]:
|
|
ids = tuple(book_ids)
|
|
if not ids:
|
|
return {}
|
|
query = (
|
|
session.query(Contact.address_book_id, func.count(Contact.id))
|
|
.filter(Contact.address_book_id.in_(ids))
|
|
.group_by(Contact.address_book_id)
|
|
)
|
|
if not include_deleted:
|
|
query = query.filter(Contact.deleted_at.is_(None))
|
|
rows = query.all()
|
|
return {book_id: count for book_id, count in rows}
|
|
|
|
|
|
def address_list_entry_counts(session: Session, address_list_ids: Iterable[str]) -> dict[str, int]:
|
|
ids = tuple(address_list_ids)
|
|
if not ids:
|
|
return {}
|
|
rows = (
|
|
session.query(AddressListEntry.address_list_id, func.count(AddressListEntry.id))
|
|
.join(Contact, AddressListEntry.contact_id == Contact.id)
|
|
.filter(AddressListEntry.address_list_id.in_(ids), Contact.deleted_at.is_(None))
|
|
.group_by(AddressListEntry.address_list_id)
|
|
.all()
|
|
)
|
|
return {address_list_id: count for address_list_id, count in rows}
|
|
|
|
|
|
def get_visible_address_book(session: Session, principal: ApiPrincipal, book_id: str, *, include_deleted: bool = False) -> AddressBook:
|
|
book = _visible_books_query(session, principal, include_deleted=include_deleted).filter(AddressBook.id == book_id).one_or_none()
|
|
if book is None:
|
|
raise AddressBookError("Address book not found.")
|
|
return book
|
|
|
|
|
|
def _visible_sync_source_query(session: Session, principal: ApiPrincipal):
|
|
book_ids = [book.id for book in list_address_books(session, principal)]
|
|
if not book_ids:
|
|
return session.query(AddressSyncSource).filter(false())
|
|
return session.query(AddressSyncSource).filter(AddressSyncSource.address_book_id.in_(book_ids))
|
|
|
|
|
|
def list_sync_sources(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
address_book_id: str | None = None,
|
|
include_disabled: bool = False,
|
|
) -> list[AddressSyncSource]:
|
|
query = _visible_sync_source_query(session, principal)
|
|
if address_book_id:
|
|
get_visible_address_book(session, principal, address_book_id)
|
|
query = query.filter(AddressSyncSource.address_book_id == address_book_id)
|
|
if not include_disabled:
|
|
query = query.filter(AddressSyncSource.enabled.is_(True))
|
|
return query.order_by(AddressSyncSource.display_name.asc(), AddressSyncSource.id.asc()).all()
|
|
|
|
|
|
def get_visible_sync_source(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource:
|
|
sync_source = _visible_sync_source_query(session, principal).filter(AddressSyncSource.id == sync_source_id).one_or_none()
|
|
if sync_source is None:
|
|
raise AddressBookError("Address sync source not found.")
|
|
return sync_source
|
|
|
|
|
|
def create_sync_source(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
address_book_id: str,
|
|
payload: AddressSyncSourceCreateRequest,
|
|
*,
|
|
trusted_connector_metadata: bool = False,
|
|
) -> AddressSyncSource:
|
|
book = get_visible_address_book(session, principal, address_book_id)
|
|
if book.deleted_at is not None:
|
|
raise AddressBookError("Address book is deleted.")
|
|
connector_type = _trim(payload.connector_type)
|
|
display_name = _trim(payload.display_name)
|
|
if not connector_type:
|
|
raise AddressBookError("Sync connector type is required.")
|
|
if not display_name:
|
|
raise AddressBookError("Sync source display name is required.")
|
|
if connector_type.casefold() == "carddav" and not trusted_connector_metadata:
|
|
_assert_api_carddav_metadata_safe(payload.metadata)
|
|
read_only = _read_only_from_sync_direction(payload.sync_direction, payload.read_only)
|
|
sync_source = AddressSyncSource(
|
|
tenant_id=book.tenant_id,
|
|
address_book_id=book.id,
|
|
connector_type=connector_type,
|
|
display_name=display_name,
|
|
external_account_ref=_trim(payload.external_account_ref),
|
|
external_address_book_ref=_trim(payload.external_address_book_ref),
|
|
sync_direction=payload.sync_direction,
|
|
read_only=read_only,
|
|
enabled=payload.enabled,
|
|
status="idle" if payload.enabled else "disabled",
|
|
sync_token=payload.sync_token,
|
|
etag=_trim(payload.etag),
|
|
remote_revision=_trim(payload.remote_revision),
|
|
created_by_account_id=_account_id(principal),
|
|
updated_by_account_id=_account_id(principal),
|
|
metadata_=payload.metadata or {},
|
|
)
|
|
session.add(sync_source)
|
|
session.flush()
|
|
_apply_sync_source_to_book(book, sync_source)
|
|
return sync_source
|
|
|
|
|
|
def update_sync_source(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source_id: str,
|
|
payload: AddressSyncSourceUpdateRequest,
|
|
) -> AddressSyncSource:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
if "display_name" in payload.model_fields_set:
|
|
display_name = _trim(payload.display_name)
|
|
if not display_name:
|
|
raise AddressBookError("Sync source display name is required.")
|
|
sync_source.display_name = display_name
|
|
if "external_account_ref" in payload.model_fields_set:
|
|
sync_source.external_account_ref = _trim(payload.external_account_ref)
|
|
if "external_address_book_ref" in payload.model_fields_set:
|
|
sync_source.external_address_book_ref = _trim(payload.external_address_book_ref)
|
|
if "sync_direction" in payload.model_fields_set and payload.sync_direction is not None:
|
|
sync_source.sync_direction = payload.sync_direction
|
|
sync_source.read_only = _read_only_from_sync_direction(payload.sync_direction, payload.read_only)
|
|
elif "read_only" in payload.model_fields_set and payload.read_only is not None:
|
|
sync_source.read_only = _read_only_from_sync_direction(sync_source.sync_direction, payload.read_only)
|
|
if "enabled" in payload.model_fields_set and payload.enabled is not None:
|
|
sync_source.enabled = payload.enabled
|
|
if not payload.enabled:
|
|
sync_source.status = "disabled"
|
|
elif sync_source.status == "disabled":
|
|
sync_source.status = "idle"
|
|
if "sync_token" in payload.model_fields_set:
|
|
sync_source.sync_token = payload.sync_token
|
|
if "etag" in payload.model_fields_set:
|
|
sync_source.etag = _trim(payload.etag)
|
|
if "remote_revision" in payload.model_fields_set:
|
|
sync_source.remote_revision = _trim(payload.remote_revision)
|
|
if "metadata" in payload.model_fields_set:
|
|
metadata = payload.metadata or {}
|
|
if sync_source.connector_type.casefold() == "carddav":
|
|
_assert_api_carddav_metadata_safe(metadata)
|
|
metadata = _merge_server_owned_carddav_metadata(sync_source.metadata_, metadata)
|
|
sync_source.metadata_ = metadata
|
|
sync_source.updated_by_account_id = _account_id(principal)
|
|
_apply_sync_source_to_book(sync_source.address_book, sync_source)
|
|
return sync_source
|
|
|
|
|
|
def delete_sync_source(session: Session, principal: ApiPrincipal, sync_source_id: str) -> None:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
book = sync_source.address_book
|
|
if book.source_ref == sync_source.id:
|
|
book.source_kind = "local"
|
|
book.source_ref = None
|
|
book.read_only = False
|
|
book.sync_status = None
|
|
book.sync_error = None
|
|
book.updated_by_account_id = _account_id(principal)
|
|
_audit_sync_credential_deletion(session, principal, sync_source)
|
|
session.delete(sync_source)
|
|
|
|
|
|
def _audit_sync_credential_deletion(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source: AddressSyncSource,
|
|
) -> None:
|
|
"""Audit removal of source-owned credential material in the DB transaction.
|
|
|
|
CardDAV credentials are encrypted inside the sync-source row. Deleting that
|
|
row therefore deletes the credential atomically with the connector. Legacy
|
|
credential references are detached from the source, but are never resolved
|
|
or sent to an external provider because ownership cannot be proven.
|
|
"""
|
|
|
|
tenant_id = _tenant_id_or_none(principal)
|
|
user = getattr(principal, "user", None)
|
|
_record_sync_credential_deletion_audit(
|
|
session,
|
|
sync_source=sync_source,
|
|
tenant_id=tenant_id,
|
|
user_id=getattr(user, "id", None),
|
|
api_key_id=getattr(principal, "api_key_id", None),
|
|
deletion_reason="sync_source_deleted",
|
|
)
|
|
|
|
|
|
def _record_sync_credential_deletion_audit(
|
|
session: Session,
|
|
*,
|
|
sync_source: AddressSyncSource,
|
|
tenant_id: str | None,
|
|
user_id: str | None,
|
|
api_key_id: str | None,
|
|
deletion_reason: str,
|
|
) -> bool:
|
|
auth = _carddav_auth_metadata(sync_source.metadata_)
|
|
if auth.get("secret_encrypted"):
|
|
storage_backend = "encrypted_database"
|
|
elif auth.get("credential_ref"):
|
|
storage_backend = "legacy_reference"
|
|
else:
|
|
return False
|
|
audit_event(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
api_key_id=api_key_id,
|
|
action="addresses.sync_credential_deleted",
|
|
scope="tenant" if tenant_id is not None else "system",
|
|
object_type="address_sync_credential",
|
|
object_id=sync_source.id,
|
|
details={
|
|
"sync_source_id": sync_source.id,
|
|
"connector_type": sync_source.connector_type,
|
|
"storage_backend": storage_backend,
|
|
"deletion_reason": deletion_reason,
|
|
},
|
|
)
|
|
return True
|
|
|
|
|
|
def audit_address_credentials_for_retirement(session: Session) -> int:
|
|
"""Audit credentials that the ensuing destructive table drop deletes."""
|
|
|
|
sources = session.query(AddressSyncSource).order_by(AddressSyncSource.id.asc()).all()
|
|
audited = 0
|
|
for source in sources:
|
|
if _record_sync_credential_deletion_audit(
|
|
session,
|
|
sync_source=source,
|
|
tenant_id=source.tenant_id,
|
|
user_id=None,
|
|
api_key_id=None,
|
|
deletion_reason="module_data_retired",
|
|
):
|
|
audited += 1
|
|
session.flush()
|
|
return audited
|
|
|
|
|
|
def start_sync_attempt(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
if not sync_source.enabled:
|
|
raise AddressBookError("Address sync source is disabled.")
|
|
sync_source.status = "running"
|
|
sync_source.last_attempted_at = utcnow()
|
|
sync_source.last_error = None
|
|
sync_source.updated_by_account_id = _account_id(principal)
|
|
sync_source.address_book.sync_status = "running"
|
|
sync_source.address_book.sync_error = None
|
|
sync_source.address_book.updated_by_account_id = _account_id(principal)
|
|
return sync_source
|
|
|
|
|
|
def finish_sync_attempt(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source_id: str,
|
|
payload: AddressSyncAttemptFinishRequest,
|
|
) -> AddressSyncSource:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
sync_source.status = payload.status
|
|
sync_source.last_attempted_at = sync_source.last_attempted_at or utcnow()
|
|
sync_source.last_error = _trim(payload.error)
|
|
sync_source.last_diagnostic = payload.diagnostic or None
|
|
if payload.status == "succeeded":
|
|
sync_source.last_success_at = utcnow()
|
|
sync_source.last_error = None
|
|
if "sync_token" in payload.model_fields_set:
|
|
sync_source.sync_token = payload.sync_token
|
|
if "etag" in payload.model_fields_set:
|
|
sync_source.etag = _trim(payload.etag)
|
|
if "remote_revision" in payload.model_fields_set:
|
|
sync_source.remote_revision = _trim(payload.remote_revision)
|
|
sync_source.updated_by_account_id = _account_id(principal)
|
|
sync_source.address_book.sync_status = payload.status
|
|
sync_source.address_book.sync_error = sync_source.last_error
|
|
sync_source.address_book.updated_by_account_id = _account_id(principal)
|
|
return sync_source
|
|
|
|
|
|
def list_sync_diagnostics(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source_id: str,
|
|
*,
|
|
limit: int = 100,
|
|
) -> list[AddressSyncDiagnostic]:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
return (
|
|
session.query(AddressSyncDiagnostic)
|
|
.filter(AddressSyncDiagnostic.sync_source_id == sync_source.id)
|
|
.order_by(AddressSyncDiagnostic.created_at.desc(), AddressSyncDiagnostic.id.desc())
|
|
.limit(max(1, min(limit, 500)))
|
|
.all()
|
|
)
|
|
|
|
|
|
def record_sync_diagnostic(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source_id: str,
|
|
payload: AddressSyncDiagnosticCreateRequest,
|
|
) -> AddressSyncDiagnostic:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
diagnostic = AddressSyncDiagnostic(
|
|
tenant_id=sync_source.tenant_id,
|
|
sync_source_id=sync_source.id,
|
|
severity=payload.severity,
|
|
code=_trim(payload.code) or "sync",
|
|
message=_trim(payload.message) or "Sync diagnostic",
|
|
details=payload.details or {},
|
|
)
|
|
sync_source.last_diagnostic = {
|
|
"severity": diagnostic.severity,
|
|
"code": diagnostic.code,
|
|
"message": diagnostic.message,
|
|
}
|
|
if diagnostic.severity == "error":
|
|
sync_source.last_error = diagnostic.message
|
|
sync_source.status = "failed"
|
|
sync_source.address_book.sync_status = "failed"
|
|
sync_source.address_book.sync_error = diagnostic.message
|
|
sync_source.updated_by_account_id = _account_id(principal)
|
|
session.add(diagnostic)
|
|
return diagnostic
|
|
|
|
|
|
def list_sync_tombstones(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source_id: str,
|
|
*,
|
|
limit: int = 200,
|
|
) -> list[AddressSyncTombstone]:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
return (
|
|
session.query(AddressSyncTombstone)
|
|
.filter(AddressSyncTombstone.sync_source_id == sync_source.id)
|
|
.order_by(AddressSyncTombstone.created_at.desc(), AddressSyncTombstone.id.desc())
|
|
.limit(max(1, min(limit, 1000)))
|
|
.all()
|
|
)
|
|
|
|
|
|
def record_sync_tombstone(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source_id: str,
|
|
payload: AddressSyncTombstoneCreateRequest,
|
|
) -> AddressSyncTombstone:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
contact = _sync_contact_or_none(session, principal, sync_source, payload.contact_id)
|
|
if not _trim(payload.remote_uid) and not _trim(payload.resource_href) and contact is None:
|
|
raise AddressBookError("Sync tombstone requires a contact id, remote uid, or resource href.")
|
|
tombstone = AddressSyncTombstone(
|
|
tenant_id=sync_source.tenant_id,
|
|
sync_source_id=sync_source.id,
|
|
address_book_id=sync_source.address_book_id,
|
|
contact_id=contact.id if contact is not None else None,
|
|
remote_uid=_trim(payload.remote_uid),
|
|
resource_href=_trim(payload.resource_href),
|
|
local_deleted_at=payload.local_deleted_at,
|
|
remote_deleted_at=payload.remote_deleted_at,
|
|
synced_at=payload.synced_at,
|
|
metadata_=payload.metadata or {},
|
|
)
|
|
session.add(tombstone)
|
|
return tombstone
|
|
|
|
|
|
def list_sync_conflicts(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source_id: str,
|
|
*,
|
|
status_filter: str | None = "open",
|
|
limit: int = 200,
|
|
) -> list[AddressSyncConflict]:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
query = session.query(AddressSyncConflict).filter(AddressSyncConflict.sync_source_id == sync_source.id)
|
|
if status_filter:
|
|
query = query.filter(AddressSyncConflict.status == status_filter)
|
|
return query.order_by(AddressSyncConflict.created_at.desc(), AddressSyncConflict.id.desc()).limit(max(1, min(limit, 1000))).all()
|
|
|
|
|
|
def record_sync_conflict(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source_id: str,
|
|
payload: AddressSyncConflictCreateRequest,
|
|
) -> AddressSyncConflict:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
contact = _sync_contact_or_none(session, principal, sync_source, payload.contact_id)
|
|
field_path = _trim(payload.field_path)
|
|
if not field_path:
|
|
raise AddressBookError("Sync conflict field path is required.")
|
|
conflict = AddressSyncConflict(
|
|
tenant_id=sync_source.tenant_id,
|
|
sync_source_id=sync_source.id,
|
|
address_book_id=sync_source.address_book_id,
|
|
contact_id=contact.id if contact is not None else None,
|
|
remote_uid=_trim(payload.remote_uid),
|
|
resource_href=_trim(payload.resource_href),
|
|
field_path=field_path,
|
|
local_value=payload.local_value,
|
|
remote_value=payload.remote_value,
|
|
local_updated_at=payload.local_updated_at,
|
|
remote_updated_at=payload.remote_updated_at,
|
|
status="open",
|
|
metadata_=payload.metadata or {},
|
|
)
|
|
sync_source.status = "conflict"
|
|
sync_source.address_book.sync_status = "conflict"
|
|
sync_source.updated_by_account_id = _account_id(principal)
|
|
session.add(conflict)
|
|
return conflict
|
|
|
|
|
|
def resolve_sync_conflict(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
conflict_id: str,
|
|
payload: AddressSyncConflictResolveRequest,
|
|
) -> AddressSyncConflict:
|
|
conflict = get_visible_sync_conflict(session, principal, conflict_id)
|
|
if payload.status == "resolved" and payload.resolution == "use_remote":
|
|
_apply_remote_sync_conflict(session, principal, conflict)
|
|
elif payload.status == "resolved" and payload.resolution == "merge":
|
|
if payload.merged_payload is None:
|
|
raise AddressBookError("Merge resolution requires a merged contact payload.")
|
|
_apply_payload_sync_conflict(session, principal, conflict, payload.merged_payload)
|
|
conflict.status = payload.status
|
|
conflict.resolution = payload.resolution
|
|
conflict.resolved_at = utcnow()
|
|
conflict.resolved_by_account_id = _account_id(principal)
|
|
if not list_sync_conflicts(session, principal, conflict.sync_source_id, status_filter="open", limit=1):
|
|
conflict.sync_source.status = "idle"
|
|
conflict.address_book.sync_status = "idle"
|
|
return conflict
|
|
|
|
|
|
def _apply_remote_sync_conflict(session: Session, principal: ApiPrincipal, conflict: AddressSyncConflict) -> None:
|
|
metadata = dict(conflict.metadata_ or {})
|
|
remote_value = dict(conflict.remote_value or {})
|
|
raw_payload = metadata.get("remote_payload") or remote_value.get("payload")
|
|
if not isinstance(raw_payload, dict):
|
|
raise AddressBookError("This conflict does not include a stored remote payload that can be applied automatically.")
|
|
payload = ContactCreateRequest.model_validate(raw_payload)
|
|
_apply_payload_sync_conflict(session, principal, conflict, payload)
|
|
|
|
|
|
def _apply_payload_sync_conflict(session: Session, principal: ApiPrincipal, conflict: AddressSyncConflict, payload: ContactCreateRequest) -> None:
|
|
metadata = dict(conflict.metadata_ or {})
|
|
remote_value = dict(conflict.remote_value or {})
|
|
item = AddressSyncPlanItem(
|
|
action="update",
|
|
href=conflict.resource_href,
|
|
remote_uid=conflict.remote_uid,
|
|
contact_id=conflict.contact_id,
|
|
display_name=payload.display_name,
|
|
etag=str(remote_value.get("etag") or "") or None,
|
|
raw_vcard=metadata.get("raw_vcard") if isinstance(metadata.get("raw_vcard"), str) else None,
|
|
parsed_payload=payload,
|
|
source_revision=str(metadata.get("source_revision") or remote_value.get("etag") or "") or None,
|
|
)
|
|
_upsert_carddav_contact(session, principal, conflict.sync_source, item)
|
|
|
|
|
|
def get_visible_sync_conflict(session: Session, principal: ApiPrincipal, conflict_id: str) -> AddressSyncConflict:
|
|
sync_source_ids = [source.id for source in list_sync_sources(session, principal, include_disabled=True)]
|
|
if not sync_source_ids:
|
|
raise AddressBookError("Address sync conflict not found.")
|
|
conflict = session.query(AddressSyncConflict).filter(AddressSyncConflict.id == conflict_id, AddressSyncConflict.sync_source_id.in_(sync_source_ids)).one_or_none()
|
|
if conflict is None:
|
|
raise AddressBookError("Address sync conflict not found.")
|
|
return conflict
|
|
|
|
|
|
def discover_carddav_address_books(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
payload: AddressCardDavDiscoveryRequest,
|
|
) -> list[AddressCardDAVAddressBook]:
|
|
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
|
|
source = get_visible_sync_source(session, principal, payload.source_id) if payload.source_id else None
|
|
client = _carddav_client_from_payload(session, principal, payload, source=source)
|
|
return client.discover_addressbooks()
|
|
|
|
|
|
def create_carddav_sync_source(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
address_book_id: str,
|
|
payload: AddressCardDavSourceCreateRequest,
|
|
) -> AddressSyncSource:
|
|
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
|
|
collection_url = ensure_collection_url(payload.collection_url)
|
|
display_name = _trim(payload.display_name) or "CardDAV address book"
|
|
metadata = _carddav_metadata(
|
|
auth_type=payload.auth_type,
|
|
username=payload.username,
|
|
password=_secret_value(payload.password),
|
|
bearer_token=_secret_value(payload.bearer_token),
|
|
credential_ref=None,
|
|
collection_url=collection_url,
|
|
)
|
|
return create_sync_source(
|
|
session,
|
|
principal,
|
|
address_book_id,
|
|
AddressSyncSourceCreateRequest(
|
|
connector_type="carddav",
|
|
display_name=display_name,
|
|
external_address_book_ref=collection_url,
|
|
sync_direction=payload.sync_direction,
|
|
read_only=payload.read_only,
|
|
sync_token=payload.sync_token,
|
|
etag=payload.etag,
|
|
remote_revision=payload.remote_revision,
|
|
metadata=metadata,
|
|
),
|
|
trusted_connector_metadata=True,
|
|
)
|
|
|
|
|
|
def preview_sync_source(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source_id: str,
|
|
*,
|
|
force_full: bool = False,
|
|
password: str | None = None,
|
|
bearer_token: str | None = None,
|
|
client: AddressCardDAVClient | None = None,
|
|
) -> AddressSyncPlan:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
if sync_source.connector_type != "carddav":
|
|
raise AddressBookError(f"Preview is not implemented for {sync_source.connector_type} sync sources.")
|
|
return _build_carddav_sync_plan(
|
|
session,
|
|
principal,
|
|
sync_source,
|
|
force_full=force_full,
|
|
password=password,
|
|
bearer_token=bearer_token,
|
|
client=client,
|
|
)
|
|
|
|
|
|
def run_sync_source(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source_id: str,
|
|
*,
|
|
force_full: bool = False,
|
|
password: str | None = None,
|
|
bearer_token: str | None = None,
|
|
client: AddressCardDAVClient | None = None,
|
|
) -> AddressSyncPlan:
|
|
sync_source = start_sync_attempt(session, principal, sync_source_id)
|
|
write_client = client
|
|
if sync_source.connector_type == "carddav" and write_client is None:
|
|
write_client = _carddav_client_for_source(session, sync_source, password=password, bearer_token=bearer_token)
|
|
try:
|
|
plan = _build_carddav_sync_plan(
|
|
session,
|
|
principal,
|
|
sync_source,
|
|
force_full=force_full,
|
|
password=password,
|
|
bearer_token=bearer_token,
|
|
client=write_client,
|
|
)
|
|
_apply_address_sync_plan(session, principal, plan, client=write_client)
|
|
status = "conflict" if plan.stats.conflicts else "succeeded"
|
|
if plan.stats.errors:
|
|
status = "failed"
|
|
finish_sync_attempt(
|
|
session,
|
|
principal,
|
|
sync_source.id,
|
|
AddressSyncAttemptFinishRequest(
|
|
status=status,
|
|
sync_token=plan.stats.sync_token,
|
|
etag=plan.stats.etag,
|
|
remote_revision=plan.stats.remote_revision,
|
|
error=f"{plan.stats.errors} sync item errors" if plan.stats.errors else None,
|
|
diagnostic=_sync_plan_diagnostic(plan),
|
|
),
|
|
)
|
|
return plan
|
|
except Exception as exc:
|
|
finish_sync_attempt(
|
|
session,
|
|
principal,
|
|
sync_source.id,
|
|
AddressSyncAttemptFinishRequest(status="failed", error=str(exc), diagnostic={"error": str(exc)}),
|
|
)
|
|
raise
|
|
|
|
|
|
def _build_carddav_sync_plan(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source: AddressSyncSource,
|
|
*,
|
|
force_full: bool,
|
|
password: str | None,
|
|
bearer_token: str | None,
|
|
client: AddressCardDAVClient | None,
|
|
) -> AddressSyncPlan:
|
|
client = client or _carddav_client_for_source(session, sync_source, password=password, bearer_token=bearer_token)
|
|
stats = AddressSyncPlanStats()
|
|
plan = AddressSyncPlan(sync_source=sync_source, stats=stats)
|
|
collection_props = AddressCardDAVReportResult()
|
|
try:
|
|
try:
|
|
collection_props = client.propfind_collection()
|
|
except AddressCardDAVError as exc:
|
|
plan.items.append(AddressSyncPlanItem(action="unchanged", message=f"Collection PROPFIND failed; continuing with REPORT: {exc}"))
|
|
|
|
if sync_source.sync_token and not force_full:
|
|
try:
|
|
report = client.sync_collection(sync_source.sync_token)
|
|
stats.used_sync_token = True
|
|
except AddressCardDAVSyncUnsupported as exc:
|
|
plan.items.append(AddressSyncPlanItem(action="unchanged", message=f"Sync token REPORT failed; falling back to full sync: {exc}"))
|
|
report = client.list_objects()
|
|
stats.full_sync = True
|
|
else:
|
|
report = client.list_objects()
|
|
stats.full_sync = True
|
|
|
|
stats.sync_token = report.sync_token or collection_props.sync_token or sync_source.sync_token
|
|
stats.etag = report.ctag or collection_props.ctag or sync_source.etag
|
|
stats.remote_revision = report.ctag or collection_props.ctag or sync_source.remote_revision
|
|
_plan_carddav_report(session, sync_source=sync_source, client=client, report=report, plan=plan)
|
|
except AddressCardDAVError as exc:
|
|
plan.items.append(AddressSyncPlanItem(action="error", message=str(exc)))
|
|
stats.errors += 1
|
|
return plan
|
|
|
|
|
|
def _plan_carddav_report(
|
|
session: Session,
|
|
*,
|
|
sync_source: AddressSyncSource,
|
|
client: AddressCardDAVClient,
|
|
report: AddressCardDAVReportResult,
|
|
plan: AddressSyncPlan,
|
|
) -> None:
|
|
local_by_href = _carddav_contacts_by_href(session, sync_source)
|
|
seen_hrefs: set[str] = set()
|
|
reads_remote = _sync_source_reads_remote(sync_source)
|
|
for item in report.objects:
|
|
href = item.href
|
|
seen_hrefs.add(href)
|
|
_plan_carddav_report_object(
|
|
sync_source=sync_source,
|
|
client=client,
|
|
item=item,
|
|
local=local_by_href.get(href),
|
|
reads_remote=reads_remote,
|
|
plan=plan,
|
|
)
|
|
|
|
if plan.stats.full_sync:
|
|
_plan_carddav_full_sync_absences(
|
|
sync_source=sync_source,
|
|
local_by_href=local_by_href,
|
|
seen_hrefs=seen_hrefs,
|
|
reads_remote=reads_remote,
|
|
plan=plan,
|
|
)
|
|
|
|
_plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan)
|
|
|
|
|
|
def _plan_carddav_report_object(
|
|
*,
|
|
sync_source: AddressSyncSource,
|
|
client: AddressCardDAVClient,
|
|
item: AddressCardDAVObject,
|
|
local: Contact | None,
|
|
reads_remote: bool,
|
|
plan: AddressSyncPlan,
|
|
) -> None:
|
|
if item.deleted:
|
|
_plan_carddav_remote_deletion(
|
|
sync_source=sync_source,
|
|
item=item,
|
|
local=local,
|
|
reads_remote=reads_remote,
|
|
plan=plan,
|
|
)
|
|
return
|
|
loaded = _load_carddav_report_vcard(client=client, item=item, plan=plan)
|
|
if loaded is None:
|
|
return
|
|
raw_vcard, parsed = loaded
|
|
_plan_carddav_live_object(
|
|
sync_source=sync_source,
|
|
item=item,
|
|
local=local,
|
|
reads_remote=reads_remote,
|
|
raw_vcard=raw_vcard,
|
|
parsed=parsed,
|
|
plan=plan,
|
|
)
|
|
|
|
|
|
def _plan_carddav_remote_deletion(
|
|
*,
|
|
sync_source: AddressSyncSource,
|
|
item: AddressCardDAVObject,
|
|
local: Contact | None,
|
|
reads_remote: bool,
|
|
plan: AddressSyncPlan,
|
|
) -> None:
|
|
if local is None or local.deleted_at is not None:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="unchanged",
|
|
href=item.href,
|
|
etag=item.etag,
|
|
message="Remote delete already reflected locally.",
|
|
),
|
|
)
|
|
return
|
|
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="conflict",
|
|
href=item.href,
|
|
contact_id=local.id,
|
|
display_name=local.display_name,
|
|
etag=item.etag,
|
|
message="Remote object was deleted while the local contact changed since the last successful sync.",
|
|
),
|
|
)
|
|
elif reads_remote:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="delete",
|
|
href=item.href,
|
|
contact_id=local.id,
|
|
display_name=local.display_name,
|
|
etag=item.etag,
|
|
),
|
|
)
|
|
|
|
|
|
def _load_carddav_report_vcard(
|
|
*,
|
|
client: AddressCardDAVClient,
|
|
item: AddressCardDAVObject,
|
|
plan: AddressSyncPlan,
|
|
) -> tuple[str, ParsedVCard] | None:
|
|
raw_vcard = item.address_data
|
|
if not raw_vcard:
|
|
try:
|
|
raw_vcard = client.fetch_object(item.href)
|
|
except AddressCardDAVError as exc:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(action="error", href=item.href, etag=item.etag, message=str(exc)),
|
|
)
|
|
return None
|
|
parsed, error_message = _parse_single_remote_vcard(raw_vcard)
|
|
if parsed is None:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="error",
|
|
href=item.href,
|
|
etag=item.etag,
|
|
message=error_message or "Remote vCard could not be parsed.",
|
|
),
|
|
)
|
|
return None
|
|
return raw_vcard, parsed
|
|
|
|
|
|
def _plan_carddav_live_object(
|
|
*,
|
|
sync_source: AddressSyncSource,
|
|
item: AddressCardDAVObject,
|
|
local: Contact | None,
|
|
reads_remote: bool,
|
|
raw_vcard: str,
|
|
parsed: ParsedVCard,
|
|
plan: AddressSyncPlan,
|
|
) -> None:
|
|
if local is None:
|
|
_plan_new_carddav_remote_object(
|
|
item=item,
|
|
reads_remote=reads_remote,
|
|
raw_vcard=raw_vcard,
|
|
parsed=parsed,
|
|
plan=plan,
|
|
)
|
|
return
|
|
remote_revision = item.etag or parsed.source_revision
|
|
if _carddav_local_delete_needs_push(local, sync_source):
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="remote_delete",
|
|
href=item.href,
|
|
remote_uid=parsed.source_ref,
|
|
contact_id=local.id,
|
|
display_name=local.display_name,
|
|
etag=item.etag or local.source_revision,
|
|
message="Local delete will be pushed to CardDAV.",
|
|
),
|
|
)
|
|
return
|
|
if local.deleted_at is None and local.source_revision == remote_revision:
|
|
_plan_matching_carddav_revision(
|
|
sync_source=sync_source,
|
|
item=item,
|
|
local=local,
|
|
parsed=parsed,
|
|
plan=plan,
|
|
)
|
|
return
|
|
if _local_contact_changed_after_last_sync(local, sync_source):
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="conflict",
|
|
href=item.href,
|
|
remote_uid=parsed.source_ref,
|
|
contact_id=local.id,
|
|
display_name=local.display_name,
|
|
etag=item.etag,
|
|
raw_vcard=raw_vcard,
|
|
parsed_payload=parsed.payload,
|
|
source_revision=item.etag or parsed.source_revision,
|
|
message="Local contact changed since the last successful sync.",
|
|
),
|
|
)
|
|
return
|
|
_plan_carddav_remote_update(
|
|
item=item,
|
|
local=local,
|
|
reads_remote=reads_remote,
|
|
raw_vcard=raw_vcard,
|
|
parsed=parsed,
|
|
remote_revision=remote_revision,
|
|
plan=plan,
|
|
)
|
|
|
|
|
|
def _plan_new_carddav_remote_object(
|
|
*,
|
|
item: AddressCardDAVObject,
|
|
reads_remote: bool,
|
|
raw_vcard: str,
|
|
parsed: ParsedVCard,
|
|
plan: AddressSyncPlan,
|
|
) -> None:
|
|
if reads_remote:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="create",
|
|
href=item.href,
|
|
remote_uid=parsed.source_ref,
|
|
display_name=parsed.payload.display_name,
|
|
etag=item.etag,
|
|
raw_vcard=raw_vcard,
|
|
parsed_payload=parsed.payload,
|
|
source_revision=item.etag or parsed.source_revision,
|
|
),
|
|
)
|
|
else:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="unchanged",
|
|
href=item.href,
|
|
etag=item.etag,
|
|
message="Remote object ignored by export-only sync source.",
|
|
),
|
|
)
|
|
|
|
|
|
def _carddav_local_delete_needs_push(local: Contact, sync_source: AddressSyncSource) -> bool:
|
|
return bool(
|
|
local.deleted_at is not None
|
|
and _sync_source_writes_remote(sync_source)
|
|
and _local_contact_changed_after_last_sync(local, sync_source)
|
|
)
|
|
|
|
|
|
def _plan_matching_carddav_revision(
|
|
*,
|
|
sync_source: AddressSyncSource,
|
|
item: AddressCardDAVObject,
|
|
local: Contact,
|
|
parsed: ParsedVCard,
|
|
plan: AddressSyncPlan,
|
|
) -> None:
|
|
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source):
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="remote_update",
|
|
href=item.href,
|
|
remote_uid=parsed.source_ref,
|
|
contact_id=local.id,
|
|
display_name=local.display_name,
|
|
etag=item.etag or local.source_revision,
|
|
raw_vcard=_carddav_contact_vcard(local, href=item.href),
|
|
source_revision=item.etag or local.source_revision,
|
|
message="Local update will be pushed to CardDAV.",
|
|
),
|
|
)
|
|
return
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="unchanged",
|
|
href=item.href,
|
|
remote_uid=parsed.source_ref,
|
|
contact_id=local.id,
|
|
display_name=local.display_name,
|
|
etag=item.etag,
|
|
),
|
|
)
|
|
|
|
|
|
def _plan_carddav_remote_update(
|
|
*,
|
|
item: AddressCardDAVObject,
|
|
local: Contact,
|
|
reads_remote: bool,
|
|
raw_vcard: str,
|
|
parsed: ParsedVCard,
|
|
remote_revision: str | None,
|
|
plan: AddressSyncPlan,
|
|
) -> None:
|
|
if reads_remote:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="update",
|
|
href=item.href,
|
|
remote_uid=parsed.source_ref,
|
|
contact_id=local.id,
|
|
display_name=parsed.payload.display_name or local.display_name,
|
|
etag=item.etag,
|
|
raw_vcard=raw_vcard,
|
|
parsed_payload=parsed.payload,
|
|
source_revision=remote_revision,
|
|
),
|
|
)
|
|
else:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="unchanged",
|
|
href=item.href,
|
|
contact_id=local.id,
|
|
display_name=local.display_name,
|
|
etag=item.etag,
|
|
message="Remote update ignored by export-only sync source.",
|
|
),
|
|
)
|
|
|
|
|
|
def _plan_carddav_full_sync_absences(
|
|
*,
|
|
sync_source: AddressSyncSource,
|
|
local_by_href: dict[str, Contact],
|
|
seen_hrefs: set[str],
|
|
reads_remote: bool,
|
|
plan: AddressSyncPlan,
|
|
) -> None:
|
|
for href, contact in local_by_href.items():
|
|
if href in seen_hrefs or contact.deleted_at is not None:
|
|
continue
|
|
if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(contact, sync_source):
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="conflict",
|
|
href=href,
|
|
contact_id=contact.id,
|
|
display_name=contact.display_name,
|
|
etag=contact.source_revision,
|
|
message="Remote object is absent from full sync, but the local contact changed since the last successful sync.",
|
|
),
|
|
)
|
|
elif reads_remote:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="delete",
|
|
href=href,
|
|
contact_id=contact.id,
|
|
display_name=contact.display_name,
|
|
message="Remote object is absent from full sync.",
|
|
),
|
|
)
|
|
|
|
|
|
def _plan_carddav_outbound_local_changes(
|
|
session: Session,
|
|
*,
|
|
sync_source: AddressSyncSource,
|
|
plan: AddressSyncPlan,
|
|
) -> None:
|
|
if not _sync_source_writes_remote(sync_source):
|
|
return
|
|
reserved_contact_ids = {item.contact_id for item in plan.items if item.contact_id and item.action != "unchanged"}
|
|
for contact in _carddav_contacts_for_source(session, sync_source):
|
|
if contact.id in reserved_contact_ids:
|
|
continue
|
|
if contact.source_ref:
|
|
if not _local_contact_changed_after_last_sync(contact, sync_source):
|
|
continue
|
|
if contact.deleted_at is not None:
|
|
if contact.source_revision:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="remote_delete",
|
|
href=contact.source_ref,
|
|
contact_id=contact.id,
|
|
display_name=contact.display_name,
|
|
etag=contact.source_revision,
|
|
message="Local delete will be pushed to CardDAV.",
|
|
),
|
|
)
|
|
else:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="conflict",
|
|
href=contact.source_ref,
|
|
contact_id=contact.id,
|
|
display_name=contact.display_name,
|
|
message="Local delete cannot be pushed because no remote ETag is known.",
|
|
),
|
|
)
|
|
continue
|
|
if contact.source_revision:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="remote_update",
|
|
href=contact.source_ref,
|
|
contact_id=contact.id,
|
|
display_name=contact.display_name,
|
|
etag=contact.source_revision,
|
|
raw_vcard=_carddav_contact_vcard(contact, href=contact.source_ref),
|
|
source_revision=contact.source_revision,
|
|
message="Local update will be pushed to CardDAV.",
|
|
),
|
|
)
|
|
else:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="conflict",
|
|
href=contact.source_ref,
|
|
contact_id=contact.id,
|
|
display_name=contact.display_name,
|
|
message="Local update cannot be pushed because no remote ETag is known.",
|
|
),
|
|
)
|
|
continue
|
|
|
|
if contact.deleted_at is None:
|
|
href = _carddav_new_contact_href(sync_source, contact)
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="remote_create",
|
|
href=href,
|
|
contact_id=contact.id,
|
|
display_name=contact.display_name,
|
|
raw_vcard=_carddav_contact_vcard(contact, href=href),
|
|
message="Local contact will be created in CardDAV.",
|
|
),
|
|
)
|
|
|
|
|
|
def _add_sync_plan_item(plan: AddressSyncPlan, item: AddressSyncPlanItem) -> None:
|
|
plan.items.append(item)
|
|
if item.action in {"create", "remote_create"}:
|
|
plan.stats.created += 1
|
|
plan.stats.fetched += 1
|
|
elif item.action in {"update", "remote_update"}:
|
|
plan.stats.updated += 1
|
|
plan.stats.fetched += 1
|
|
elif item.action in {"delete", "remote_delete"}:
|
|
plan.stats.deleted += 1
|
|
elif item.action == "conflict":
|
|
plan.stats.conflicts += 1
|
|
plan.stats.fetched += 1
|
|
elif item.action == "unchanged":
|
|
plan.stats.unchanged += 1
|
|
elif item.action == "error":
|
|
plan.stats.errors += 1
|
|
|
|
|
|
def _apply_address_sync_plan(session: Session, principal: ApiPrincipal, plan: AddressSyncPlan, *, client: AddressCardDAVClient | None = None) -> None:
|
|
for item in plan.items:
|
|
if item.action == "create":
|
|
contact = _upsert_carddav_contact(session, principal, plan.sync_source, item)
|
|
item.contact_id = contact.id
|
|
elif item.action == "update":
|
|
contact = _upsert_carddav_contact(session, principal, plan.sync_source, item)
|
|
item.contact_id = contact.id
|
|
elif item.action == "delete" and item.contact_id:
|
|
contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True)
|
|
previous = _contact_change_payload(contact, prefix="previous_")
|
|
contact.deleted_at = utcnow()
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
record_sync_tombstone(
|
|
session,
|
|
principal,
|
|
plan.sync_source.id,
|
|
AddressSyncTombstoneCreateRequest(
|
|
contact_id=contact.id,
|
|
remote_uid=item.remote_uid,
|
|
resource_href=item.href,
|
|
local_deleted_at=contact.deleted_at,
|
|
remote_deleted_at=utcnow(),
|
|
synced_at=utcnow(),
|
|
),
|
|
)
|
|
_record_address_contact_change(session, principal, contact=contact, operation="deleted", previous=previous)
|
|
elif item.action in REMOTE_SYNC_ACTIONS:
|
|
if client is None:
|
|
raise AddressBookError("CardDAV write client is required for outbound sync.")
|
|
try:
|
|
_apply_carddav_remote_sync_item(session, principal, plan.sync_source, item, client=client)
|
|
except AddressCardDAVPreconditionFailed as exc:
|
|
_record_carddav_write_conflict(session, principal, plan, item, message=str(exc))
|
|
elif item.action == "conflict":
|
|
local_value = {"contact_id": item.contact_id, "display_name": item.display_name}
|
|
if item.contact_id:
|
|
contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True)
|
|
local_value["payload"] = _contact_payload_for_conflict(contact)
|
|
remote_payload = item.parsed_payload.model_dump(mode="json") if item.parsed_payload else None
|
|
record_sync_conflict(
|
|
session,
|
|
principal,
|
|
plan.sync_source.id,
|
|
AddressSyncConflictCreateRequest(
|
|
contact_id=item.contact_id,
|
|
remote_uid=item.remote_uid,
|
|
resource_href=item.href,
|
|
field_path="vcard",
|
|
local_value=local_value,
|
|
remote_value={"payload": remote_payload, "display_name": item.parsed_payload.display_name if item.parsed_payload else None, "etag": item.etag},
|
|
metadata={"message": item.message, "raw_vcard": item.raw_vcard, "remote_payload": remote_payload, "source_revision": item.source_revision},
|
|
),
|
|
)
|
|
session.flush()
|
|
|
|
|
|
def _apply_carddav_remote_sync_item(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source: AddressSyncSource,
|
|
item: AddressSyncPlanItem,
|
|
*,
|
|
client: AddressCardDAVClient,
|
|
) -> None:
|
|
if not item.contact_id:
|
|
raise AddressBookError("Outbound sync item is missing a contact id.")
|
|
contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True)
|
|
if contact.address_book_id != sync_source.address_book_id:
|
|
raise AddressBookError("Outbound sync contact must belong to the sync source address book.")
|
|
if item.action == "remote_create":
|
|
href = item.href or _carddav_new_contact_href(sync_source, contact)
|
|
raw_vcard = item.raw_vcard or _carddav_contact_vcard(contact, href=href)
|
|
previous = _contact_change_payload(contact, prefix="previous_")
|
|
result = client.put_object(href, raw_vcard, create=True)
|
|
_mark_carddav_contact_synced(contact, sync_source=sync_source, href=href, raw_vcard=raw_vcard, etag=result.etag or item.source_revision or contact.source_revision)
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
_record_address_contact_change(session, principal, contact=contact, operation="synced", previous=previous)
|
|
elif item.action == "remote_update":
|
|
if not item.href:
|
|
raise AddressBookError("Outbound update is missing a CardDAV href.")
|
|
etag = item.etag or contact.source_revision
|
|
raw_vcard = item.raw_vcard or _carddav_contact_vcard(contact, href=item.href)
|
|
previous = _contact_change_payload(contact, prefix="previous_")
|
|
result = client.put_object(item.href, raw_vcard, etag=etag)
|
|
_mark_carddav_contact_synced(contact, sync_source=sync_source, href=item.href, raw_vcard=raw_vcard, etag=result.etag or etag)
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
_record_address_contact_change(session, principal, contact=contact, operation="synced", previous=previous)
|
|
elif item.action == "remote_delete":
|
|
if not item.href:
|
|
raise AddressBookError("Outbound delete is missing a CardDAV href.")
|
|
etag = item.etag or contact.source_revision
|
|
previous = _contact_change_payload(contact, prefix="previous_")
|
|
client.delete_object(item.href, etag=etag)
|
|
record_sync_tombstone(
|
|
session,
|
|
principal,
|
|
sync_source.id,
|
|
AddressSyncTombstoneCreateRequest(
|
|
contact_id=contact.id,
|
|
remote_uid=item.remote_uid,
|
|
resource_href=item.href,
|
|
local_deleted_at=contact.deleted_at,
|
|
remote_deleted_at=utcnow(),
|
|
synced_at=utcnow(),
|
|
),
|
|
)
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
_record_address_contact_change(session, principal, contact=contact, operation="synced", previous=previous)
|
|
|
|
|
|
def _mark_carddav_contact_synced(
|
|
contact: Contact,
|
|
*,
|
|
sync_source: AddressSyncSource,
|
|
href: str,
|
|
raw_vcard: str,
|
|
etag: str | None,
|
|
) -> None:
|
|
contact.source_kind = sync_source.connector_type
|
|
contact.source_ref = href
|
|
contact.source_payload_kind = "vcard"
|
|
contact.source_payload_raw = raw_vcard
|
|
contact.source_revision = etag
|
|
provenance = dict(contact.provenance or {})
|
|
provenance["carddav"] = {
|
|
"sync_source_id": sync_source.id,
|
|
"href": href,
|
|
"etag": etag,
|
|
"last_push_at": utcnow().isoformat(),
|
|
}
|
|
contact.provenance = provenance
|
|
|
|
|
|
def _record_carddav_write_conflict(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
plan: AddressSyncPlan,
|
|
item: AddressSyncPlanItem,
|
|
*,
|
|
message: str,
|
|
) -> None:
|
|
original_action = item.action
|
|
_decrement_sync_plan_stats(plan, original_action)
|
|
item.action = "conflict"
|
|
item.message = message
|
|
plan.stats.conflicts += 1
|
|
local_value: dict[str, Any] = {"contact_id": item.contact_id, "display_name": item.display_name, "action": original_action}
|
|
if item.contact_id:
|
|
contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True)
|
|
local_value["payload"] = _contact_payload_for_conflict(contact)
|
|
record_sync_conflict(
|
|
session,
|
|
principal,
|
|
plan.sync_source.id,
|
|
AddressSyncConflictCreateRequest(
|
|
contact_id=item.contact_id,
|
|
remote_uid=item.remote_uid,
|
|
resource_href=item.href,
|
|
field_path="vcard",
|
|
local_value=local_value,
|
|
remote_value={"etag": item.etag, "message": message},
|
|
metadata={"message": message, "source": "carddav_write"},
|
|
),
|
|
)
|
|
|
|
|
|
def _decrement_sync_plan_stats(plan: AddressSyncPlan, action: str) -> None:
|
|
if action == "remote_create":
|
|
plan.stats.created = max(0, plan.stats.created - 1)
|
|
plan.stats.fetched = max(0, plan.stats.fetched - 1)
|
|
elif action == "remote_update":
|
|
plan.stats.updated = max(0, plan.stats.updated - 1)
|
|
plan.stats.fetched = max(0, plan.stats.fetched - 1)
|
|
elif action == "remote_delete":
|
|
plan.stats.deleted = max(0, plan.stats.deleted - 1)
|
|
|
|
|
|
def _upsert_carddav_contact(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source: AddressSyncSource,
|
|
item: AddressSyncPlanItem,
|
|
) -> Contact:
|
|
if item.parsed_payload is None:
|
|
raise AddressBookError("Remote vCard payload is missing.")
|
|
contact = None
|
|
if item.contact_id:
|
|
contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True)
|
|
if contact is None:
|
|
contact = Contact(
|
|
tenant_id=sync_source.tenant_id,
|
|
address_book_id=sync_source.address_book_id,
|
|
display_name=_display_name_from_payload(item.parsed_payload),
|
|
created_by_account_id=_account_id(principal),
|
|
)
|
|
session.add(contact)
|
|
operation = "created"
|
|
previous = None
|
|
else:
|
|
previous = _contact_change_payload(contact, prefix="previous_")
|
|
operation = "created" if contact.deleted_at is not None else "updated"
|
|
contact.display_name = _display_name_from_payload(item.parsed_payload, fallback=contact.display_name)
|
|
contact.given_name = _trim(item.parsed_payload.given_name)
|
|
contact.family_name = _trim(item.parsed_payload.family_name)
|
|
contact.organization = _trim(item.parsed_payload.organization)
|
|
contact.role_title = _trim(item.parsed_payload.role_title)
|
|
contact.note = _trim(item.parsed_payload.note)
|
|
contact.tags = _normalize_tags(item.parsed_payload.tags)
|
|
contact.source_kind = sync_source.connector_type
|
|
contact.source_ref = item.href
|
|
contact.source_payload_kind = "vcard"
|
|
contact.source_payload_raw = item.raw_vcard
|
|
contact.source_revision = item.source_revision
|
|
contact.provenance = {
|
|
**(item.parsed_payload.provenance or {}),
|
|
"carddav": {
|
|
"sync_source_id": sync_source.id,
|
|
"href": item.href,
|
|
"remote_uid": item.remote_uid,
|
|
"etag": item.etag,
|
|
},
|
|
}
|
|
contact.deleted_at = None
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
_replace_emails(contact, item.parsed_payload.emails)
|
|
_replace_phones(contact, item.parsed_payload.phones)
|
|
_replace_postal_addresses(contact, item.parsed_payload.postal_addresses)
|
|
session.flush()
|
|
_record_address_contact_change(session, principal, contact=contact, operation=operation, previous=previous)
|
|
return contact
|
|
|
|
|
|
def _parse_single_remote_vcard(raw_vcard: str):
|
|
result = parse_vcards_with_issues(raw_vcard)
|
|
if not result.cards:
|
|
message = result.issues[0].message if result.issues else "No vCard entries found."
|
|
return None, message
|
|
return result.cards[0], "; ".join(issue.message for issue in result.issues if issue.severity == "error") or None
|
|
|
|
|
|
def _carddav_contacts_by_href(session: Session, sync_source: AddressSyncSource) -> dict[str, Contact]:
|
|
return {str(contact.source_ref): contact for contact in _carddav_contacts_for_source(session, sync_source) if contact.source_ref}
|
|
|
|
|
|
def _carddav_contacts_for_source(session: Session, sync_source: AddressSyncSource) -> list[Contact]:
|
|
rows = (
|
|
session.query(Contact)
|
|
.filter(
|
|
Contact.address_book_id == sync_source.address_book_id,
|
|
)
|
|
.all()
|
|
)
|
|
return rows
|
|
|
|
|
|
def _sync_source_writes_remote(sync_source: AddressSyncSource) -> bool:
|
|
return sync_source.sync_direction in OUTBOUND_SYNC_DIRECTIONS and not sync_source.read_only
|
|
|
|
|
|
def _sync_source_reads_remote(sync_source: AddressSyncSource) -> bool:
|
|
return sync_source.sync_direction in INBOUND_SYNC_DIRECTIONS
|
|
|
|
|
|
def _carddav_new_contact_href(sync_source: AddressSyncSource, contact: Contact) -> str:
|
|
url = _trim(sync_source.external_address_book_ref)
|
|
if not url:
|
|
return f"{contact.id}.vcf"
|
|
try:
|
|
collection = ensure_collection_url(url)
|
|
except AddressCardDAVError:
|
|
return f"{contact.id}.vcf"
|
|
path = urllib.parse.urlparse(collection).path.rstrip("/")
|
|
if not path:
|
|
return f"{contact.id}.vcf"
|
|
return f"{path}/{contact.id}.vcf"
|
|
|
|
|
|
def _carddav_contact_vcard(contact: Contact, *, href: str | None = None) -> str:
|
|
content = contacts_to_vcard([contact])
|
|
if href and "UID:" not in content:
|
|
content = content.replace("VERSION:4.0\r\n", f"VERSION:4.0\r\nUID:{href}\r\n", 1)
|
|
return content
|
|
|
|
|
|
def _local_contact_changed_after_last_sync(contact: Contact, sync_source: AddressSyncSource) -> bool:
|
|
if sync_source.last_success_at is None:
|
|
return False
|
|
if contact.deleted_at and contact.deleted_at > sync_source.last_success_at:
|
|
return True
|
|
return bool(contact.updated_at and contact.updated_at > sync_source.last_success_at)
|
|
|
|
|
|
def _carddav_client_from_payload(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
payload: AddressCardDavDiscoveryRequest,
|
|
*,
|
|
source: AddressSyncSource | None = None,
|
|
) -> AddressCardDAVClient:
|
|
_assert_no_caller_carddav_credential_ref(payload.credential_ref)
|
|
url = payload.url or (source.external_address_book_ref if source else "")
|
|
metadata = dict(source.metadata_ or {}) if source else {}
|
|
auth = dict(metadata.get("carddav") or {})
|
|
auth_type = payload.auth_type or str(auth.get("auth_type") or "none")
|
|
username = payload.username if payload.username is not None else auth.get("username")
|
|
password = _secret_value(payload.password)
|
|
bearer_token = _secret_value(payload.bearer_token)
|
|
credential_ref = payload.credential_ref if payload.credential_ref is not None else auth.get("credential_ref")
|
|
secret = _resolve_carddav_secret(auth_type=auth_type, password=password, bearer_token=bearer_token, credential_ref=credential_ref, encrypted=auth.get("secret_encrypted"))
|
|
return _new_carddav_client(url, auth_type=auth_type, username=username, secret=secret)
|
|
|
|
|
|
def _carddav_client_for_source(
|
|
session: Session,
|
|
sync_source: AddressSyncSource,
|
|
*,
|
|
password: str | None = None,
|
|
bearer_token: str | None = None,
|
|
) -> AddressCardDAVClient:
|
|
del session
|
|
metadata = dict(sync_source.metadata_ or {})
|
|
auth = dict(metadata.get("carddav") or {})
|
|
auth_type = str(auth.get("auth_type") or "none")
|
|
username = auth.get("username")
|
|
secret = _resolve_carddav_secret(
|
|
auth_type=auth_type,
|
|
password=password,
|
|
bearer_token=bearer_token,
|
|
credential_ref=auth.get("credential_ref"),
|
|
encrypted=auth.get("secret_encrypted"),
|
|
)
|
|
return _new_carddav_client(sync_source.external_address_book_ref or "", auth_type=auth_type, username=username, secret=secret)
|
|
|
|
|
|
def _new_carddav_client(url: str, *, auth_type: str, username: str | None, secret: str | None) -> AddressCardDAVClient:
|
|
if auth_type == "basic":
|
|
if not username:
|
|
raise AddressBookError("CardDAV basic auth requires a username.")
|
|
if not secret:
|
|
raise AddressBookError("CardDAV basic auth requires a password or credential reference.")
|
|
return AddressCardDAVClient(collection_url=url, username=username, password=secret)
|
|
if auth_type == "bearer":
|
|
if not secret:
|
|
raise AddressBookError("CardDAV bearer auth requires a token or credential reference.")
|
|
return AddressCardDAVClient(collection_url=url, bearer_token=secret)
|
|
return AddressCardDAVClient(collection_url=url)
|
|
|
|
|
|
def _carddav_metadata(
|
|
*,
|
|
auth_type: str,
|
|
username: str | None,
|
|
password: str | None,
|
|
bearer_token: str | None,
|
|
credential_ref: str | None,
|
|
collection_url: str,
|
|
) -> dict[str, Any]:
|
|
secret = password if auth_type == "basic" else bearer_token if auth_type == "bearer" else None
|
|
auth: dict[str, Any] = {
|
|
"auth_type": auth_type,
|
|
"username": _trim(username),
|
|
"credential_ref": _trim(credential_ref),
|
|
}
|
|
if secret:
|
|
auth["secret_encrypted"] = encrypt_secret(secret)
|
|
return {
|
|
"carddav": auth,
|
|
"collection_url": collection_url,
|
|
}
|
|
|
|
|
|
def _resolve_carddav_secret(
|
|
*,
|
|
auth_type: str,
|
|
password: str | None,
|
|
bearer_token: str | None,
|
|
credential_ref: str | None,
|
|
encrypted: str | None,
|
|
) -> str | None:
|
|
if auth_type == "basic" and password:
|
|
return password
|
|
if auth_type == "bearer" and bearer_token:
|
|
return bearer_token
|
|
if credential_ref:
|
|
raise AddressBookError(
|
|
"The CardDAV credential reference is not a server-owned credential; provide a replacement password or token"
|
|
)
|
|
if encrypted:
|
|
return decrypt_secret(encrypted)
|
|
return None
|
|
|
|
|
|
def resolve_trusted_deployment_carddav_credential_ref(credential_ref: str) -> str | None:
|
|
"""Resolve env-backed credentials only for trusted deployment code.
|
|
|
|
API-managed discovery and sync-source paths deliberately never call this
|
|
function, so a tenant user cannot select arbitrary process environment
|
|
variables as connector credentials.
|
|
"""
|
|
|
|
if not credential_ref.startswith(CARDDAV_SECRET_ENV_PREFIX):
|
|
raise AddressBookError("Trusted deployment credential references must use the env: prefix")
|
|
env_name = credential_ref.removeprefix(CARDDAV_SECRET_ENV_PREFIX)
|
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_name):
|
|
raise AddressBookError("Trusted deployment credential reference contains an invalid environment variable name")
|
|
return os.environ.get(env_name)
|
|
|
|
|
|
def public_address_sync_metadata(metadata: object) -> dict[str, Any]:
|
|
payload = copy.deepcopy(metadata) if isinstance(metadata, dict) else {}
|
|
auth = payload.get("carddav")
|
|
if not isinstance(auth, dict):
|
|
return payload
|
|
had_credential = bool(auth.get("secret_encrypted") or auth.get("credential_ref"))
|
|
auth.pop("secret_encrypted", None)
|
|
auth.pop("credential_ref", None)
|
|
auth["has_credential"] = had_credential
|
|
return payload
|
|
|
|
|
|
def _assert_no_caller_carddav_credential_ref(credential_ref: str | None) -> None:
|
|
if _trim(credential_ref):
|
|
raise AddressBookError(
|
|
"Caller-supplied credential references are not accepted; provide a password or bearer token"
|
|
)
|
|
|
|
|
|
def _carddav_auth_metadata(metadata: object) -> dict[str, Any]:
|
|
if not isinstance(metadata, dict):
|
|
return {}
|
|
carddav = metadata.get("carddav")
|
|
return carddav if isinstance(carddav, dict) else {}
|
|
|
|
|
|
def _assert_api_carddav_metadata_safe(metadata: object) -> None:
|
|
auth = _carddav_auth_metadata(metadata)
|
|
if auth.get("credential_ref") or auth.get("secret_encrypted"):
|
|
raise AddressBookError(
|
|
"CardDAV credential references and encrypted secrets are server-managed; provide credentials through the CardDAV source endpoint"
|
|
)
|
|
|
|
|
|
def _merge_server_owned_carddav_metadata(existing: object, incoming: dict[str, Any]) -> dict[str, Any]:
|
|
merged = copy.deepcopy(incoming)
|
|
existing_auth = _carddav_auth_metadata(existing)
|
|
server_owned = {
|
|
key: existing_auth[key]
|
|
for key in ("credential_ref", "secret_encrypted")
|
|
if existing_auth.get(key)
|
|
}
|
|
if not server_owned:
|
|
return merged
|
|
auth = merged.get("carddav")
|
|
if not isinstance(auth, dict):
|
|
auth = {}
|
|
merged["carddav"] = auth
|
|
auth.update(server_owned)
|
|
return merged
|
|
|
|
|
|
def _secret_value(value: Any | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
if hasattr(value, "get_secret_value"):
|
|
return str(value.get_secret_value())
|
|
return str(value)
|
|
|
|
|
|
def _sync_plan_diagnostic(plan: AddressSyncPlan) -> dict[str, Any]:
|
|
return {
|
|
"created": plan.stats.created,
|
|
"updated": plan.stats.updated,
|
|
"deleted": plan.stats.deleted,
|
|
"conflicts": plan.stats.conflicts,
|
|
"unchanged": plan.stats.unchanged,
|
|
"errors": plan.stats.errors,
|
|
"full_sync": plan.stats.full_sync,
|
|
"used_sync_token": plan.stats.used_sync_token,
|
|
}
|
|
|
|
|
|
def _contact_change_payload(contact: Contact, *, prefix: str = "") -> dict[str, Any]:
|
|
return {
|
|
f"{prefix}address_book_id": contact.address_book_id,
|
|
f"{prefix}display_name": contact.display_name,
|
|
f"{prefix}source_kind": contact.source_kind,
|
|
f"{prefix}source_ref": contact.source_ref,
|
|
f"{prefix}source_revision": contact.source_revision,
|
|
}
|
|
|
|
|
|
def _contact_payload_for_conflict(contact: Contact) -> dict[str, Any]:
|
|
return {
|
|
"display_name": contact.display_name,
|
|
"given_name": contact.given_name,
|
|
"family_name": contact.family_name,
|
|
"organization": contact.organization,
|
|
"role_title": contact.role_title,
|
|
"note": contact.note,
|
|
"tags": list(contact.tags or []),
|
|
"emails": [
|
|
{"label": email.label, "email": email.email, "is_primary": email.is_primary}
|
|
for email in sorted(contact.emails, key=lambda item: (item.order_index, item.id))
|
|
],
|
|
"phones": [
|
|
{"label": phone.label, "phone": phone.phone, "is_primary": phone.is_primary}
|
|
for phone in sorted(contact.phones, key=lambda item: (item.order_index, item.id))
|
|
],
|
|
"postal_addresses": [
|
|
{
|
|
"label": address.label,
|
|
"street": address.street,
|
|
"postal_code": address.postal_code,
|
|
"locality": address.locality,
|
|
"region": address.region,
|
|
"country": address.country,
|
|
"is_primary": address.is_primary,
|
|
}
|
|
for address in sorted(contact.postal_addresses, key=lambda item: (item.order_index, item.id))
|
|
],
|
|
}
|
|
|
|
|
|
def _record_address_contact_change(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
contact: Contact,
|
|
operation: str,
|
|
previous: dict[str, Any] | None = None,
|
|
) -> None:
|
|
payload = _contact_change_payload(contact)
|
|
if previous:
|
|
payload.update(previous)
|
|
record_change(
|
|
session,
|
|
module_id=ADDRESS_MODULE_ID,
|
|
collection=ADDRESS_CONTACTS_COLLECTION,
|
|
resource_type=ADDRESS_CONTACT_RESOURCE,
|
|
resource_id=contact.id,
|
|
operation=operation,
|
|
tenant_id=contact.tenant_id,
|
|
actor_type="user",
|
|
actor_id=_account_id(principal),
|
|
payload=payload,
|
|
)
|
|
|
|
|
|
def _read_only_from_sync_direction(sync_direction: str, explicit_read_only: bool | None) -> bool:
|
|
if sync_direction in READ_ONLY_SYNC_DIRECTIONS:
|
|
return True
|
|
if explicit_read_only is not None:
|
|
return explicit_read_only
|
|
return False
|
|
|
|
|
|
def _apply_sync_source_to_book(book: AddressBook, sync_source: AddressSyncSource) -> None:
|
|
book.source_kind = sync_source.connector_type
|
|
book.source_ref = sync_source.id
|
|
book.read_only = sync_source.read_only or sync_source.sync_direction in READ_ONLY_SYNC_DIRECTIONS
|
|
book.sync_status = sync_source.status
|
|
book.sync_error = sync_source.last_error
|
|
book.updated_by_account_id = sync_source.updated_by_account_id
|
|
|
|
|
|
def _sync_contact_or_none(session: Session, principal: ApiPrincipal, sync_source: AddressSyncSource, contact_id: str | None) -> Contact | None:
|
|
if not contact_id:
|
|
return None
|
|
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
|
|
if contact.address_book_id != sync_source.address_book_id:
|
|
raise AddressBookError("Sync contact must belong to the sync source address book.")
|
|
return contact
|
|
|
|
|
|
def _visible_address_lists_query(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False, include_deleted_books: bool = False):
|
|
book_ids = [book.id for book in list_address_books(session, principal, include_deleted=include_deleted_books)]
|
|
if not book_ids:
|
|
return session.query(AddressList).filter(false())
|
|
query = session.query(AddressList).filter(AddressList.address_book_id.in_(book_ids))
|
|
if not include_deleted:
|
|
query = query.filter(AddressList.deleted_at.is_(None))
|
|
return query
|
|
|
|
|
|
def list_address_lists(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
address_book_id: str | None = None,
|
|
include_deleted: bool = False,
|
|
) -> list[AddressList]:
|
|
query = _visible_address_lists_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted)
|
|
if address_book_id:
|
|
get_visible_address_book(session, principal, address_book_id, include_deleted=include_deleted)
|
|
query = query.filter(AddressList.address_book_id == address_book_id)
|
|
return query.order_by(AddressList.name.asc(), AddressList.id.asc()).all()
|
|
|
|
|
|
def get_visible_address_list(session: Session, principal: ApiPrincipal, address_list_id: str, *, include_deleted: bool = False) -> AddressList:
|
|
address_list = (
|
|
_visible_address_lists_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted)
|
|
.filter(AddressList.id == address_list_id)
|
|
.one_or_none()
|
|
)
|
|
if address_list is None:
|
|
raise AddressBookError("Address list not found.")
|
|
return address_list
|
|
|
|
|
|
def create_address_list(session: Session, principal: ApiPrincipal, address_book_id: str, payload: AddressListCreateRequest) -> AddressList:
|
|
book = get_visible_address_book(session, principal, address_book_id)
|
|
_require_mutable_book(book)
|
|
name = _trim(payload.name)
|
|
if not name:
|
|
raise AddressBookError("Address list name is required.")
|
|
address_list = AddressList(
|
|
tenant_id=book.tenant_id,
|
|
address_book_id=book.id,
|
|
name=name,
|
|
description=_trim(payload.description),
|
|
source_kind=book.source_kind,
|
|
read_only=book.read_only,
|
|
created_by_account_id=_account_id(principal),
|
|
updated_by_account_id=_account_id(principal),
|
|
metadata_={},
|
|
)
|
|
session.add(address_list)
|
|
return address_list
|
|
|
|
|
|
def update_address_list(session: Session, principal: ApiPrincipal, address_list_id: str, payload: AddressListUpdateRequest) -> AddressList:
|
|
address_list = get_visible_address_list(session, principal, address_list_id)
|
|
_require_mutable_address_list(address_list)
|
|
if "name" in payload.model_fields_set:
|
|
name = _trim(payload.name)
|
|
if not name:
|
|
raise AddressBookError("Address list name is required.")
|
|
address_list.name = name
|
|
if "description" in payload.model_fields_set:
|
|
address_list.description = _trim(payload.description)
|
|
address_list.updated_by_account_id = _account_id(principal)
|
|
return address_list
|
|
|
|
|
|
def delete_address_list(session: Session, principal: ApiPrincipal, address_list_id: str) -> None:
|
|
address_list = get_visible_address_list(session, principal, address_list_id)
|
|
_require_mutable_address_list(address_list)
|
|
address_list.deleted_at = utcnow()
|
|
address_list.updated_by_account_id = _account_id(principal)
|
|
|
|
|
|
def restore_address_list(session: Session, principal: ApiPrincipal, address_list_id: str) -> AddressList:
|
|
address_list = get_visible_address_list(session, principal, address_list_id, include_deleted=True)
|
|
_require_mutable_book(address_list.address_book)
|
|
if address_list.read_only:
|
|
raise AddressBookError("Address list is read-only.")
|
|
address_list.deleted_at = None
|
|
address_list.updated_by_account_id = _account_id(principal)
|
|
return address_list
|
|
|
|
|
|
def list_address_list_entries(session: Session, principal: ApiPrincipal, address_list_id: str) -> list[AddressListEntry]:
|
|
address_list = get_visible_address_list(session, principal, address_list_id)
|
|
return (
|
|
session.query(AddressListEntry)
|
|
.join(Contact, AddressListEntry.contact_id == Contact.id)
|
|
.filter(AddressListEntry.address_list_id == address_list.id, Contact.deleted_at.is_(None))
|
|
.order_by(AddressListEntry.order_index.asc(), AddressListEntry.id.asc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def create_address_list_entry(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
address_list_id: str,
|
|
payload: AddressListEntryCreateRequest,
|
|
) -> AddressListEntry:
|
|
address_list = get_visible_address_list(session, principal, address_list_id)
|
|
_require_mutable_address_list(address_list)
|
|
contact = get_visible_contact(session, principal, payload.contact_id)
|
|
if contact.address_book_id != address_list.address_book_id:
|
|
raise AddressBookError("Address-list entries must reference contacts in the same address book.")
|
|
contact_email = _contact_email_by_id(contact, payload.contact_email_id)
|
|
postal_address = _contact_postal_address_by_id(contact, payload.contact_postal_address_id)
|
|
if contact_email is not None and postal_address is not None:
|
|
raise AddressBookError("Address-list entries may reference either an email address or a postal address, not both.")
|
|
order_index = _next_address_list_entry_order(session, address_list.id)
|
|
entry = AddressListEntry(
|
|
address_list_id=address_list.id,
|
|
contact_id=contact.id,
|
|
contact_email_id=contact_email.id if contact_email is not None else None,
|
|
contact_postal_address_id=postal_address.id if postal_address is not None else None,
|
|
target_kind=_address_list_entry_kind(contact_email, postal_address),
|
|
label=_trim(payload.label),
|
|
order_index=order_index,
|
|
metadata_={},
|
|
)
|
|
address_list.updated_by_account_id = _account_id(principal)
|
|
session.add(entry)
|
|
return entry
|
|
|
|
|
|
def delete_address_list_entry(session: Session, principal: ApiPrincipal, entry_id: str) -> None:
|
|
entry = get_visible_address_list_entry(session, principal, entry_id)
|
|
_require_mutable_address_list(entry.address_list)
|
|
entry.address_list.updated_by_account_id = _account_id(principal)
|
|
session.delete(entry)
|
|
|
|
|
|
def get_visible_address_list_entry(session: Session, principal: ApiPrincipal, entry_id: str) -> AddressListEntry:
|
|
list_ids = [address_list.id for address_list in list_address_lists(session, principal)]
|
|
if not list_ids:
|
|
raise AddressBookError("Address-list entry not found.")
|
|
entry = session.query(AddressListEntry).filter(AddressListEntry.id == entry_id, AddressListEntry.address_list_id.in_(list_ids)).one_or_none()
|
|
if entry is None:
|
|
raise AddressBookError("Address-list entry not found.")
|
|
return entry
|
|
|
|
|
|
def _contact_email_by_id(contact: Contact, email_id: str | None) -> ContactEmail | None:
|
|
if not email_id:
|
|
return None
|
|
email = next((item for item in contact.emails if item.id == email_id), None)
|
|
if email is None:
|
|
raise AddressBookError("Contact email not found.")
|
|
return email
|
|
|
|
|
|
def _contact_postal_address_by_id(contact: Contact, postal_address_id: str | None) -> ContactPostalAddress | None:
|
|
if not postal_address_id:
|
|
return None
|
|
postal_address = next((item for item in contact.postal_addresses if item.id == postal_address_id), None)
|
|
if postal_address is None:
|
|
raise AddressBookError("Contact postal address not found.")
|
|
return postal_address
|
|
|
|
|
|
def _address_list_entry_kind(contact_email: ContactEmail | None, postal_address: ContactPostalAddress | None) -> str:
|
|
if contact_email is not None:
|
|
return "email"
|
|
if postal_address is not None:
|
|
return "postal_address"
|
|
return "contact"
|
|
|
|
|
|
def _next_address_list_entry_order(session: Session, address_list_id: str) -> int:
|
|
current = session.query(func.max(AddressListEntry.order_index)).filter(AddressListEntry.address_list_id == address_list_id).scalar()
|
|
return int(current or 0) + 1
|
|
|
|
|
|
def create_address_book(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
payload: AddressBookCreateRequest,
|
|
*,
|
|
allow_system: bool = False,
|
|
) -> AddressBook:
|
|
name = _trim(payload.name)
|
|
if not name:
|
|
raise AddressBookError("Address book name is required.")
|
|
tenant_id, scope_id = _scope_id_for_create(principal, payload, allow_system=allow_system)
|
|
book = AddressBook(
|
|
tenant_id=tenant_id,
|
|
scope_type=payload.scope_type,
|
|
scope_id=scope_id,
|
|
name=name,
|
|
description=_trim(payload.description),
|
|
source_kind="local",
|
|
read_only=False,
|
|
created_by_account_id=_account_id(principal),
|
|
updated_by_account_id=_account_id(principal),
|
|
metadata_={},
|
|
)
|
|
session.add(book)
|
|
return book
|
|
|
|
|
|
def update_address_book(session: Session, principal: ApiPrincipal, book_id: str, payload: AddressBookUpdateRequest) -> AddressBook:
|
|
book = get_visible_address_book(session, principal, book_id)
|
|
_require_mutable_book(book)
|
|
if "name" in payload.model_fields_set:
|
|
name = _trim(payload.name)
|
|
if not name:
|
|
raise AddressBookError("Address book name is required.")
|
|
book.name = name
|
|
if "description" in payload.model_fields_set:
|
|
book.description = _trim(payload.description)
|
|
book.updated_by_account_id = _account_id(principal)
|
|
return book
|
|
|
|
|
|
def delete_address_book(session: Session, principal: ApiPrincipal, book_id: str) -> None:
|
|
book = get_visible_address_book(session, principal, book_id)
|
|
_require_mutable_book(book)
|
|
deleted_at = utcnow()
|
|
book.deleted_at = deleted_at
|
|
book.updated_by_account_id = _account_id(principal)
|
|
for contact in book.contacts:
|
|
if contact.deleted_at is None:
|
|
contact.deleted_at = deleted_at
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
|
|
|
|
def restore_address_book(session: Session, principal: ApiPrincipal, book_id: str, *, restore_contacts: bool = True) -> AddressBook:
|
|
book = get_visible_address_book(session, principal, book_id, include_deleted=True)
|
|
if book.read_only:
|
|
raise AddressBookError("Address book is read-only.")
|
|
book.deleted_at = None
|
|
book.updated_by_account_id = _account_id(principal)
|
|
if restore_contacts:
|
|
for contact in book.contacts:
|
|
if contact.deleted_at is not None:
|
|
contact.deleted_at = None
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
return book
|
|
|
|
|
|
def _primary_email(payloads: list[ContactEmailPayload]) -> str | None:
|
|
if not payloads:
|
|
return None
|
|
primary = next((item.email for item in payloads if item.is_primary), None)
|
|
return _trim(primary) or _trim(payloads[0].email)
|
|
|
|
|
|
def _display_name_from_payload(payload: ContactCreateRequest | ContactUpdateRequest, *, fallback: str | None = None) -> str:
|
|
explicit = _trim(payload.display_name)
|
|
if explicit:
|
|
return explicit
|
|
parts = [_trim(payload.given_name), _trim(payload.family_name)]
|
|
joined = " ".join(part for part in parts if part)
|
|
if joined:
|
|
return joined
|
|
emails = getattr(payload, "emails", None)
|
|
if isinstance(emails, list):
|
|
email = _primary_email(emails)
|
|
if email:
|
|
return email
|
|
if fallback:
|
|
return fallback
|
|
raise AddressBookError("Contact name or email is required.")
|
|
|
|
|
|
def _normalize_tags(tags: list[str] | None) -> list[str]:
|
|
seen: set[str] = set()
|
|
normalized: list[str] = []
|
|
for tag in tags or []:
|
|
value = tag.strip()
|
|
key = value.casefold()
|
|
if value and key not in seen:
|
|
seen.add(key)
|
|
normalized.append(value)
|
|
return normalized
|
|
|
|
|
|
def _replace_emails(contact: Contact, payloads: list[ContactEmailPayload]) -> None:
|
|
contact.emails.clear()
|
|
normalized = [_trim(item.email) for item in payloads]
|
|
normalized_payloads = [(item, email) for item, email in zip(payloads, normalized, strict=False) if email]
|
|
primary_index = next((index for index, (item, _email) in enumerate(normalized_payloads) if item.is_primary), 0)
|
|
for index, (item, email) in enumerate(normalized_payloads):
|
|
contact.emails.append(
|
|
ContactEmail(
|
|
label=_trim(item.label),
|
|
email=email or "",
|
|
is_primary=index == primary_index,
|
|
order_index=index,
|
|
)
|
|
)
|
|
|
|
|
|
def _replace_phones(contact: Contact, payloads: list[ContactPhonePayload]) -> None:
|
|
contact.phones.clear()
|
|
normalized = [_trim(item.phone) for item in payloads]
|
|
normalized_payloads = [(item, phone) for item, phone in zip(payloads, normalized, strict=False) if phone]
|
|
primary_index = next((index for index, (item, _phone) in enumerate(normalized_payloads) if item.is_primary), 0)
|
|
for index, (item, phone) in enumerate(normalized_payloads):
|
|
contact.phones.append(
|
|
ContactPhone(
|
|
label=_trim(item.label),
|
|
phone=phone or "",
|
|
is_primary=index == primary_index,
|
|
order_index=index,
|
|
)
|
|
)
|
|
|
|
|
|
def _postal_payload_has_content(payload: ContactPostalAddressPayload) -> bool:
|
|
return any(_trim(value) for value in (payload.street, payload.postal_code, payload.locality, payload.region, payload.country))
|
|
|
|
|
|
def _replace_postal_addresses(contact: Contact, payloads: list[ContactPostalAddressPayload]) -> None:
|
|
contact.postal_addresses.clear()
|
|
normalized_payloads = [item for item in payloads if _postal_payload_has_content(item)]
|
|
primary_index = next((index for index, item in enumerate(normalized_payloads) if item.is_primary), 0)
|
|
for index, item in enumerate(normalized_payloads):
|
|
contact.postal_addresses.append(
|
|
ContactPostalAddress(
|
|
label=_trim(item.label),
|
|
street=_trim(item.street),
|
|
postal_code=_trim(item.postal_code),
|
|
locality=_trim(item.locality),
|
|
region=_trim(item.region),
|
|
country=_trim(item.country),
|
|
is_primary=index == primary_index,
|
|
order_index=index,
|
|
)
|
|
)
|
|
|
|
|
|
def create_contact(session: Session, principal: ApiPrincipal, address_book_id: str, payload: ContactCreateRequest) -> Contact:
|
|
book = get_visible_address_book(session, principal, address_book_id)
|
|
_require_mutable_book(book)
|
|
contact = Contact(
|
|
tenant_id=book.tenant_id,
|
|
address_book_id=book.id,
|
|
display_name=_display_name_from_payload(payload),
|
|
given_name=_trim(payload.given_name),
|
|
family_name=_trim(payload.family_name),
|
|
organization=_trim(payload.organization),
|
|
role_title=_trim(payload.role_title),
|
|
note=_trim(payload.note),
|
|
tags=_normalize_tags(payload.tags),
|
|
source_kind=book.source_kind,
|
|
provenance=payload.provenance or {},
|
|
created_by_account_id=_account_id(principal),
|
|
updated_by_account_id=_account_id(principal),
|
|
metadata_={},
|
|
)
|
|
_replace_emails(contact, payload.emails)
|
|
_replace_phones(contact, payload.phones)
|
|
_replace_postal_addresses(contact, payload.postal_addresses)
|
|
session.add(contact)
|
|
return contact
|
|
|
|
|
|
def import_vcards(session: Session, principal: ApiPrincipal, address_book_id: str, content: str) -> VCardImportResult:
|
|
book = get_visible_address_book(session, principal, address_book_id)
|
|
_require_mutable_book(book)
|
|
contacts: list[Contact] = []
|
|
result = parse_vcards_with_issues(content)
|
|
for parsed in result.cards:
|
|
contact = create_contact(session, principal, book.id, parsed.payload)
|
|
contact.source_kind = "vcard"
|
|
contact.source_ref = _trim(parsed.source_ref)
|
|
contact.source_payload_kind = "vcard"
|
|
contact.source_payload_raw = parsed.raw
|
|
contact.source_revision = _trim(parsed.source_revision)
|
|
contacts.append(contact)
|
|
return VCardImportResult(contacts=contacts, issues=result.issues, skipped=result.skipped)
|
|
|
|
|
|
def _visible_contact_query(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False, include_deleted_books: bool = False):
|
|
book_ids = [book.id for book in list_address_books(session, principal, include_deleted=include_deleted_books)]
|
|
if not book_ids:
|
|
return session.query(Contact).filter(false())
|
|
query = session.query(Contact).filter(Contact.address_book_id.in_(book_ids))
|
|
if not include_deleted:
|
|
query = query.filter(Contact.deleted_at.is_(None))
|
|
return query
|
|
|
|
|
|
def list_contacts(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
address_book_id: str | None = None,
|
|
query: str | None = None,
|
|
limit: int = 200,
|
|
include_deleted: bool = False,
|
|
) -> list[Contact]:
|
|
contact_query = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted)
|
|
if address_book_id:
|
|
get_visible_address_book(session, principal, address_book_id, include_deleted=include_deleted)
|
|
contact_query = contact_query.filter(Contact.address_book_id == address_book_id)
|
|
normalized_query = _trim(query)
|
|
if normalized_query:
|
|
pattern = f"%{normalized_query.lower()}%"
|
|
contact_query = contact_query.outerjoin(ContactEmail).filter(
|
|
or_(
|
|
func.lower(Contact.display_name).like(pattern),
|
|
func.lower(Contact.organization).like(pattern),
|
|
func.lower(ContactEmail.email).like(pattern),
|
|
)
|
|
)
|
|
return contact_query.order_by(Contact.display_name.asc(), Contact.id.asc()).limit(max(1, min(limit, 500))).all()
|
|
|
|
|
|
def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False) -> Contact:
|
|
contact = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted).filter(Contact.id == contact_id).one_or_none()
|
|
if contact is None:
|
|
raise AddressBookError("Contact not found.")
|
|
return contact
|
|
|
|
|
|
def update_contact(session: Session, principal: ApiPrincipal, contact_id: str, payload: ContactUpdateRequest) -> Contact:
|
|
contact = get_visible_contact(session, principal, contact_id)
|
|
_require_mutable_book(contact.address_book)
|
|
fallback_name = contact.display_name
|
|
if any(field in payload.model_fields_set for field in ("display_name", "given_name", "family_name", "emails")):
|
|
contact.display_name = _display_name_from_payload(payload, fallback=fallback_name)
|
|
for field_name, attr_name in (
|
|
("given_name", "given_name"),
|
|
("family_name", "family_name"),
|
|
("organization", "organization"),
|
|
("role_title", "role_title"),
|
|
("note", "note"),
|
|
):
|
|
if field_name in payload.model_fields_set:
|
|
setattr(contact, attr_name, _trim(getattr(payload, field_name)))
|
|
if "tags" in payload.model_fields_set:
|
|
contact.tags = _normalize_tags(payload.tags)
|
|
if "provenance" in payload.model_fields_set:
|
|
contact.provenance = payload.provenance or {}
|
|
if "emails" in payload.model_fields_set:
|
|
_replace_emails(contact, payload.emails or [])
|
|
if "phones" in payload.model_fields_set:
|
|
_replace_phones(contact, payload.phones or [])
|
|
if "postal_addresses" in payload.model_fields_set:
|
|
_replace_postal_addresses(contact, payload.postal_addresses or [])
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
return contact
|
|
|
|
|
|
def delete_contact(session: Session, principal: ApiPrincipal, contact_id: str) -> None:
|
|
contact = get_visible_contact(session, principal, contact_id)
|
|
_require_mutable_book(contact.address_book)
|
|
contact.deleted_at = utcnow()
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
|
|
|
|
def restore_contact(session: Session, principal: ApiPrincipal, contact_id: str) -> Contact:
|
|
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
|
|
_require_mutable_book(contact.address_book)
|
|
contact.deleted_at = None
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
return contact
|
|
|
|
|
|
def export_address_book_vcard(session: Session, principal: ApiPrincipal, address_book_id: str) -> tuple[AddressBook, str]:
|
|
book = get_visible_address_book(session, principal, address_book_id)
|
|
contacts = (
|
|
session.query(Contact)
|
|
.filter(Contact.address_book_id == book.id, Contact.deleted_at.is_(None))
|
|
.order_by(Contact.display_name.asc(), Contact.id.asc())
|
|
.all()
|
|
)
|
|
return book, contacts_to_vcard(contacts)
|
|
|
|
|
|
def export_contact_vcard(session: Session, principal: ApiPrincipal, contact_id: str) -> tuple[Contact, str]:
|
|
contact = get_visible_contact(session, principal, contact_id)
|
|
return contact, contacts_to_vcard([contact])
|