4630 lines
171 KiB
Python
4630 lines
171 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import unicodedata
|
|
import urllib.parse
|
|
from typing import Any
|
|
|
|
from sqlalchemy import and_, exists, false, func, or_
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
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.credential_envelopes import (
|
|
CredentialAccessContext,
|
|
CredentialEnvelopeError,
|
|
ResolvedCredentialEnvelope,
|
|
credential_envelope_summary,
|
|
list_credential_envelopes,
|
|
resolve_credential_envelope,
|
|
)
|
|
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.ldap import (
|
|
AddressLdapClient,
|
|
AddressLdapError,
|
|
)
|
|
from govoplan_addresses.backend.ldap_schemas import (
|
|
AddressLdapConnectionRequest,
|
|
AddressLdapSourceCreateRequest,
|
|
)
|
|
from govoplan_addresses.backend.db.models import (
|
|
AddressBook,
|
|
AddressList,
|
|
AddressListEntry,
|
|
AddressSyncConflict,
|
|
AddressSyncDiagnostic,
|
|
AddressSyncSource,
|
|
AddressSyncTombstone,
|
|
Contact,
|
|
ContactChannelRule,
|
|
ContactEmail,
|
|
ContactFieldProvenance,
|
|
ContactMergeRecord,
|
|
ContactPhone,
|
|
ContactPointQualityDecision,
|
|
ContactPostalAddress,
|
|
ContactRedirect,
|
|
new_uuid,
|
|
)
|
|
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,
|
|
ContactChannelRuleCreateRequest,
|
|
ContactMergeRequest,
|
|
ContactMergeRecoveryRequest,
|
|
ContactPointQualityDecisionCreateRequest,
|
|
)
|
|
from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues
|
|
|
|
|
|
class AddressBookError(ValueError):
|
|
pass
|
|
|
|
|
|
CORE_CREDENTIAL_ENVELOPE_PREFIX = "credential-envelope:"
|
|
|
|
|
|
def address_credential_context(
|
|
*,
|
|
tenant_id: str,
|
|
source_id: str | None = None,
|
|
) -> CredentialAccessContext:
|
|
return CredentialAccessContext(
|
|
tenant_id=tenant_id,
|
|
target_scope_type="tenant",
|
|
target_scope_id=tenant_id,
|
|
module_id="addresses",
|
|
server_ref=f"addresses:{source_id}" if source_id else None,
|
|
)
|
|
|
|
|
|
def available_address_credentials(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
source_id: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
context = address_credential_context(
|
|
tenant_id=principal.tenant_id,
|
|
source_id=source_id,
|
|
)
|
|
return [
|
|
credential_envelope_summary(row)
|
|
for row in list_credential_envelopes(session, context=context)
|
|
]
|
|
|
|
|
|
@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
|
|
raw_payload: str | None = None
|
|
source_details: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AddressSyncPlan:
|
|
sync_source: AddressSyncSource
|
|
stats: AddressSyncPlanStats
|
|
items: list[AddressSyncPlanItem] = field(default_factory=list)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ContactDuplicateFeature:
|
|
code: str
|
|
label: str
|
|
weight: int
|
|
value: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ContactDuplicateSuggestion:
|
|
left: Contact
|
|
right: Contact
|
|
score: int
|
|
confidence: str
|
|
features: tuple[ContactDuplicateFeature, ...]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ContactDuplicateScan:
|
|
suggestions: tuple[ContactDuplicateSuggestion, ...]
|
|
scanned_contacts: int
|
|
candidate_pairs: int
|
|
truncated: bool
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ContactRedirectResolution:
|
|
requested_contact_id: str
|
|
resolved_contact_id: str
|
|
redirected: bool
|
|
redirect_chain: tuple[str, ...]
|
|
merge_record_ids: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AddressQualityCorrection:
|
|
contact_id: str
|
|
display_name: str
|
|
channel: str
|
|
contact_point_id: str | None
|
|
state: str
|
|
reason_code: str
|
|
reason: str | None
|
|
effective_from: datetime
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AddressQualitySummary:
|
|
contact_count: int
|
|
contact_point_count: int
|
|
quality_counts: dict[str, int]
|
|
duplicate_suggestion_count: int
|
|
correction_count: int
|
|
corrections: tuple[AddressQualityCorrection, ...]
|
|
truncated: bool
|
|
|
|
|
|
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)
|
|
if connector_type.casefold() in {"ldap", "active_directory"} and not trusted_connector_metadata:
|
|
_assert_api_ldap_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)
|
|
elif sync_source.connector_type.casefold() in {"ldap", "active_directory"}:
|
|
_assert_api_ldap_metadata_safe(metadata)
|
|
metadata = _merge_server_owned_ldap_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_remote_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)
|
|
if payload.credential_ref and (payload.password is not None or payload.bearer_token is not None):
|
|
raise AddressBookError("Select a reusable credential or enter a new secret, not both.")
|
|
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=payload.credential_ref,
|
|
collection_url=collection_url,
|
|
)
|
|
source = 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,
|
|
)
|
|
reusable = _resolve_core_address_credential(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
source_id=source.id,
|
|
credential_ref=payload.credential_ref,
|
|
)
|
|
if reusable is not None:
|
|
source_metadata = dict(source.metadata_ or {})
|
|
auth = dict(source_metadata.get("carddav") or {})
|
|
auth["username"] = _credential_username(reusable) or auth.get("username")
|
|
source_metadata["carddav"] = auth
|
|
source.metadata_ = source_metadata
|
|
return source
|
|
|
|
|
|
def discover_ldap_base_dns(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
payload: AddressLdapConnectionRequest,
|
|
*,
|
|
client: AddressLdapClient | None = None,
|
|
) -> tuple[str, ...]:
|
|
_assert_reusable_credential_ref(payload.credential_ref)
|
|
ldap_client = client or _ldap_client_from_connection_payload(
|
|
session,
|
|
principal,
|
|
payload,
|
|
)
|
|
return ldap_client.discover_base_dns()
|
|
|
|
|
|
def create_ldap_sync_source(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
address_book_id: str,
|
|
payload: AddressLdapSourceCreateRequest,
|
|
) -> AddressSyncSource:
|
|
_assert_reusable_credential_ref(payload.credential_ref)
|
|
# Constructor validation rejects plaintext LDAP and embedded URL credentials
|
|
# without opening a network connection.
|
|
AddressLdapClient(
|
|
url=payload.url,
|
|
bind_dn=payload.bind_dn,
|
|
start_tls=payload.start_tls,
|
|
connect_timeout=payload.connect_timeout,
|
|
receive_timeout=payload.receive_timeout,
|
|
)
|
|
metadata = {
|
|
"ldap": {
|
|
"url": payload.url.strip(),
|
|
"base_dn": payload.base_dn.strip(),
|
|
"search_filter": payload.search_filter.strip(),
|
|
"start_tls": payload.start_tls,
|
|
"connect_timeout": payload.connect_timeout,
|
|
"receive_timeout": payload.receive_timeout,
|
|
"page_size": payload.page_size,
|
|
"max_entries": payload.max_entries,
|
|
"attribute_map": dict(payload.attribute_map),
|
|
"bind_dn": _trim(payload.bind_dn),
|
|
"credential_ref": _trim(payload.credential_ref),
|
|
}
|
|
}
|
|
source = create_sync_source(
|
|
session,
|
|
principal,
|
|
address_book_id,
|
|
AddressSyncSourceCreateRequest(
|
|
connector_type="ldap",
|
|
display_name=payload.display_name,
|
|
external_account_ref=payload.url,
|
|
external_address_book_ref=payload.base_dn,
|
|
sync_direction="read_only",
|
|
read_only=True,
|
|
metadata=metadata,
|
|
),
|
|
trusted_connector_metadata=True,
|
|
)
|
|
reusable = _resolve_core_address_credential(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
source_id=source.id,
|
|
credential_ref=payload.credential_ref,
|
|
)
|
|
if reusable is not None and not metadata["ldap"].get("bind_dn"):
|
|
metadata["ldap"]["bind_dn"] = _credential_username(reusable)
|
|
source.metadata_ = metadata
|
|
return source
|
|
|
|
|
|
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 | AddressLdapClient | None = None,
|
|
) -> AddressSyncPlan:
|
|
sync_source = get_visible_sync_source(session, principal, sync_source_id)
|
|
if sync_source.connector_type == "carddav":
|
|
return _build_carddav_sync_plan(
|
|
session,
|
|
principal,
|
|
sync_source,
|
|
force_full=force_full,
|
|
password=password,
|
|
bearer_token=bearer_token,
|
|
client=client, # type: ignore[arg-type]
|
|
)
|
|
if sync_source.connector_type in {"ldap", "active_directory"}:
|
|
return _build_ldap_sync_plan(
|
|
session,
|
|
principal,
|
|
sync_source,
|
|
client=client, # type: ignore[arg-type]
|
|
)
|
|
raise AddressBookError(f"Preview is not implemented for {sync_source.connector_type} sync sources.")
|
|
|
|
|
|
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 | AddressLdapClient | 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:
|
|
if sync_source.connector_type == "carddav":
|
|
plan = _build_carddav_sync_plan(
|
|
session,
|
|
principal,
|
|
sync_source,
|
|
force_full=force_full,
|
|
password=password,
|
|
bearer_token=bearer_token,
|
|
client=write_client, # type: ignore[arg-type]
|
|
)
|
|
elif sync_source.connector_type in {"ldap", "active_directory"}:
|
|
plan = _build_ldap_sync_plan(
|
|
session,
|
|
principal,
|
|
sync_source,
|
|
client=write_client, # type: ignore[arg-type]
|
|
)
|
|
else:
|
|
raise AddressBookError(f"Sync is not implemented for {sync_source.connector_type} sources.")
|
|
_apply_address_sync_plan(
|
|
session,
|
|
principal,
|
|
plan,
|
|
client=write_client if sync_source.connector_type == "carddav" else None, # type: ignore[arg-type]
|
|
)
|
|
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_ldap_sync_plan(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source: AddressSyncSource,
|
|
*,
|
|
client: AddressLdapClient | None,
|
|
) -> AddressSyncPlan:
|
|
settings = _ldap_metadata(sync_source.metadata_)
|
|
if not settings:
|
|
raise AddressBookError("LDAP sync source configuration is missing.")
|
|
ldap_client = client or _ldap_client_for_source(session, sync_source)
|
|
attribute_map = {
|
|
str(key): str(value)
|
|
for key, value in dict(settings.get("attribute_map") or {}).items()
|
|
if str(key).strip() and str(value).strip()
|
|
}
|
|
source_key_attribute = attribute_map.get("source_key")
|
|
if not source_key_attribute:
|
|
raise AddressBookError("LDAP source mapping requires a stable source_key attribute.")
|
|
attributes = tuple(
|
|
dict.fromkeys(
|
|
[
|
|
*attribute_map.values(),
|
|
"entryUUID",
|
|
"objectGUID",
|
|
"modifyTimestamp",
|
|
"uSNChanged",
|
|
"entryCSN",
|
|
]
|
|
)
|
|
)
|
|
try:
|
|
result = ldap_client.search(
|
|
base_dn=str(settings.get("base_dn") or sync_source.external_address_book_ref or ""),
|
|
search_filter=str(settings.get("search_filter") or "(objectClass=person)"),
|
|
attributes=attributes,
|
|
page_size=int(settings.get("page_size") or 500),
|
|
max_entries=int(settings.get("max_entries") or 10_000),
|
|
)
|
|
except AddressLdapError as exc:
|
|
raise AddressBookError(str(exc)) from exc
|
|
|
|
stats = AddressSyncPlanStats(full_sync=True)
|
|
plan = AddressSyncPlan(sync_source=sync_source, stats=stats)
|
|
existing = {
|
|
str(contact.source_ref): contact
|
|
for contact in _ldap_contacts_for_source(session, sync_source)
|
|
if contact.source_ref
|
|
}
|
|
observed_refs: set[str] = set()
|
|
revision_rows: list[dict[str, str]] = []
|
|
for entry in result.entries:
|
|
serialized_attributes = _json_safe_ldap_attributes(entry.attributes)
|
|
source_key = _ldap_scalar(entry.attributes.get(source_key_attribute))
|
|
if not source_key:
|
|
source_key = entry.dn.strip()
|
|
if not source_key:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="error",
|
|
message="LDAP entry has neither the configured source key nor a DN.",
|
|
),
|
|
)
|
|
continue
|
|
source_ref = f"ldap:{sync_source.id}:{hashlib.sha256(source_key.encode()).hexdigest()}"
|
|
if source_ref in observed_refs:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="error",
|
|
href=source_ref,
|
|
remote_uid=source_key,
|
|
message="LDAP search returned a duplicate stable source key.",
|
|
),
|
|
)
|
|
continue
|
|
observed_refs.add(source_ref)
|
|
source_revision = _ldap_source_revision(
|
|
entry.attributes,
|
|
attribute_map=attribute_map,
|
|
serialized_attributes=serialized_attributes,
|
|
)
|
|
revision_rows.append({"source_key": source_key, "revision": source_revision})
|
|
try:
|
|
payload = _ldap_contact_payload(
|
|
entry.attributes,
|
|
attribute_map=attribute_map,
|
|
sync_source=sync_source,
|
|
source_key=source_key,
|
|
source_revision=source_revision,
|
|
dn=entry.dn,
|
|
)
|
|
except (AddressBookError, ValueError) as exc:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="error",
|
|
href=source_ref,
|
|
remote_uid=source_key,
|
|
message=str(exc),
|
|
source_revision=source_revision,
|
|
),
|
|
)
|
|
continue
|
|
local = existing.get(source_ref)
|
|
action = "create"
|
|
if local is not None:
|
|
comparable = payload.model_dump(mode="json", exclude={"provenance"})
|
|
action = (
|
|
"unchanged"
|
|
if local.deleted_at is None
|
|
and _contact_payload_for_conflict(local) == comparable
|
|
and local.source_revision == source_revision
|
|
else "update"
|
|
)
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action=action,
|
|
href=source_ref,
|
|
remote_uid=source_key,
|
|
contact_id=local.id if local is not None else None,
|
|
display_name=payload.display_name,
|
|
parsed_payload=payload,
|
|
source_revision=source_revision,
|
|
raw_payload=json.dumps(serialized_attributes, sort_keys=True, ensure_ascii=True),
|
|
source_details={"dn": entry.dn, "source_key": source_key},
|
|
),
|
|
)
|
|
|
|
if result.complete:
|
|
for source_ref, contact in existing.items():
|
|
if source_ref not in observed_refs and contact.deleted_at is None:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="delete",
|
|
href=source_ref,
|
|
remote_uid=str((contact.provenance or {}).get("ldap", {}).get("source_key") or "") or None,
|
|
contact_id=contact.id,
|
|
display_name=contact.display_name,
|
|
message="LDAP authoritative source no longer contains this contact.",
|
|
),
|
|
)
|
|
else:
|
|
_add_sync_plan_item(
|
|
plan,
|
|
AddressSyncPlanItem(
|
|
action="error",
|
|
message="LDAP result reached the configured entry limit; absence-based deletes are suppressed.",
|
|
),
|
|
)
|
|
stats.remote_revision = hashlib.sha256(
|
|
json.dumps(sorted(revision_rows, key=lambda item: item["source_key"]), sort_keys=True).encode()
|
|
).hexdigest()
|
|
return plan
|
|
|
|
|
|
def _ldap_contact_payload(
|
|
attributes: dict[str, Any],
|
|
*,
|
|
attribute_map: dict[str, str],
|
|
sync_source: AddressSyncSource,
|
|
source_key: str,
|
|
source_revision: str,
|
|
dn: str,
|
|
) -> ContactCreateRequest:
|
|
def scalar(target: str) -> str | None:
|
|
attribute = attribute_map.get(target)
|
|
return _ldap_scalar(attributes.get(attribute)) if attribute else None
|
|
|
|
given_name = scalar("given_name")
|
|
family_name = scalar("family_name")
|
|
email = scalar("email")
|
|
organization = scalar("organization")
|
|
display_name = scalar("display_name") or " ".join(
|
|
value for value in (given_name, family_name) if value
|
|
) or email or organization
|
|
if not display_name:
|
|
raise AddressBookError(f'LDAP entry "{dn or source_key}" has no mapped contact identity.')
|
|
phone = scalar("phone")
|
|
postal = {
|
|
target: scalar(target)
|
|
for target in ("street", "postal_code", "locality", "region", "country")
|
|
}
|
|
tag_attribute = attribute_map.get("tags")
|
|
tags = [str(item).strip() for item in _ldap_values(attributes.get(tag_attribute)) if str(item).strip()] if tag_attribute else []
|
|
return ContactCreateRequest(
|
|
display_name=display_name,
|
|
given_name=given_name,
|
|
family_name=family_name,
|
|
organization=organization,
|
|
role_title=scalar("role_title"),
|
|
note=scalar("note"),
|
|
tags=tags,
|
|
emails=[ContactEmailPayload(email=email, is_primary=True)] if email else [],
|
|
phones=[ContactPhonePayload(phone=phone, is_primary=True)] if phone else [],
|
|
postal_addresses=[ContactPostalAddressPayload(**postal, is_primary=True)] if any(postal.values()) else [],
|
|
provenance={
|
|
"ldap": {
|
|
"sync_source_id": sync_source.id,
|
|
"dn": dn,
|
|
"source_key": source_key,
|
|
"source_revision": source_revision,
|
|
"authority": "external_authoritative",
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
def _ldap_source_revision(
|
|
attributes: dict[str, Any],
|
|
*,
|
|
attribute_map: dict[str, str],
|
|
serialized_attributes: dict[str, Any],
|
|
) -> str:
|
|
configured = attribute_map.get("source_revision")
|
|
for attribute in (configured, "modifyTimestamp", "uSNChanged", "entryCSN"):
|
|
if attribute:
|
|
value = _ldap_scalar(attributes.get(attribute))
|
|
if value:
|
|
return value
|
|
return hashlib.sha256(
|
|
json.dumps(serialized_attributes, sort_keys=True, ensure_ascii=True).encode()
|
|
).hexdigest()
|
|
|
|
|
|
def _ldap_values(value: Any) -> tuple[Any, ...]:
|
|
if value is None:
|
|
return ()
|
|
if isinstance(value, (list, tuple, set)):
|
|
return tuple(value)
|
|
return (value,)
|
|
|
|
|
|
def _ldap_scalar(value: Any) -> str | None:
|
|
values = _ldap_values(value)
|
|
if not values:
|
|
return None
|
|
selected = values[0]
|
|
if isinstance(selected, bytes):
|
|
return selected.hex()
|
|
normalized = str(selected).strip()
|
|
return normalized or None
|
|
|
|
|
|
def _json_safe_ldap_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
|
|
result: dict[str, Any] = {}
|
|
for key, value in attributes.items():
|
|
values = _ldap_values(value)
|
|
normalized = [item.hex() if isinstance(item, bytes) else str(item) for item in values]
|
|
result[str(key)] = normalized if isinstance(value, (list, tuple, set)) else (normalized[0] if normalized else None)
|
|
return result
|
|
|
|
|
|
def _ldap_contacts_for_source(session: Session, sync_source: AddressSyncSource) -> list[Contact]:
|
|
return (
|
|
session.query(Contact)
|
|
.filter(
|
|
Contact.address_book_id == sync_source.address_book_id,
|
|
Contact.source_kind.in_(("ldap", "active_directory")),
|
|
Contact.source_ref.like(f"ldap:{sync_source.id}:%"),
|
|
)
|
|
.order_by(Contact.id.asc())
|
|
.all()
|
|
)
|
|
|
|
|
|
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_remote_contact(session, principal, plan.sync_source, item)
|
|
item.contact_id = contact.id
|
|
elif item.action == "update":
|
|
contact = _upsert_remote_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_remote_contact(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
sync_source: AddressSyncSource,
|
|
item: AddressSyncPlanItem,
|
|
) -> Contact:
|
|
if item.parsed_payload is None:
|
|
raise AddressBookError("Mapped remote contact 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" if sync_source.connector_type == "carddav" else "ldap-entry"
|
|
contact.source_payload_raw = item.raw_vcard if sync_source.connector_type == "carddav" else None
|
|
contact.source_revision = item.source_revision
|
|
if sync_source.connector_type == "carddav":
|
|
source_provenance = {
|
|
"carddav": {
|
|
"sync_source_id": sync_source.id,
|
|
"href": item.href,
|
|
"remote_uid": item.remote_uid,
|
|
"etag": item.etag,
|
|
}
|
|
}
|
|
else:
|
|
source_provenance = {
|
|
"ldap": {
|
|
"sync_source_id": sync_source.id,
|
|
"source_ref": item.href,
|
|
"source_key": item.remote_uid,
|
|
"source_revision": item.source_revision,
|
|
"dn": item.source_details.get("dn"),
|
|
"authority": "external_authoritative",
|
|
}
|
|
}
|
|
contact.provenance = {**(item.parsed_payload.provenance or {}), **source_provenance}
|
|
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 _ldap_client_from_connection_payload(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
payload: AddressLdapConnectionRequest,
|
|
) -> AddressLdapClient:
|
|
reusable = _resolve_core_address_credential(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
source_id=None,
|
|
credential_ref=payload.credential_ref,
|
|
)
|
|
bind_dn = _trim(payload.bind_dn) or _credential_username(reusable)
|
|
password = _credential_secret(reusable, auth_type="basic") if reusable is not None else None
|
|
return AddressLdapClient(
|
|
url=payload.url,
|
|
bind_dn=bind_dn,
|
|
password=password,
|
|
start_tls=payload.start_tls,
|
|
connect_timeout=payload.connect_timeout,
|
|
receive_timeout=payload.receive_timeout,
|
|
)
|
|
|
|
|
|
def _ldap_client_for_source(
|
|
session: Session,
|
|
sync_source: AddressSyncSource,
|
|
) -> AddressLdapClient:
|
|
settings = _ldap_metadata(sync_source.metadata_)
|
|
reusable = _resolve_core_address_credential(
|
|
session,
|
|
tenant_id=sync_source.tenant_id or "",
|
|
source_id=sync_source.id,
|
|
credential_ref=settings.get("credential_ref"),
|
|
)
|
|
bind_dn = _trim(str(settings.get("bind_dn") or "")) or _credential_username(reusable)
|
|
password = _credential_secret(reusable, auth_type="basic") if reusable is not None else None
|
|
return AddressLdapClient(
|
|
url=str(settings.get("url") or sync_source.external_account_ref or ""),
|
|
bind_dn=bind_dn,
|
|
password=password,
|
|
start_tls=bool(settings.get("start_tls", True)),
|
|
connect_timeout=int(settings.get("connect_timeout") or 10),
|
|
receive_timeout=int(settings.get("receive_timeout") or 30),
|
|
)
|
|
|
|
|
|
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")
|
|
reusable = _resolve_core_address_credential(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
source_id=source.id if source else None,
|
|
credential_ref=credential_ref,
|
|
)
|
|
username = username or _credential_username(reusable)
|
|
secret = _resolve_carddav_secret(
|
|
session=session,
|
|
tenant_id=principal.tenant_id,
|
|
source_id=source.id if source else None,
|
|
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:
|
|
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")
|
|
reusable = _resolve_core_address_credential(
|
|
session,
|
|
tenant_id=sync_source.tenant_id or "",
|
|
source_id=sync_source.id,
|
|
credential_ref=auth.get("credential_ref"),
|
|
)
|
|
username = username or _credential_username(reusable)
|
|
secret = _resolve_carddav_secret(
|
|
session=session,
|
|
tenant_id=sync_source.tenant_id or "",
|
|
source_id=sync_source.id,
|
|
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(
|
|
*,
|
|
session: Session,
|
|
tenant_id: str,
|
|
source_id: str | None,
|
|
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:
|
|
reusable = _resolve_core_address_credential(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
source_id=source_id,
|
|
credential_ref=credential_ref,
|
|
)
|
|
if reusable is None:
|
|
raise AddressBookError(
|
|
"The CardDAV credential reference is not a server-owned credential or visible reusable credential envelope."
|
|
)
|
|
return _credential_secret(reusable, auth_type=auth_type)
|
|
if encrypted:
|
|
return decrypt_secret(encrypted)
|
|
return None
|
|
|
|
|
|
def _core_credential_id(credential_ref: str | None) -> str | None:
|
|
if not credential_ref or not credential_ref.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX):
|
|
return None
|
|
value = credential_ref.removeprefix(CORE_CREDENTIAL_ENVELOPE_PREFIX).strip()
|
|
return value or None
|
|
|
|
|
|
def _resolve_core_address_credential(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
source_id: str | None,
|
|
credential_ref: str | None,
|
|
) -> ResolvedCredentialEnvelope | None:
|
|
credential_id = _core_credential_id(credential_ref)
|
|
if credential_id is None:
|
|
return None
|
|
try:
|
|
return resolve_credential_envelope(
|
|
session,
|
|
credential_id=credential_id,
|
|
context=address_credential_context(
|
|
tenant_id=tenant_id,
|
|
source_id=source_id,
|
|
),
|
|
)
|
|
except CredentialEnvelopeError as exc:
|
|
raise AddressBookError(
|
|
"The selected reusable credential is unavailable to this CardDAV source."
|
|
) from exc
|
|
|
|
|
|
def _credential_username(credential: ResolvedCredentialEnvelope | None) -> str | None:
|
|
if credential is None:
|
|
return None
|
|
value = credential.public_data.get("username")
|
|
return _trim(value)
|
|
|
|
|
|
def _credential_secret(credential: ResolvedCredentialEnvelope, *, auth_type: str) -> str | None:
|
|
keys = (
|
|
("password", "secret", "token")
|
|
if auth_type == "basic"
|
|
else ("access_token", "bearer_token", "token", "password", "secret")
|
|
)
|
|
for key in keys:
|
|
value = credential.secret_data.get(key)
|
|
if value is not None and str(value):
|
|
return str(value)
|
|
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 isinstance(auth, dict):
|
|
had_credential = bool(auth.get("secret_encrypted") or auth.get("credential_ref"))
|
|
auth["credential_envelope_id"] = _core_credential_id(auth.get("credential_ref"))
|
|
auth.pop("secret_encrypted", None)
|
|
auth.pop("credential_ref", None)
|
|
auth["has_credential"] = had_credential
|
|
ldap = payload.get("ldap")
|
|
if isinstance(ldap, dict):
|
|
credential_ref = ldap.pop("credential_ref", None)
|
|
ldap["credential_envelope_id"] = _core_credential_id(credential_ref)
|
|
ldap["has_credential"] = bool(credential_ref)
|
|
return payload
|
|
|
|
|
|
def _assert_no_caller_carddav_credential_ref(credential_ref: str | None) -> None:
|
|
value = _trim(credential_ref)
|
|
if value and not value.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX):
|
|
raise AddressBookError(
|
|
"Caller-supplied credential references are accepted only for reusable credential envelopes."
|
|
)
|
|
|
|
|
|
def _assert_reusable_credential_ref(credential_ref: str | None) -> None:
|
|
_assert_no_caller_carddav_credential_ref(credential_ref)
|
|
|
|
|
|
def _ldap_metadata(metadata: object) -> dict[str, Any]:
|
|
if not isinstance(metadata, dict):
|
|
return {}
|
|
ldap = metadata.get("ldap")
|
|
return ldap if isinstance(ldap, dict) else {}
|
|
|
|
|
|
def _assert_api_ldap_metadata_safe(metadata: object) -> None:
|
|
ldap = _ldap_metadata(metadata)
|
|
forbidden = {"credential_ref", "password", "secret", "bind_password"}.intersection(ldap)
|
|
if forbidden:
|
|
raise AddressBookError(
|
|
"LDAP credential references and secrets are server-managed; use the LDAP source endpoint."
|
|
)
|
|
|
|
|
|
def _merge_server_owned_ldap_metadata(existing: object, incoming: dict[str, Any]) -> dict[str, Any]:
|
|
merged = copy.deepcopy(incoming)
|
|
existing_ldap = _ldap_metadata(existing)
|
|
credential_ref = existing_ldap.get("credential_ref")
|
|
if credential_ref:
|
|
ldap = merged.get("ldap")
|
|
if not isinstance(ldap, dict):
|
|
ldap = {}
|
|
merged["ldap"] = ldap
|
|
ldap["credential_ref"] = credential_ref
|
|
return merged
|
|
|
|
|
|
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,
|
|
merge_record_id: str | None = None,
|
|
) -> None:
|
|
session.flush()
|
|
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,
|
|
)
|
|
_record_contact_field_provenance(
|
|
session,
|
|
principal,
|
|
contact=contact,
|
|
reason_code=f"addresses.contact.{operation}",
|
|
explanation=f"The retained value was selected during contact {operation}.",
|
|
merge_record_id=merge_record_id,
|
|
)
|
|
|
|
|
|
def _record_contact_field_provenance(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
contact: Contact,
|
|
reason_code: str,
|
|
explanation: str,
|
|
merge_record_id: str | None = None,
|
|
) -> None:
|
|
session.flush()
|
|
session.query(ContactFieldProvenance).filter(
|
|
ContactFieldProvenance.contact_id == contact.id,
|
|
ContactFieldProvenance.selected.is_(True),
|
|
).update({ContactFieldProvenance.selected: False}, synchronize_session=False)
|
|
contact_provenance = contact.provenance if isinstance(contact.provenance, dict) else {}
|
|
raw_visibility = contact_provenance.get("field_visibility")
|
|
visibility_by_path = dict(raw_visibility) if isinstance(raw_visibility, dict) else {}
|
|
raw_field_sources = contact_provenance.get("field_sources")
|
|
field_sources = dict(raw_field_sources) if isinstance(raw_field_sources, dict) else {}
|
|
for path, value, provenance in _retained_contact_fields(contact):
|
|
field_source = field_sources.get(path)
|
|
if isinstance(field_source, dict):
|
|
provenance = {**provenance, **field_source}
|
|
source_kind = str(provenance.get("source_kind") or contact.source_kind or "local")
|
|
source_ref = provenance.get("source_ref") or contact.source_ref
|
|
source_revision = provenance.get("source_revision") or contact.source_revision
|
|
visibility = str(visibility_by_path.get(path) or "inherit")
|
|
if visibility not in {"inherit", "private", "restricted", "public"}:
|
|
visibility = "inherit"
|
|
session.add(
|
|
ContactFieldProvenance(
|
|
tenant_id=contact.tenant_id,
|
|
contact_id=contact.id,
|
|
field_path=path,
|
|
value=value,
|
|
source_kind=source_kind,
|
|
source_ref=str(source_ref) if source_ref else None,
|
|
source_revision=str(source_revision) if source_revision else None,
|
|
precedence=_source_precedence(source_kind),
|
|
selected=True,
|
|
reason_code=reason_code,
|
|
explanation=explanation,
|
|
visibility=visibility,
|
|
merge_record_id=merge_record_id,
|
|
created_by_account_id=_account_id(principal),
|
|
metadata_={
|
|
"operation": reason_code.rsplit(".", 1)[-1],
|
|
**{
|
|
key: value
|
|
for key, value in provenance.items()
|
|
if key not in {"source_kind", "source_ref", "source_revision"}
|
|
},
|
|
},
|
|
)
|
|
)
|
|
|
|
|
|
def _retained_contact_fields(
|
|
contact: Contact,
|
|
) -> list[tuple[str, Any, dict[str, Any]]]:
|
|
contact_source = {
|
|
"source_kind": contact.source_kind,
|
|
"source_ref": contact.source_ref,
|
|
"source_revision": contact.source_revision,
|
|
}
|
|
rows: list[tuple[str, Any, dict[str, Any]]] = [
|
|
(field_name, getattr(contact, field_name), contact_source)
|
|
for field_name in (
|
|
"display_name",
|
|
"given_name",
|
|
"family_name",
|
|
"organization",
|
|
"role_title",
|
|
"note",
|
|
"tags",
|
|
)
|
|
]
|
|
for point in contact.emails:
|
|
provenance = dict(point.provenance or contact_source)
|
|
base = f"emails.{point.id}"
|
|
rows.extend(
|
|
(
|
|
(f"{base}.label", point.label, provenance),
|
|
(f"{base}.email", point.email, provenance),
|
|
(f"{base}.is_primary", point.is_primary, provenance),
|
|
)
|
|
)
|
|
for point in contact.phones:
|
|
provenance = dict(point.provenance or contact_source)
|
|
base = f"phones.{point.id}"
|
|
rows.extend(
|
|
(
|
|
(f"{base}.label", point.label, provenance),
|
|
(f"{base}.phone", point.phone, provenance),
|
|
(f"{base}.is_primary", point.is_primary, provenance),
|
|
)
|
|
)
|
|
for point in contact.postal_addresses:
|
|
provenance = dict(point.provenance or contact_source)
|
|
base = f"postal_addresses.{point.id}"
|
|
for field_name in (
|
|
"label",
|
|
"street",
|
|
"postal_code",
|
|
"locality",
|
|
"region",
|
|
"country",
|
|
"is_primary",
|
|
):
|
|
rows.append((f"{base}.{field_name}", getattr(point, field_name), provenance))
|
|
return rows
|
|
|
|
|
|
def _source_precedence(source_kind: str) -> int:
|
|
return {
|
|
"manual": 100,
|
|
"local": 90,
|
|
"carddav": 80,
|
|
"microsoft_graph": 75,
|
|
"google": 75,
|
|
"ldap": 70,
|
|
"vcard": 60,
|
|
"csv": 50,
|
|
}.get(source_kind, 40)
|
|
|
|
|
|
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 _normalize_match_text(value: str | None) -> str:
|
|
if value is None:
|
|
return ""
|
|
return " ".join(unicodedata.normalize("NFKC", value).strip().casefold().split())
|
|
|
|
|
|
def _normalize_email(value: str | None) -> str:
|
|
return _normalize_match_text(value)
|
|
|
|
|
|
def _normalize_phone(value: str | None) -> str:
|
|
if value is None:
|
|
return ""
|
|
normalized = unicodedata.normalize("NFKC", value).strip()
|
|
prefix = "+" if normalized.startswith("+") else ""
|
|
return prefix + re.sub(r"\D", "", normalized)
|
|
|
|
|
|
def _normalized_postal_value(payload: ContactPostalAddressPayload) -> dict[str, str | None]:
|
|
return {
|
|
key: _normalize_match_text(getattr(payload, key)) or None
|
|
for key in ("label", "street", "postal_code", "locality", "region", "country")
|
|
}
|
|
|
|
|
|
def _original_postal_value(payload: ContactPostalAddressPayload) -> dict[str, str | None]:
|
|
return {
|
|
key: getattr(payload, key)
|
|
for key in ("label", "street", "postal_code", "locality", "region", "country")
|
|
}
|
|
|
|
|
|
def _contact_point_provenance(contact: Contact, *, point_kind: str) -> dict[str, Any]:
|
|
return {
|
|
"module": "addresses",
|
|
"point_kind": point_kind,
|
|
"source_kind": contact.source_kind,
|
|
"source_ref": contact.source_ref,
|
|
"source_revision": contact.source_revision,
|
|
"supplied_by_account_id": contact.updated_by_account_id or contact.created_by_account_id,
|
|
"original_value_preserved": True,
|
|
}
|
|
|
|
|
|
def _restamp_contact_point_provenance(contact: Contact) -> None:
|
|
for point in contact.emails:
|
|
point.provenance = _contact_point_provenance(contact, point_kind="email")
|
|
for point in contact.phones:
|
|
point.provenance = _contact_point_provenance(contact, point_kind="phone")
|
|
for point in contact.postal_addresses:
|
|
point.provenance = _contact_point_provenance(contact, point_kind="postal")
|
|
|
|
|
|
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 "",
|
|
original_email=item.email,
|
|
normalized_email=_normalize_email(item.email),
|
|
provenance=_contact_point_provenance(contact, point_kind="email"),
|
|
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 "",
|
|
original_phone=item.phone,
|
|
normalized_phone=_normalize_phone(item.phone),
|
|
provenance=_contact_point_provenance(contact, point_kind="phone"),
|
|
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),
|
|
original_value=_original_postal_value(item),
|
|
normalized_value=_normalized_postal_value(item),
|
|
provenance=_contact_point_provenance(contact, point_kind="postal"),
|
|
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)
|
|
_record_address_contact_change(
|
|
session,
|
|
principal,
|
|
contact=contact,
|
|
operation="created",
|
|
)
|
|
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)
|
|
_restamp_contact_point_provenance(contact)
|
|
_record_address_contact_change(
|
|
session,
|
|
principal,
|
|
contact=contact,
|
|
operation="imported",
|
|
)
|
|
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)
|
|
.options(
|
|
selectinload(Contact.emails),
|
|
selectinload(Contact.phones),
|
|
selectinload(Contact.postal_addresses),
|
|
selectinload(Contact.quality_decisions),
|
|
)
|
|
.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,
|
|
address_list_id: str | None = None,
|
|
query: str | None = None,
|
|
limit: int = 200,
|
|
offset: int = 0,
|
|
include_deleted: bool = False,
|
|
) -> list[Contact]:
|
|
contact_query = _filtered_contact_query(
|
|
session,
|
|
principal,
|
|
address_book_id=address_book_id,
|
|
address_list_id=address_list_id,
|
|
query=query,
|
|
include_deleted=include_deleted,
|
|
)
|
|
return (
|
|
contact_query.order_by(Contact.display_name.asc(), Contact.id.asc())
|
|
.offset(max(0, offset))
|
|
.limit(max(1, min(limit, 500)))
|
|
.all()
|
|
)
|
|
|
|
|
|
def count_contacts(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
address_book_id: str | None = None,
|
|
address_list_id: str | None = None,
|
|
query: str | None = None,
|
|
include_deleted: bool = False,
|
|
) -> int:
|
|
contact_query = _filtered_contact_query(
|
|
session,
|
|
principal,
|
|
address_book_id=address_book_id,
|
|
address_list_id=address_list_id,
|
|
query=query,
|
|
include_deleted=include_deleted,
|
|
)
|
|
return int(
|
|
contact_query.order_by(None)
|
|
.with_entities(func.count(func.distinct(Contact.id)))
|
|
.scalar()
|
|
or 0
|
|
)
|
|
|
|
|
|
def _filtered_contact_query(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
address_book_id: str | None,
|
|
address_list_id: str | None,
|
|
query: str | None,
|
|
include_deleted: bool,
|
|
):
|
|
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)
|
|
if address_list_id:
|
|
address_list = get_visible_address_list(
|
|
session,
|
|
principal,
|
|
address_list_id,
|
|
include_deleted=include_deleted,
|
|
)
|
|
if address_book_id and address_list.address_book_id != address_book_id:
|
|
raise AddressBookError("Address list does not belong to the selected address book.")
|
|
contact_query = contact_query.filter(
|
|
exists().where(
|
|
AddressListEntry.contact_id == Contact.id,
|
|
AddressListEntry.address_list_id == address_list_id,
|
|
)
|
|
)
|
|
normalized_query = _trim(query)
|
|
if normalized_query:
|
|
pattern = f"%{normalized_query.lower()}%"
|
|
contact_query = contact_query.filter(
|
|
or_(
|
|
func.lower(Contact.display_name).like(pattern),
|
|
func.lower(Contact.organization).like(pattern),
|
|
exists().where(
|
|
ContactEmail.contact_id == Contact.id,
|
|
func.lower(ContactEmail.email).like(pattern),
|
|
),
|
|
)
|
|
)
|
|
return contact_query
|
|
|
|
|
|
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 list_contact_channel_rules(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
contact_id: str,
|
|
) -> list[ContactChannelRule]:
|
|
contact = get_visible_contact(session, principal, contact_id)
|
|
return (
|
|
session.query(ContactChannelRule)
|
|
.filter(ContactChannelRule.contact_id == contact.id)
|
|
.order_by(ContactChannelRule.created_at.desc(), ContactChannelRule.id.asc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def create_contact_channel_rule(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
contact_id: str,
|
|
payload: ContactChannelRuleCreateRequest,
|
|
) -> ContactChannelRule:
|
|
contact = get_visible_contact(session, principal, contact_id)
|
|
if (
|
|
payload.effective_from is not None
|
|
and payload.effective_until is not None
|
|
and payload.effective_until <= payload.effective_from
|
|
):
|
|
raise AddressBookError("Channel-rule end must be after its start.")
|
|
point_ids: set[str] = set()
|
|
if payload.channel == "email":
|
|
point_ids = {item.id for item in contact.emails}
|
|
elif payload.channel == "postal":
|
|
point_ids = {item.id for item in contact.postal_addresses}
|
|
if payload.contact_point_id and payload.contact_point_id not in point_ids:
|
|
raise AddressBookError(
|
|
"The selected contact point does not belong to this contact and channel."
|
|
)
|
|
rule = ContactChannelRule(
|
|
tenant_id=contact.tenant_id,
|
|
contact_id=contact.id,
|
|
channel=payload.channel,
|
|
purpose=_trim(payload.purpose),
|
|
contact_point_id=payload.contact_point_id,
|
|
decision=payload.decision,
|
|
legal_basis=_trim(payload.legal_basis),
|
|
evidence_ref=_trim(payload.evidence_ref),
|
|
reason=_trim(payload.reason),
|
|
preference_rank=payload.preference_rank,
|
|
locale=_trim(payload.locale),
|
|
effective_from=payload.effective_from,
|
|
effective_until=payload.effective_until,
|
|
created_by_account_id=_account_id(principal),
|
|
metadata_=dict(payload.metadata),
|
|
)
|
|
session.add(rule)
|
|
session.flush()
|
|
return rule
|
|
|
|
|
|
def end_contact_channel_rule(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
rule_id: str,
|
|
) -> ContactChannelRule:
|
|
rule = session.get(ContactChannelRule, rule_id)
|
|
if rule is None:
|
|
raise AddressBookError("Contact channel rule not found.")
|
|
get_visible_contact(session, principal, rule.contact_id)
|
|
now = utcnow()
|
|
if rule.effective_until is None or rule.effective_until > now:
|
|
rule.effective_until = now
|
|
session.add(rule)
|
|
return rule
|
|
|
|
|
|
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)
|
|
previous = _contact_change_payload(contact, prefix="previous_")
|
|
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)
|
|
_record_address_contact_change(
|
|
session,
|
|
principal,
|
|
contact=contact,
|
|
operation="updated",
|
|
previous=previous,
|
|
)
|
|
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)
|
|
previous = _contact_change_payload(contact, prefix="previous_")
|
|
contact.deleted_at = utcnow()
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
_record_address_contact_change(
|
|
session,
|
|
principal,
|
|
contact=contact,
|
|
operation="deleted",
|
|
previous=previous,
|
|
)
|
|
|
|
|
|
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)
|
|
previous = _contact_change_payload(contact, prefix="previous_")
|
|
contact.deleted_at = None
|
|
contact.updated_by_account_id = _account_id(principal)
|
|
_record_address_contact_change(
|
|
session,
|
|
principal,
|
|
contact=contact,
|
|
operation="restored",
|
|
previous=previous,
|
|
)
|
|
return contact
|
|
|
|
|
|
def list_contact_quality_decisions(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
contact_id: str,
|
|
*,
|
|
include_ended: bool = True,
|
|
) -> list[ContactPointQualityDecision]:
|
|
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
|
|
query = session.query(ContactPointQualityDecision).filter(
|
|
ContactPointQualityDecision.contact_id == contact.id
|
|
)
|
|
if not include_ended:
|
|
now = utcnow()
|
|
query = query.filter(
|
|
or_(
|
|
ContactPointQualityDecision.effective_until.is_(None),
|
|
ContactPointQualityDecision.effective_until > now,
|
|
)
|
|
)
|
|
return query.order_by(
|
|
ContactPointQualityDecision.effective_from.desc(),
|
|
ContactPointQualityDecision.created_at.desc(),
|
|
ContactPointQualityDecision.id.asc(),
|
|
).all()
|
|
|
|
|
|
def create_contact_quality_decision(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
contact_id: str,
|
|
payload: ContactPointQualityDecisionCreateRequest,
|
|
) -> ContactPointQualityDecision:
|
|
contact = get_visible_contact(session, principal, contact_id)
|
|
_require_mutable_book(contact.address_book)
|
|
_validate_quality_contact_point(contact, payload.channel, payload.contact_point_id)
|
|
effective_from = payload.effective_from or utcnow()
|
|
overlapping = (
|
|
session.query(ContactPointQualityDecision)
|
|
.filter(
|
|
ContactPointQualityDecision.contact_id == contact.id,
|
|
ContactPointQualityDecision.channel == payload.channel,
|
|
ContactPointQualityDecision.contact_point_id == payload.contact_point_id,
|
|
ContactPointQualityDecision.effective_from <= effective_from,
|
|
or_(
|
|
ContactPointQualityDecision.effective_until.is_(None),
|
|
ContactPointQualityDecision.effective_until > effective_from,
|
|
),
|
|
)
|
|
.all()
|
|
)
|
|
for decision in overlapping:
|
|
decision.effective_until = effective_from
|
|
decision = ContactPointQualityDecision(
|
|
tenant_id=contact.tenant_id,
|
|
contact_id=contact.id,
|
|
channel=payload.channel,
|
|
contact_point_id=payload.contact_point_id,
|
|
state=payload.state,
|
|
reason_code=payload.reason_code or f"addresses.quality.{payload.state}",
|
|
reason=_trim(payload.reason),
|
|
evidence_ref=_trim(payload.evidence_ref),
|
|
effective_from=effective_from,
|
|
created_by_account_id=_account_id(principal),
|
|
metadata_=dict(payload.metadata or {}),
|
|
)
|
|
session.add(decision)
|
|
session.flush()
|
|
_record_address_contact_change(
|
|
session,
|
|
principal,
|
|
contact=contact,
|
|
operation="quality_updated",
|
|
)
|
|
return decision
|
|
|
|
|
|
def current_contact_quality(
|
|
contact: Contact,
|
|
*,
|
|
effective_at: datetime | None = None,
|
|
) -> dict[tuple[str, str | None], ContactPointQualityDecision]:
|
|
at = _aware_time(effective_at or utcnow())
|
|
current: dict[tuple[str, str | None], ContactPointQualityDecision] = {}
|
|
for decision in contact.quality_decisions:
|
|
decision_from = _aware_time(decision.effective_from)
|
|
decision_until = (
|
|
_aware_time(decision.effective_until)
|
|
if decision.effective_until is not None
|
|
else None
|
|
)
|
|
if decision_from > at:
|
|
continue
|
|
if decision_until is not None and decision_until <= at:
|
|
continue
|
|
key = (decision.channel, decision.contact_point_id)
|
|
existing = current.get(key)
|
|
if existing is None or (
|
|
decision_from,
|
|
_aware_time(decision.created_at),
|
|
decision.id,
|
|
) > (
|
|
_aware_time(existing.effective_from),
|
|
_aware_time(existing.created_at),
|
|
existing.id,
|
|
):
|
|
current[key] = decision
|
|
return current
|
|
|
|
|
|
def list_contact_field_provenance(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
contact_id: str,
|
|
*,
|
|
current_only: bool = False,
|
|
limit: int = 500,
|
|
) -> list[ContactFieldProvenance]:
|
|
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
|
|
query = session.query(ContactFieldProvenance).filter(
|
|
ContactFieldProvenance.contact_id == contact.id
|
|
)
|
|
if current_only:
|
|
query = query.filter(ContactFieldProvenance.selected.is_(True))
|
|
return query.order_by(
|
|
ContactFieldProvenance.selected.desc(),
|
|
ContactFieldProvenance.field_path.asc(),
|
|
ContactFieldProvenance.created_at.desc(),
|
|
).limit(max(1, min(limit, 2_000))).all()
|
|
|
|
|
|
def suggest_duplicate_contacts(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
address_book_id: str,
|
|
contact_id: str | None = None,
|
|
minimum_score: int = 40,
|
|
limit: int = 100,
|
|
scan_limit: int = 500,
|
|
) -> ContactDuplicateScan:
|
|
get_visible_address_book(session, principal, address_book_id)
|
|
bounded_scan = max(2, min(scan_limit, 500))
|
|
total = count_contacts(
|
|
session,
|
|
principal,
|
|
address_book_id=address_book_id,
|
|
)
|
|
contacts = list_contacts(
|
|
session,
|
|
principal,
|
|
address_book_id=address_book_id,
|
|
limit=bounded_scan,
|
|
)
|
|
if contact_id and not any(item.id == contact_id for item in contacts):
|
|
selected = get_visible_contact(session, principal, contact_id)
|
|
if selected.address_book_id != address_book_id:
|
|
raise AddressBookError("Duplicate scan contact must belong to the selected address book.")
|
|
contacts = [selected, *contacts[:-1]] if contacts else [selected]
|
|
|
|
feature_index: dict[tuple[str, str], list[Contact]] = {}
|
|
feature_details: dict[tuple[str, str], ContactDuplicateFeature] = {}
|
|
for contact in contacts:
|
|
for key, feature in _duplicate_features(contact):
|
|
feature_index.setdefault(key, []).append(contact)
|
|
feature_details[key] = feature
|
|
|
|
pair_features: dict[tuple[str, str], dict[str, ContactDuplicateFeature]] = {}
|
|
contacts_by_id = {item.id: item for item in contacts}
|
|
for key, matched in feature_index.items():
|
|
if len(matched) < 2:
|
|
continue
|
|
for left_index, left in enumerate(matched[:-1]):
|
|
for right in matched[left_index + 1 :]:
|
|
if contact_id and contact_id not in {left.id, right.id}:
|
|
continue
|
|
pair = tuple(sorted((left.id, right.id)))
|
|
feature = feature_details[key]
|
|
pair_features.setdefault(pair, {})[feature.code] = feature
|
|
|
|
suggestions: list[ContactDuplicateSuggestion] = []
|
|
for pair, features_by_code in pair_features.items():
|
|
features = tuple(
|
|
sorted(
|
|
features_by_code.values(),
|
|
key=lambda item: (-item.weight, item.code),
|
|
)
|
|
)
|
|
score = min(100, sum(item.weight for item in features))
|
|
if score < max(1, min(minimum_score, 100)):
|
|
continue
|
|
suggestions.append(
|
|
ContactDuplicateSuggestion(
|
|
left=contacts_by_id[pair[0]],
|
|
right=contacts_by_id[pair[1]],
|
|
score=score,
|
|
confidence="strong" if score >= 85 else "likely" if score >= 60 else "possible",
|
|
features=features,
|
|
)
|
|
)
|
|
suggestions.sort(
|
|
key=lambda item: (
|
|
-item.score,
|
|
item.left.display_name.casefold(),
|
|
item.right.display_name.casefold(),
|
|
item.left.id,
|
|
item.right.id,
|
|
)
|
|
)
|
|
bounded_limit = max(1, min(limit, 100))
|
|
return ContactDuplicateScan(
|
|
suggestions=tuple(suggestions[:bounded_limit]),
|
|
scanned_contacts=len(contacts),
|
|
candidate_pairs=len(suggestions),
|
|
truncated=total > len(contacts) or len(suggestions) > bounded_limit,
|
|
)
|
|
|
|
|
|
def address_quality_summary(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
address_book_id: str,
|
|
correction_limit: int = 100,
|
|
) -> AddressQualitySummary:
|
|
get_visible_address_book(session, principal, address_book_id)
|
|
contacts = list_contacts(
|
|
session,
|
|
principal,
|
|
address_book_id=address_book_id,
|
|
limit=500,
|
|
)
|
|
total = count_contacts(session, principal, address_book_id=address_book_id)
|
|
points = sum(
|
|
len(contact.emails) + len(contact.phones) + len(contact.postal_addresses)
|
|
for contact in contacts
|
|
)
|
|
quality_counts = {
|
|
"valid": points,
|
|
"invalid": 0,
|
|
"returned": 0,
|
|
"stale": 0,
|
|
"undeliverable": 0,
|
|
}
|
|
corrections: list[AddressQualityCorrection] = []
|
|
for contact in contacts:
|
|
for (channel, point_id), decision in current_contact_quality(contact).items():
|
|
quality_counts[decision.state] = quality_counts.get(decision.state, 0) + 1
|
|
if decision.state != "valid":
|
|
quality_counts["valid"] = max(0, quality_counts["valid"] - 1)
|
|
corrections.append(
|
|
AddressQualityCorrection(
|
|
contact_id=contact.id,
|
|
display_name=contact.display_name,
|
|
channel=channel,
|
|
contact_point_id=point_id,
|
|
state=decision.state,
|
|
reason_code=decision.reason_code,
|
|
reason=decision.reason,
|
|
effective_from=decision.effective_from,
|
|
)
|
|
)
|
|
corrections.sort(
|
|
key=lambda item: (
|
|
item.state,
|
|
item.display_name.casefold(),
|
|
item.contact_point_id or "",
|
|
)
|
|
)
|
|
duplicates = suggest_duplicate_contacts(
|
|
session,
|
|
principal,
|
|
address_book_id=address_book_id,
|
|
limit=100,
|
|
)
|
|
bounded_limit = max(1, min(correction_limit, 500))
|
|
return AddressQualitySummary(
|
|
contact_count=total,
|
|
contact_point_count=points,
|
|
quality_counts=quality_counts,
|
|
duplicate_suggestion_count=duplicates.candidate_pairs,
|
|
correction_count=len(corrections),
|
|
corrections=tuple(corrections[:bounded_limit]),
|
|
truncated=(
|
|
total > len(contacts)
|
|
or len(corrections) > bounded_limit
|
|
or duplicates.truncated
|
|
),
|
|
)
|
|
|
|
|
|
def merge_contacts(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
payload: ContactMergeRequest,
|
|
) -> ContactMergeRecord:
|
|
contact_ids = list(dict.fromkeys([payload.winner_contact_id, *payload.duplicate_contact_ids]))
|
|
if len(contact_ids) < 2:
|
|
raise AddressBookError("A merge requires one winner and at least one distinct duplicate.")
|
|
contacts = [get_visible_contact(session, principal, item) for item in contact_ids]
|
|
winner = contacts[0]
|
|
losers = contacts[1:]
|
|
if any(item.address_book_id != winner.address_book_id for item in losers):
|
|
raise AddressBookError("Contacts can only be merged inside one address book.")
|
|
_require_mutable_book(winner.address_book)
|
|
if session.query(ContactRedirect).filter(
|
|
ContactRedirect.source_contact_id.in_(contact_ids),
|
|
ContactRedirect.ended_at.is_(None),
|
|
).first() is not None:
|
|
raise AddressBookError("A contact in this merge already has an active redirect.")
|
|
allowed_sources = set(contact_ids)
|
|
unsupported_fields = set(payload.field_sources) - set(_MERGE_SCALAR_FIELDS)
|
|
if unsupported_fields:
|
|
raise AddressBookError(
|
|
f"Unsupported merge field source: {sorted(unsupported_fields)[0]}."
|
|
)
|
|
for field_name, source_id in payload.field_sources.items():
|
|
if source_id not in allowed_sources:
|
|
raise AddressBookError(
|
|
f"Merge source for {field_name} must be one of the merged contacts."
|
|
)
|
|
|
|
before_payload = _merge_state_payload(session, contacts)
|
|
record = ContactMergeRecord(
|
|
id=new_uuid(),
|
|
tenant_id=winner.tenant_id,
|
|
address_book_id=winner.address_book_id,
|
|
winner_contact_id=winner.id,
|
|
loser_contact_ids=[item.id for item in losers],
|
|
status="active",
|
|
reason=payload.reason.strip(),
|
|
survivorship={
|
|
"field_sources": dict(payload.field_sources),
|
|
"contact_point_strategy": payload.contact_point_strategy,
|
|
"source_precedence": list(payload.source_precedence),
|
|
},
|
|
decisions=[],
|
|
before_payload=before_payload,
|
|
after_payload={},
|
|
before_hash=_evidence_hash(before_payload),
|
|
after_hash="0" * 64,
|
|
created_by_account_id=_account_id(principal),
|
|
provenance={"module": "addresses", "reversible": True},
|
|
)
|
|
session.add(record)
|
|
session.flush()
|
|
|
|
decisions: list[dict[str, Any]] = []
|
|
winner_provenance = copy.deepcopy(
|
|
winner.provenance if isinstance(winner.provenance, dict) else {}
|
|
)
|
|
raw_field_sources = winner_provenance.get("field_sources")
|
|
retained_field_sources = (
|
|
copy.deepcopy(raw_field_sources) if isinstance(raw_field_sources, dict) else {}
|
|
)
|
|
for field_name in _MERGE_SCALAR_FIELDS:
|
|
selected = _merge_field_source(
|
|
field_name,
|
|
contacts,
|
|
explicit_source_id=payload.field_sources.get(field_name),
|
|
source_precedence=payload.source_precedence,
|
|
)
|
|
value = getattr(selected, field_name)
|
|
if field_name == "display_name" and not _has_merge_value(value):
|
|
selected = winner
|
|
value = winner.display_name
|
|
setattr(winner, field_name, copy.deepcopy(value))
|
|
retained_field_sources[field_name] = {
|
|
"source_kind": selected.source_kind,
|
|
"source_ref": selected.source_ref or f"addresses:contact:{selected.id}",
|
|
"source_revision": selected.source_revision,
|
|
"source_contact_id": selected.id,
|
|
}
|
|
decisions.append(
|
|
{
|
|
"field_path": field_name,
|
|
"source_contact_id": selected.id,
|
|
"source_kind": selected.source_kind,
|
|
"reason_code": (
|
|
"addresses.merge.explicit_survivor"
|
|
if field_name in payload.field_sources
|
|
else "addresses.merge.precedence_survivor"
|
|
if payload.source_precedence
|
|
else "addresses.merge.non_empty_survivor"
|
|
),
|
|
}
|
|
)
|
|
winner_provenance["field_sources"] = retained_field_sources
|
|
winner.provenance = winner_provenance
|
|
if payload.contact_point_strategy == "union":
|
|
winner.tags = _merge_tags(contacts)
|
|
decisions.append(
|
|
{
|
|
"field_path": "tags",
|
|
"source_contact_ids": contact_ids,
|
|
"reason_code": "addresses.merge.union",
|
|
}
|
|
)
|
|
point_map = _merge_contact_points(
|
|
winner,
|
|
losers,
|
|
strategy=payload.contact_point_strategy,
|
|
merge_record_id=record.id,
|
|
decisions=decisions,
|
|
)
|
|
session.flush()
|
|
_copy_merge_governance(
|
|
session,
|
|
winner=winner,
|
|
losers=losers,
|
|
point_map=point_map,
|
|
merge_record_id=record.id,
|
|
principal=principal,
|
|
)
|
|
_redirect_address_list_entries(
|
|
session,
|
|
winner=winner,
|
|
losers=losers,
|
|
point_map=point_map,
|
|
)
|
|
merged_at = utcnow()
|
|
for loser in losers:
|
|
loser.deleted_at = merged_at
|
|
loser.updated_by_account_id = _account_id(principal)
|
|
session.add(
|
|
ContactRedirect(
|
|
tenant_id=winner.tenant_id,
|
|
source_contact_id=loser.id,
|
|
target_contact_id=winner.id,
|
|
merge_record_id=record.id,
|
|
)
|
|
)
|
|
_record_address_contact_change(
|
|
session,
|
|
principal,
|
|
contact=loser,
|
|
operation="merged_redirect",
|
|
merge_record_id=record.id,
|
|
)
|
|
winner.updated_by_account_id = _account_id(principal)
|
|
_record_address_contact_change(
|
|
session,
|
|
principal,
|
|
contact=winner,
|
|
operation="merged",
|
|
merge_record_id=record.id,
|
|
)
|
|
session.flush()
|
|
after_payload = _merge_state_payload(session, contacts)
|
|
record.decisions = decisions
|
|
record.after_payload = after_payload
|
|
record.after_hash = _evidence_hash(after_payload)
|
|
return record
|
|
|
|
|
|
def recover_contact_merge(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
merge_id: str,
|
|
payload: ContactMergeRecoveryRequest,
|
|
*,
|
|
action: str,
|
|
) -> ContactMergeRecord:
|
|
if action not in {"undo", "split"}:
|
|
raise AddressBookError("Unsupported merge recovery action.")
|
|
record = _visible_merge_record(session, principal, merge_id)
|
|
if record.status != "active":
|
|
raise AddressBookError("This merge has already been recovered.")
|
|
if payload.expected_after_hash != record.after_hash:
|
|
raise AddressBookError("Merge recovery evidence does not match the recorded post-merge state.")
|
|
ids = [record.winner_contact_id, *record.loser_contact_ids]
|
|
contacts = [get_visible_contact(session, principal, item, include_deleted=True) for item in ids]
|
|
current_payload = _merge_state_payload(session, contacts)
|
|
if _evidence_hash(current_payload) != record.after_hash:
|
|
raise AddressBookError(
|
|
"Contacts or list memberships changed after this merge; reconcile those edits before recovery."
|
|
)
|
|
_require_mutable_book(contacts[0].address_book)
|
|
|
|
snapshots = {
|
|
str(item["id"]): item
|
|
for item in record.before_payload.get("contacts", [])
|
|
if isinstance(item, dict) and item.get("id")
|
|
}
|
|
for contact in contacts:
|
|
snapshot = snapshots.get(contact.id)
|
|
if snapshot is None:
|
|
raise AddressBookError("Merge recovery evidence is incomplete.")
|
|
_restore_contact_evidence(session, contact, snapshot, merge_record_id=record.id)
|
|
session.flush()
|
|
for item in record.before_payload.get("address_list_entries", []):
|
|
if not isinstance(item, dict) or not item.get("id"):
|
|
continue
|
|
entry = session.get(AddressListEntry, str(item["id"]))
|
|
if entry is None:
|
|
raise AddressBookError("An address-list membership needed for recovery no longer exists.")
|
|
entry.contact_id = str(item["contact_id"])
|
|
entry.contact_email_id = str(item["contact_email_id"]) if item.get("contact_email_id") else None
|
|
entry.contact_postal_address_id = (
|
|
str(item["contact_postal_address_id"])
|
|
if item.get("contact_postal_address_id")
|
|
else None
|
|
)
|
|
entry.target_kind = str(item["target_kind"])
|
|
entry.label = str(item["label"]) if item.get("label") else None
|
|
entry.order_index = int(item.get("order_index") or 0)
|
|
recovered_at = utcnow()
|
|
redirects = session.query(ContactRedirect).filter(
|
|
ContactRedirect.merge_record_id == record.id,
|
|
ContactRedirect.ended_at.is_(None),
|
|
).all()
|
|
for redirect in redirects:
|
|
redirect.ended_at = recovered_at
|
|
record.status = "split" if action == "split" else "undone"
|
|
record.recovered_at = recovered_at
|
|
record.recovered_by_account_id = _account_id(principal)
|
|
record.recovery_action = action
|
|
record.recovery_reason = payload.reason.strip()
|
|
for contact in contacts:
|
|
_record_address_contact_change(
|
|
session,
|
|
principal,
|
|
contact=contact,
|
|
operation=f"merge_{action}",
|
|
merge_record_id=record.id,
|
|
)
|
|
return record
|
|
|
|
|
|
def list_contact_merges(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
address_book_id: str | None = None,
|
|
contact_id: str | None = None,
|
|
limit: int = 100,
|
|
) -> list[ContactMergeRecord]:
|
|
visible_book_ids = [item.id for item in list_address_books(session, principal, include_deleted=True)]
|
|
if not visible_book_ids:
|
|
return []
|
|
query = session.query(ContactMergeRecord).filter(
|
|
ContactMergeRecord.address_book_id.in_(visible_book_ids)
|
|
)
|
|
if address_book_id:
|
|
get_visible_address_book(session, principal, address_book_id, include_deleted=True)
|
|
query = query.filter(ContactMergeRecord.address_book_id == address_book_id)
|
|
if contact_id:
|
|
get_visible_contact(session, principal, contact_id, include_deleted=True)
|
|
rows = query.order_by(ContactMergeRecord.created_at.desc()).limit(500).all()
|
|
if contact_id:
|
|
rows = [
|
|
item
|
|
for item in rows
|
|
if item.winner_contact_id == contact_id
|
|
or contact_id in (item.loser_contact_ids or [])
|
|
]
|
|
return rows[: max(1, min(limit, 500))]
|
|
|
|
|
|
def resolve_contact_redirect(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
contact_id: str,
|
|
) -> ContactRedirectResolution:
|
|
get_visible_contact(session, principal, contact_id, include_deleted=True)
|
|
current_id = contact_id
|
|
chain: list[str] = []
|
|
merge_ids: list[str] = []
|
|
seen = {contact_id}
|
|
for _ in range(20):
|
|
redirect = (
|
|
session.query(ContactRedirect)
|
|
.filter(
|
|
ContactRedirect.source_contact_id == current_id,
|
|
ContactRedirect.ended_at.is_(None),
|
|
)
|
|
.order_by(ContactRedirect.created_at.desc())
|
|
.first()
|
|
)
|
|
if redirect is None:
|
|
break
|
|
if redirect.target_contact_id in seen:
|
|
raise AddressBookError("Contact redirect cycle detected.")
|
|
current_id = redirect.target_contact_id
|
|
seen.add(current_id)
|
|
chain.append(current_id)
|
|
merge_ids.append(redirect.merge_record_id)
|
|
get_visible_contact(session, principal, current_id, include_deleted=True)
|
|
return ContactRedirectResolution(
|
|
requested_contact_id=contact_id,
|
|
resolved_contact_id=current_id,
|
|
redirected=current_id != contact_id,
|
|
redirect_chain=tuple(chain),
|
|
merge_record_ids=tuple(merge_ids),
|
|
)
|
|
|
|
|
|
_MERGE_SCALAR_FIELDS = (
|
|
"display_name",
|
|
"given_name",
|
|
"family_name",
|
|
"organization",
|
|
"role_title",
|
|
"note",
|
|
)
|
|
|
|
|
|
def _validate_quality_contact_point(
|
|
contact: Contact,
|
|
channel: str,
|
|
point_id: str | None,
|
|
) -> None:
|
|
if point_id is None:
|
|
return
|
|
point_ids: set[str]
|
|
if channel == "email":
|
|
point_ids = {item.id for item in contact.emails}
|
|
elif channel == "postal":
|
|
point_ids = {item.id for item in contact.postal_addresses}
|
|
elif channel in {"internal_mail", "portal"}:
|
|
point_ids = set()
|
|
else:
|
|
point_ids = {item.id for item in contact.phones}
|
|
if point_id not in point_ids:
|
|
raise AddressBookError("The selected quality contact point does not belong to this contact and channel.")
|
|
|
|
|
|
def _duplicate_features(
|
|
contact: Contact,
|
|
) -> list[tuple[tuple[str, str], ContactDuplicateFeature]]:
|
|
rows: list[tuple[tuple[str, str], ContactDuplicateFeature]] = []
|
|
for item in contact.emails:
|
|
value = item.normalized_email or _normalize_email(item.email)
|
|
if value:
|
|
rows.append(
|
|
(("email", value), ContactDuplicateFeature("email_exact", "Same email address", 90, item.email))
|
|
)
|
|
for item in contact.phones:
|
|
value = item.normalized_phone or _normalize_phone(item.phone)
|
|
if len(value.lstrip("+")) >= 6:
|
|
rows.append(
|
|
(("phone", value), ContactDuplicateFeature("phone_exact", "Same phone number", 80, item.phone))
|
|
)
|
|
for item in contact.postal_addresses:
|
|
value = _postal_match_key(item)
|
|
if value:
|
|
rows.append(
|
|
(("postal", value), ContactDuplicateFeature("postal_exact", "Same postal address", 65, _postal_display_value(item)))
|
|
)
|
|
name = _normalize_match_text(contact.display_name)
|
|
organization = _normalize_match_text(contact.organization)
|
|
if name and organization:
|
|
rows.append(
|
|
(("name_org", f"{name}|{organization}"), ContactDuplicateFeature("name_organization_exact", "Same name and organization", 55, f"{contact.display_name} · {contact.organization}"))
|
|
)
|
|
elif name:
|
|
rows.append(
|
|
(("name", name), ContactDuplicateFeature("name_exact", "Same display name", 35, contact.display_name))
|
|
)
|
|
return rows
|
|
|
|
|
|
def _postal_match_key(item: ContactPostalAddress) -> str:
|
|
normalized = dict(item.normalized_value or {})
|
|
values = [
|
|
str(normalized.get(key) or _normalize_match_text(getattr(item, key)))
|
|
for key in ("street", "postal_code", "locality", "region", "country")
|
|
]
|
|
return "|".join(values) if any(values) else ""
|
|
|
|
|
|
def _postal_display_value(item: ContactPostalAddress) -> str:
|
|
return ", ".join(
|
|
value
|
|
for value in (
|
|
item.street,
|
|
" ".join(part for part in (item.postal_code, item.locality) if part),
|
|
item.region,
|
|
item.country,
|
|
)
|
|
if value
|
|
)
|
|
|
|
|
|
def _has_merge_value(value: Any) -> bool:
|
|
return value not in (None, "", [], {})
|
|
|
|
|
|
def _merge_field_source(
|
|
field_name: str,
|
|
contacts: list[Contact],
|
|
*,
|
|
explicit_source_id: str | None,
|
|
source_precedence: list[str],
|
|
) -> Contact:
|
|
if explicit_source_id:
|
|
return next(item for item in contacts if item.id == explicit_source_id)
|
|
available = [item for item in contacts if _has_merge_value(getattr(item, field_name))]
|
|
if not available:
|
|
return contacts[0]
|
|
if not source_precedence:
|
|
return available[0]
|
|
ranks = {kind: len(source_precedence) - index for index, kind in enumerate(source_precedence)}
|
|
return max(
|
|
available,
|
|
key=lambda item: (
|
|
ranks.get(item.source_kind, 0),
|
|
_source_precedence(item.source_kind),
|
|
item.id == contacts[0].id,
|
|
),
|
|
)
|
|
|
|
|
|
def _merge_tags(contacts: list[Contact]) -> list[str]:
|
|
seen: set[str] = set()
|
|
result: list[str] = []
|
|
for contact in contacts:
|
|
for value in contact.tags or []:
|
|
key = value.casefold()
|
|
if key not in seen:
|
|
seen.add(key)
|
|
result.append(value)
|
|
return result
|
|
|
|
|
|
def _merge_contact_points(
|
|
winner: Contact,
|
|
losers: list[Contact],
|
|
*,
|
|
strategy: str,
|
|
merge_record_id: str,
|
|
decisions: list[dict[str, Any]],
|
|
) -> dict[str, str]:
|
|
point_map: dict[str, str] = {}
|
|
if strategy == "winner_only":
|
|
return point_map
|
|
email_keys = {
|
|
item.normalized_email or _normalize_email(item.email): item
|
|
for item in winner.emails
|
|
if item.normalized_email or _normalize_email(item.email)
|
|
}
|
|
phone_keys = {
|
|
item.normalized_phone or _normalize_phone(item.phone): item
|
|
for item in winner.phones
|
|
if item.normalized_phone or _normalize_phone(item.phone)
|
|
}
|
|
postal_keys = {
|
|
_postal_match_key(item): item
|
|
for item in winner.postal_addresses
|
|
if _postal_match_key(item)
|
|
}
|
|
for loser in losers:
|
|
for item in loser.emails:
|
|
key = item.normalized_email or _normalize_email(item.email)
|
|
existing = email_keys.get(key)
|
|
created = existing is None
|
|
if existing is None:
|
|
existing = ContactEmail(
|
|
id=new_uuid(),
|
|
label=item.label,
|
|
email=item.email,
|
|
original_email=item.original_email or item.email,
|
|
normalized_email=key,
|
|
provenance={
|
|
**dict(item.provenance or {}),
|
|
"merge_record_id": merge_record_id,
|
|
"copied_from_contact_id": loser.id,
|
|
"copied_from_contact_point_id": item.id,
|
|
},
|
|
is_primary=item.is_primary and not any(row.is_primary for row in winner.emails),
|
|
order_index=len(winner.emails),
|
|
)
|
|
winner.emails.append(existing)
|
|
email_keys[key] = existing
|
|
point_map[item.id] = existing.id
|
|
decisions.append(_contact_point_merge_decision("email", item.id, existing.id, loser.id, created))
|
|
for item in loser.phones:
|
|
key = item.normalized_phone or _normalize_phone(item.phone)
|
|
existing = phone_keys.get(key)
|
|
created = existing is None
|
|
if existing is None:
|
|
existing = ContactPhone(
|
|
id=new_uuid(),
|
|
label=item.label,
|
|
phone=item.phone,
|
|
original_phone=item.original_phone or item.phone,
|
|
normalized_phone=key,
|
|
provenance={
|
|
**dict(item.provenance or {}),
|
|
"merge_record_id": merge_record_id,
|
|
"copied_from_contact_id": loser.id,
|
|
"copied_from_contact_point_id": item.id,
|
|
},
|
|
is_primary=item.is_primary and not any(row.is_primary for row in winner.phones),
|
|
order_index=len(winner.phones),
|
|
)
|
|
winner.phones.append(existing)
|
|
phone_keys[key] = existing
|
|
point_map[item.id] = existing.id
|
|
decisions.append(_contact_point_merge_decision("phone", item.id, existing.id, loser.id, created))
|
|
for item in loser.postal_addresses:
|
|
key = _postal_match_key(item)
|
|
existing = postal_keys.get(key)
|
|
created = existing is None
|
|
if existing is None:
|
|
existing = ContactPostalAddress(
|
|
id=new_uuid(),
|
|
label=item.label,
|
|
street=item.street,
|
|
postal_code=item.postal_code,
|
|
locality=item.locality,
|
|
region=item.region,
|
|
country=item.country,
|
|
original_value=copy.deepcopy(item.original_value or {}),
|
|
normalized_value=copy.deepcopy(item.normalized_value or {}),
|
|
provenance={
|
|
**dict(item.provenance or {}),
|
|
"merge_record_id": merge_record_id,
|
|
"copied_from_contact_id": loser.id,
|
|
"copied_from_contact_point_id": item.id,
|
|
},
|
|
is_primary=item.is_primary and not any(row.is_primary for row in winner.postal_addresses),
|
|
order_index=len(winner.postal_addresses),
|
|
)
|
|
winner.postal_addresses.append(existing)
|
|
postal_keys[key] = existing
|
|
point_map[item.id] = existing.id
|
|
decisions.append(_contact_point_merge_decision("postal", item.id, existing.id, loser.id, created))
|
|
return point_map
|
|
|
|
|
|
def _contact_point_merge_decision(
|
|
channel: str,
|
|
source_id: str,
|
|
selected_id: str,
|
|
source_contact_id: str,
|
|
created: bool,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"field_path": f"{channel}.{selected_id}",
|
|
"source_contact_id": source_contact_id,
|
|
"source_contact_point_id": source_id,
|
|
"selected_contact_point_id": selected_id,
|
|
"reason_code": "addresses.merge.union" if created else "addresses.merge.normalized_duplicate",
|
|
}
|
|
|
|
|
|
def _copy_merge_governance(
|
|
session: Session,
|
|
*,
|
|
winner: Contact,
|
|
losers: list[Contact],
|
|
point_map: dict[str, str],
|
|
merge_record_id: str,
|
|
principal: ApiPrincipal,
|
|
) -> None:
|
|
now = _aware_time(utcnow())
|
|
for loser in losers:
|
|
for rule in loser.channel_rules:
|
|
if rule.contact_point_id is not None and rule.contact_point_id not in point_map:
|
|
continue
|
|
winner.channel_rules.append(
|
|
ContactChannelRule(
|
|
tenant_id=winner.tenant_id,
|
|
channel=rule.channel,
|
|
purpose=rule.purpose,
|
|
contact_point_id=point_map.get(rule.contact_point_id or ""),
|
|
decision=rule.decision,
|
|
legal_basis=rule.legal_basis,
|
|
evidence_ref=rule.evidence_ref,
|
|
reason=rule.reason,
|
|
preference_rank=rule.preference_rank,
|
|
locale=rule.locale,
|
|
effective_from=rule.effective_from,
|
|
effective_until=rule.effective_until,
|
|
created_by_account_id=_account_id(principal),
|
|
metadata_={
|
|
**dict(rule.metadata_ or {}),
|
|
"merge_record_id": merge_record_id,
|
|
"copied_from_contact_id": loser.id,
|
|
"copied_from_rule_id": rule.id,
|
|
},
|
|
)
|
|
)
|
|
for decision in loser.quality_decisions:
|
|
if (
|
|
decision.contact_point_id is not None
|
|
and decision.contact_point_id not in point_map
|
|
):
|
|
continue
|
|
if _aware_time(decision.effective_from) > now or (
|
|
decision.effective_until is not None
|
|
and _aware_time(decision.effective_until) <= now
|
|
):
|
|
continue
|
|
winner.quality_decisions.append(
|
|
ContactPointQualityDecision(
|
|
tenant_id=winner.tenant_id,
|
|
channel=decision.channel,
|
|
contact_point_id=point_map.get(decision.contact_point_id or ""),
|
|
state=decision.state,
|
|
reason_code=decision.reason_code,
|
|
reason=decision.reason,
|
|
evidence_ref=decision.evidence_ref,
|
|
effective_from=decision.effective_from,
|
|
effective_until=decision.effective_until,
|
|
created_by_account_id=_account_id(principal),
|
|
metadata_={
|
|
**dict(decision.metadata_ or {}),
|
|
"merge_record_id": merge_record_id,
|
|
"copied_from_contact_id": loser.id,
|
|
"copied_from_quality_decision_id": decision.id,
|
|
},
|
|
)
|
|
)
|
|
|
|
|
|
def _redirect_address_list_entries(
|
|
session: Session,
|
|
*,
|
|
winner: Contact,
|
|
losers: list[Contact],
|
|
point_map: dict[str, str],
|
|
) -> None:
|
|
loser_ids = [item.id for item in losers]
|
|
entries = session.query(AddressListEntry).filter(
|
|
AddressListEntry.contact_id.in_(loser_ids)
|
|
).all()
|
|
for entry in entries:
|
|
entry.contact_id = winner.id
|
|
if entry.contact_email_id:
|
|
entry.contact_email_id = point_map.get(entry.contact_email_id)
|
|
if entry.contact_postal_address_id:
|
|
entry.contact_postal_address_id = point_map.get(entry.contact_postal_address_id)
|
|
entry.target_kind = _address_list_entry_kind(
|
|
next((item for item in winner.emails if item.id == entry.contact_email_id), None),
|
|
next((item for item in winner.postal_addresses if item.id == entry.contact_postal_address_id), None),
|
|
)
|
|
|
|
|
|
def _contact_evidence_payload(contact: Contact) -> dict[str, Any]:
|
|
return {
|
|
"id": contact.id,
|
|
"tenant_id": contact.tenant_id,
|
|
"address_book_id": contact.address_book_id,
|
|
"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 []),
|
|
"source_kind": contact.source_kind,
|
|
"source_ref": contact.source_ref,
|
|
"source_payload_kind": contact.source_payload_kind,
|
|
"source_payload_raw": contact.source_payload_raw,
|
|
"source_revision": contact.source_revision,
|
|
"provenance": copy.deepcopy(contact.provenance or {}),
|
|
"metadata": copy.deepcopy(contact.metadata_ or {}),
|
|
"created_by_account_id": contact.created_by_account_id,
|
|
"updated_by_account_id": contact.updated_by_account_id,
|
|
"deleted_at": _evidence_datetime(contact.deleted_at),
|
|
"emails": [
|
|
{
|
|
"id": item.id,
|
|
"label": item.label,
|
|
"email": item.email,
|
|
"original_email": item.original_email,
|
|
"normalized_email": item.normalized_email,
|
|
"provenance": copy.deepcopy(item.provenance or {}),
|
|
"is_primary": item.is_primary,
|
|
"order_index": item.order_index,
|
|
}
|
|
for item in contact.emails
|
|
],
|
|
"phones": [
|
|
{
|
|
"id": item.id,
|
|
"label": item.label,
|
|
"phone": item.phone,
|
|
"original_phone": item.original_phone,
|
|
"normalized_phone": item.normalized_phone,
|
|
"provenance": copy.deepcopy(item.provenance or {}),
|
|
"is_primary": item.is_primary,
|
|
"order_index": item.order_index,
|
|
}
|
|
for item in contact.phones
|
|
],
|
|
"postal_addresses": [
|
|
{
|
|
"id": item.id,
|
|
"label": item.label,
|
|
"street": item.street,
|
|
"postal_code": item.postal_code,
|
|
"locality": item.locality,
|
|
"region": item.region,
|
|
"country": item.country,
|
|
"original_value": copy.deepcopy(item.original_value or {}),
|
|
"normalized_value": copy.deepcopy(item.normalized_value or {}),
|
|
"provenance": copy.deepcopy(item.provenance or {}),
|
|
"is_primary": item.is_primary,
|
|
"order_index": item.order_index,
|
|
}
|
|
for item in contact.postal_addresses
|
|
],
|
|
"channel_rules": [
|
|
{
|
|
"id": item.id,
|
|
"channel": item.channel,
|
|
"purpose": item.purpose,
|
|
"contact_point_id": item.contact_point_id,
|
|
"decision": item.decision,
|
|
"legal_basis": item.legal_basis,
|
|
"evidence_ref": item.evidence_ref,
|
|
"reason": item.reason,
|
|
"preference_rank": item.preference_rank,
|
|
"locale": item.locale,
|
|
"effective_from": _evidence_datetime(item.effective_from),
|
|
"effective_until": _evidence_datetime(item.effective_until),
|
|
"metadata": copy.deepcopy(item.metadata_ or {}),
|
|
}
|
|
for item in contact.channel_rules
|
|
],
|
|
"quality_decisions": [
|
|
{
|
|
"id": item.id,
|
|
"channel": item.channel,
|
|
"contact_point_id": item.contact_point_id,
|
|
"state": item.state,
|
|
"reason_code": item.reason_code,
|
|
"reason": item.reason,
|
|
"evidence_ref": item.evidence_ref,
|
|
"effective_from": _evidence_datetime(item.effective_from),
|
|
"effective_until": _evidence_datetime(item.effective_until),
|
|
"metadata": copy.deepcopy(item.metadata_ or {}),
|
|
}
|
|
for item in contact.quality_decisions
|
|
],
|
|
}
|
|
|
|
|
|
def _address_list_entry_evidence(entry: AddressListEntry) -> dict[str, Any]:
|
|
return {
|
|
"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,
|
|
}
|
|
|
|
|
|
def _merge_state_payload(session: Session, contacts: list[Contact]) -> dict[str, Any]:
|
|
ids = [item.id for item in contacts]
|
|
session.flush()
|
|
entries = session.query(AddressListEntry).filter(AddressListEntry.contact_id.in_(ids)).all()
|
|
return {
|
|
"contacts": [
|
|
_contact_evidence_payload(item)
|
|
for item in sorted(contacts, key=lambda row: row.id)
|
|
],
|
|
"address_list_entries": [
|
|
_address_list_entry_evidence(item)
|
|
for item in sorted(entries, key=lambda row: row.id)
|
|
],
|
|
}
|
|
|
|
|
|
def _evidence_hash(payload: dict[str, Any]) -> str:
|
|
return hashlib.sha256(
|
|
json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
|
).hexdigest()
|
|
|
|
|
|
def _evidence_datetime(value: datetime | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
return _aware_time(value).astimezone(UTC).isoformat()
|
|
|
|
|
|
def _visible_merge_record(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
merge_id: str,
|
|
) -> ContactMergeRecord:
|
|
record = session.get(ContactMergeRecord, merge_id)
|
|
if record is None:
|
|
raise AddressBookError("Contact merge record not found.")
|
|
get_visible_address_book(session, principal, record.address_book_id, include_deleted=True)
|
|
return record
|
|
|
|
|
|
def _restore_contact_evidence(
|
|
session: Session,
|
|
contact: Contact,
|
|
snapshot: dict[str, Any],
|
|
*,
|
|
merge_record_id: str,
|
|
) -> None:
|
|
for field_name in (
|
|
"display_name",
|
|
"given_name",
|
|
"family_name",
|
|
"organization",
|
|
"role_title",
|
|
"note",
|
|
"source_kind",
|
|
"source_ref",
|
|
"source_payload_kind",
|
|
"source_payload_raw",
|
|
"source_revision",
|
|
"created_by_account_id",
|
|
"updated_by_account_id",
|
|
):
|
|
setattr(contact, field_name, snapshot.get(field_name))
|
|
contact.tags = list(snapshot.get("tags") or [])
|
|
contact.provenance = copy.deepcopy(snapshot.get("provenance") or {})
|
|
contact.metadata_ = copy.deepcopy(snapshot.get("metadata") or {})
|
|
contact.deleted_at = (
|
|
datetime.fromisoformat(str(snapshot["deleted_at"]))
|
|
if snapshot.get("deleted_at")
|
|
else None
|
|
)
|
|
for rule in list(contact.channel_rules):
|
|
if (rule.metadata_ or {}).get("merge_record_id") == merge_record_id:
|
|
session.delete(rule)
|
|
for decision in list(contact.quality_decisions):
|
|
if (decision.metadata_ or {}).get("merge_record_id") == merge_record_id:
|
|
session.delete(decision)
|
|
contact.emails.clear()
|
|
contact.phones.clear()
|
|
contact.postal_addresses.clear()
|
|
session.flush()
|
|
contact.emails.extend(
|
|
ContactEmail(
|
|
id=str(item["id"]),
|
|
label=item.get("label"),
|
|
email=str(item.get("email") or ""),
|
|
original_email=str(item.get("original_email") or item.get("email") or ""),
|
|
normalized_email=str(item.get("normalized_email") or ""),
|
|
provenance=copy.deepcopy(item.get("provenance") or {}),
|
|
is_primary=bool(item.get("is_primary")),
|
|
order_index=int(item.get("order_index") or 0),
|
|
)
|
|
for item in snapshot.get("emails", [])
|
|
if isinstance(item, dict) and item.get("id")
|
|
)
|
|
contact.phones.extend(
|
|
ContactPhone(
|
|
id=str(item["id"]),
|
|
label=item.get("label"),
|
|
phone=str(item.get("phone") or ""),
|
|
original_phone=str(item.get("original_phone") or item.get("phone") or ""),
|
|
normalized_phone=str(item.get("normalized_phone") or ""),
|
|
provenance=copy.deepcopy(item.get("provenance") or {}),
|
|
is_primary=bool(item.get("is_primary")),
|
|
order_index=int(item.get("order_index") or 0),
|
|
)
|
|
for item in snapshot.get("phones", [])
|
|
if isinstance(item, dict) and item.get("id")
|
|
)
|
|
contact.postal_addresses.extend(
|
|
ContactPostalAddress(
|
|
id=str(item["id"]),
|
|
label=item.get("label"),
|
|
street=item.get("street"),
|
|
postal_code=item.get("postal_code"),
|
|
locality=item.get("locality"),
|
|
region=item.get("region"),
|
|
country=item.get("country"),
|
|
original_value=copy.deepcopy(item.get("original_value") or {}),
|
|
normalized_value=copy.deepcopy(item.get("normalized_value") or {}),
|
|
provenance=copy.deepcopy(item.get("provenance") or {}),
|
|
is_primary=bool(item.get("is_primary")),
|
|
order_index=int(item.get("order_index") or 0),
|
|
)
|
|
for item in snapshot.get("postal_addresses", [])
|
|
if isinstance(item, dict) and item.get("id")
|
|
)
|
|
|
|
|
|
def _aware_time(value: datetime) -> datetime:
|
|
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
|
|
|
|
|
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])
|