intermittent commit
This commit is contained in:
2
src/govoplan_addresses/backend/__init__.py
Normal file
2
src/govoplan_addresses/backend/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Backend integration for the GovOPlaN addresses module."""
|
||||
|
||||
629
src/govoplan_addresses/backend/capabilities.py
Normal file
629
src/govoplan_addresses/backend/capabilities.py
Normal file
@@ -0,0 +1,629 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.db.base import utcnow
|
||||
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone
|
||||
from govoplan_addresses.backend.schemas import ContactCreateRequest
|
||||
from govoplan_addresses.backend.service import (
|
||||
AddressBookError,
|
||||
create_contact,
|
||||
get_visible_address_book,
|
||||
get_visible_address_list,
|
||||
list_address_books,
|
||||
list_address_list_entries,
|
||||
list_address_lists,
|
||||
list_contacts,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
||||
CAPABILITY_ADDRESSES_CONTACT_WRITER = "addresses.contact_writer"
|
||||
|
||||
CONTACT_WRITE_OPERATIONS = ("create_contact", "update_contact", "delete_contact")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AddressLookupCandidate:
|
||||
contact_id: str
|
||||
address_book_id: str
|
||||
display_name: str
|
||||
email: str | None
|
||||
email_label: str | None = None
|
||||
organization: str | None = None
|
||||
role_title: str | None = None
|
||||
tags: tuple[str, ...] = ()
|
||||
source_kind: str = "local"
|
||||
source_ref: str | None = None
|
||||
source_revision: str | None = None
|
||||
provenance: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecipientSnapshotItem:
|
||||
contact_id: str
|
||||
display_name: str
|
||||
email: str
|
||||
email_label: str | None = None
|
||||
fields: dict[str, Any] = field(default_factory=dict)
|
||||
provenance: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecipientSourceRef:
|
||||
source_id: str
|
||||
source_label: str
|
||||
source_kind: str
|
||||
source_revision: str
|
||||
recipient_count: int = 0
|
||||
provenance: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecipientSourceSnapshot:
|
||||
source_id: str
|
||||
source_label: str
|
||||
source_kind: str
|
||||
source_revision: str
|
||||
generated_at: str
|
||||
recipients: tuple[RecipientSnapshotItem, ...]
|
||||
provenance: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AddressBookWriteDecision:
|
||||
address_book_id: str
|
||||
address_book_label: str | None
|
||||
operation: str
|
||||
allowed: bool
|
||||
reason: str
|
||||
message: str
|
||||
scope_type: str | None = None
|
||||
scope_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
source_kind: str | None = None
|
||||
read_only: bool = False
|
||||
required_scopes: tuple[str, ...] = ()
|
||||
provenance: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactWriteResult:
|
||||
contact_id: str
|
||||
address_book_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
source_kind: str = "local"
|
||||
provenance: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class AddressWriterError(ValueError):
|
||||
def __init__(self, decision: AddressBookWriteDecision):
|
||||
super().__init__(decision.message)
|
||||
self.decision = decision
|
||||
|
||||
|
||||
class AddressesLookupCapability:
|
||||
def lookup(
|
||||
self,
|
||||
session: Any,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
address_book_id: str | None = None,
|
||||
) -> tuple[AddressLookupCandidate, ...]:
|
||||
normalized_limit = max(1, min(limit, 100))
|
||||
contacts = list_contacts(
|
||||
session,
|
||||
principal,
|
||||
address_book_id=address_book_id,
|
||||
query=query,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
candidates: list[AddressLookupCandidate] = []
|
||||
for contact in contacts:
|
||||
email_rows: list[ContactEmail | None] = list(contact.emails) or [None]
|
||||
for email in email_rows:
|
||||
candidates.append(_lookup_candidate(contact, email))
|
||||
if len(candidates) >= normalized_limit:
|
||||
return tuple(candidates)
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
class AddressesContactWriterCapability:
|
||||
def list_write_targets(
|
||||
self,
|
||||
session: Any,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
operation: str = "create_contact",
|
||||
) -> tuple[AddressBookWriteDecision, ...]:
|
||||
books = list_address_books(session, principal)
|
||||
return tuple(_address_book_write_decision(principal, book, operation=operation) for book in books)
|
||||
|
||||
def can_write_to_address_book(
|
||||
self,
|
||||
session: Any,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
address_book_id: str,
|
||||
operation: str = "create_contact",
|
||||
) -> AddressBookWriteDecision:
|
||||
try:
|
||||
book = get_visible_address_book(session, principal, address_book_id, include_deleted=True)
|
||||
except AddressBookError:
|
||||
return _blocked_write_decision(
|
||||
address_book_id=address_book_id,
|
||||
operation=operation,
|
||||
reason="not_visible",
|
||||
message="Address book is not visible to the current principal.",
|
||||
)
|
||||
return _address_book_write_decision(principal, book, operation=operation)
|
||||
|
||||
def create_contact(
|
||||
self,
|
||||
session: Any,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
address_book_id: str,
|
||||
payload: ContactCreateRequest | dict[str, Any],
|
||||
provenance: dict[str, Any] | None = None,
|
||||
) -> ContactWriteResult:
|
||||
decision = self.can_write_to_address_book(session, principal, address_book_id=address_book_id, operation="create_contact")
|
||||
if not decision.allowed:
|
||||
raise AddressWriterError(decision)
|
||||
request = payload if isinstance(payload, ContactCreateRequest) else ContactCreateRequest.model_validate(payload)
|
||||
if provenance:
|
||||
request = request.model_copy(update={"provenance": {**request.provenance, **provenance}})
|
||||
contact = create_contact(session, principal, address_book_id, request)
|
||||
return _contact_write_result(contact)
|
||||
|
||||
|
||||
class AddressesRecipientSourceCapability:
|
||||
def list_sources(
|
||||
self,
|
||||
session: Any,
|
||||
principal: ApiPrincipal,
|
||||
) -> tuple[RecipientSourceRef, ...]:
|
||||
from govoplan_addresses.backend.service import address_book_contact_counts, address_list_entry_counts
|
||||
|
||||
books = list_address_books(session, principal)
|
||||
counts = address_book_contact_counts(session, [book.id for book in books])
|
||||
contact_updated_at = _address_book_contact_updated_at(session, [book.id for book in books])
|
||||
book_sources = tuple(
|
||||
_recipient_source_ref(
|
||||
book,
|
||||
recipient_count=counts.get(book.id, 0),
|
||||
latest_contact_updated_at=contact_updated_at.get(book.id),
|
||||
)
|
||||
for book in books
|
||||
)
|
||||
address_lists = list_address_lists(session, principal)
|
||||
address_list_counts = address_list_entry_counts(session, [address_list.id for address_list in address_lists])
|
||||
address_list_updated_at = _address_list_updated_at(session, [address_list.id for address_list in address_lists])
|
||||
list_sources = tuple(
|
||||
_address_list_source_ref(
|
||||
address_list,
|
||||
recipient_count=address_list_counts.get(address_list.id, 0),
|
||||
latest_entry_updated_at=address_list_updated_at.get(address_list.id),
|
||||
)
|
||||
for address_list in address_lists
|
||||
)
|
||||
return book_sources + list_sources
|
||||
|
||||
def snapshot_address_book(
|
||||
self,
|
||||
session: Any,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
address_book_id: str,
|
||||
) -> RecipientSourceSnapshot:
|
||||
book = get_visible_address_book(session, principal, address_book_id)
|
||||
contacts = _active_address_book_contacts(session, book.id)
|
||||
recipients: list[RecipientSnapshotItem] = []
|
||||
for contact in contacts:
|
||||
for email in contact.emails:
|
||||
recipients.append(_recipient_snapshot_item(contact, email))
|
||||
revision = _recipient_source_revision(book, _address_book_contact_updated_at(session, [book.id]).get(book.id))
|
||||
return RecipientSourceSnapshot(
|
||||
source_id=f"addresses:address_book:{book.id}",
|
||||
source_label=book.name,
|
||||
source_kind=book.source_kind,
|
||||
source_revision=revision,
|
||||
generated_at=utcnow().isoformat(),
|
||||
recipients=tuple(recipients),
|
||||
provenance={
|
||||
"module": "addresses",
|
||||
"address_book_id": book.id,
|
||||
"scope_type": book.scope_type,
|
||||
"scope_id": book.scope_id,
|
||||
"tenant_id": book.tenant_id,
|
||||
},
|
||||
)
|
||||
|
||||
def snapshot_address_list(
|
||||
self,
|
||||
session: Any,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
address_list_id: str,
|
||||
) -> RecipientSourceSnapshot:
|
||||
address_list = get_visible_address_list(session, principal, address_list_id)
|
||||
entries = list_address_list_entries(session, principal, address_list.id)
|
||||
recipients: list[RecipientSnapshotItem] = []
|
||||
for entry in entries:
|
||||
email = _entry_email(entry)
|
||||
if email is None:
|
||||
continue
|
||||
recipients.append(_recipient_snapshot_item(entry.contact, email, address_list=address_list, address_list_entry=entry))
|
||||
revision = _address_list_source_revision(address_list, _address_list_updated_at(session, [address_list.id]).get(address_list.id))
|
||||
return RecipientSourceSnapshot(
|
||||
source_id=f"addresses:address_list:{address_list.id}",
|
||||
source_label=address_list.name,
|
||||
source_kind="address_list",
|
||||
source_revision=revision,
|
||||
generated_at=utcnow().isoformat(),
|
||||
recipients=tuple(recipients),
|
||||
provenance={
|
||||
"module": "addresses",
|
||||
"address_book_id": address_list.address_book_id,
|
||||
"address_list_id": address_list.id,
|
||||
"scope_type": address_list.address_book.scope_type,
|
||||
"scope_id": address_list.address_book.scope_id,
|
||||
"tenant_id": address_list.tenant_id,
|
||||
},
|
||||
)
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
session: Any,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
source_id: str,
|
||||
) -> RecipientSourceSnapshot:
|
||||
book_prefix = "addresses:address_book:"
|
||||
if source_id.startswith(book_prefix):
|
||||
return self.snapshot_address_book(session, principal, address_book_id=source_id.removeprefix(book_prefix))
|
||||
list_prefix = "addresses:address_list:"
|
||||
if source_id.startswith(list_prefix):
|
||||
return self.snapshot_address_list(session, principal, address_list_id=source_id.removeprefix(list_prefix))
|
||||
raise ValueError(f"Unsupported addresses recipient source id: {source_id}")
|
||||
|
||||
|
||||
def lookup_capability(_context: Any) -> AddressesLookupCapability:
|
||||
return AddressesLookupCapability()
|
||||
|
||||
|
||||
def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability:
|
||||
return AddressesRecipientSourceCapability()
|
||||
|
||||
|
||||
def contact_writer_capability(_context: Any) -> AddressesContactWriterCapability:
|
||||
return AddressesContactWriterCapability()
|
||||
|
||||
|
||||
def _operation_required_scopes(operation: str) -> tuple[str, ...] | None:
|
||||
if operation in ("create_contact", "update_contact"):
|
||||
return ("addresses:address_book:read", "addresses:contact:write")
|
||||
if operation == "delete_contact":
|
||||
return ("addresses:address_book:read", "addresses:contact:delete")
|
||||
return None
|
||||
|
||||
|
||||
def _address_book_write_decision(principal: ApiPrincipal, book: AddressBook, *, operation: str) -> AddressBookWriteDecision:
|
||||
required_scopes = _operation_required_scopes(operation)
|
||||
if required_scopes is None:
|
||||
return _blocked_write_decision(
|
||||
address_book_id=book.id,
|
||||
operation=operation,
|
||||
reason="unsupported_operation",
|
||||
message=f"Unsupported address-book write operation: {operation}",
|
||||
book=book,
|
||||
)
|
||||
missing_scopes = tuple(scope for scope in required_scopes if not principal.has(scope))
|
||||
if missing_scopes:
|
||||
return _blocked_write_decision(
|
||||
address_book_id=book.id,
|
||||
operation=operation,
|
||||
reason="missing_scope",
|
||||
message=f"Missing scope: {missing_scopes[0]}",
|
||||
book=book,
|
||||
required_scopes=required_scopes,
|
||||
)
|
||||
if book.deleted_at is not None:
|
||||
return _blocked_write_decision(
|
||||
address_book_id=book.id,
|
||||
operation=operation,
|
||||
reason="address_book_deleted",
|
||||
message="Address book is deleted.",
|
||||
book=book,
|
||||
required_scopes=required_scopes,
|
||||
)
|
||||
if book.read_only:
|
||||
return _blocked_write_decision(
|
||||
address_book_id=book.id,
|
||||
operation=operation,
|
||||
reason="address_book_read_only",
|
||||
message="Address book is read-only.",
|
||||
book=book,
|
||||
required_scopes=required_scopes,
|
||||
)
|
||||
return AddressBookWriteDecision(
|
||||
address_book_id=book.id,
|
||||
address_book_label=book.name,
|
||||
operation=operation,
|
||||
allowed=True,
|
||||
reason="allowed",
|
||||
message="Address book accepts this write operation.",
|
||||
scope_type=book.scope_type,
|
||||
scope_id=book.scope_id,
|
||||
tenant_id=book.tenant_id,
|
||||
source_kind=book.source_kind,
|
||||
read_only=book.read_only,
|
||||
required_scopes=required_scopes,
|
||||
provenance=_address_book_provenance(book),
|
||||
)
|
||||
|
||||
|
||||
def _blocked_write_decision(
|
||||
*,
|
||||
address_book_id: str,
|
||||
operation: str,
|
||||
reason: str,
|
||||
message: str,
|
||||
book: AddressBook | None = None,
|
||||
required_scopes: tuple[str, ...] = (),
|
||||
) -> AddressBookWriteDecision:
|
||||
return AddressBookWriteDecision(
|
||||
address_book_id=address_book_id,
|
||||
address_book_label=book.name if book else None,
|
||||
operation=operation,
|
||||
allowed=False,
|
||||
reason=reason,
|
||||
message=message,
|
||||
scope_type=book.scope_type if book else None,
|
||||
scope_id=book.scope_id if book else None,
|
||||
tenant_id=book.tenant_id if book else None,
|
||||
source_kind=book.source_kind if book else None,
|
||||
read_only=book.read_only if book else False,
|
||||
required_scopes=required_scopes,
|
||||
provenance=_address_book_provenance(book) if book else {"module": "addresses", "address_book_id": address_book_id},
|
||||
)
|
||||
|
||||
|
||||
def _lookup_candidate(contact: Contact, email: ContactEmail | None) -> AddressLookupCandidate:
|
||||
return AddressLookupCandidate(
|
||||
contact_id=contact.id,
|
||||
address_book_id=contact.address_book_id,
|
||||
display_name=contact.display_name,
|
||||
email=email.email if email is not None else None,
|
||||
email_label=email.label if email is not None else None,
|
||||
organization=contact.organization,
|
||||
role_title=contact.role_title,
|
||||
tags=tuple(contact.tags or ()),
|
||||
source_kind=contact.source_kind,
|
||||
source_ref=contact.source_ref,
|
||||
source_revision=contact.source_revision,
|
||||
provenance=_contact_provenance(contact),
|
||||
)
|
||||
|
||||
|
||||
def _recipient_snapshot_item(
|
||||
contact: Contact,
|
||||
email: ContactEmail,
|
||||
*,
|
||||
address_list: AddressList | None = None,
|
||||
address_list_entry: AddressListEntry | None = None,
|
||||
) -> RecipientSnapshotItem:
|
||||
primary_phone = next((phone.phone for phone in contact.phones if phone.is_primary), contact.phones[0].phone if contact.phones else None)
|
||||
provenance = {
|
||||
**_contact_provenance(contact),
|
||||
"email_id": email.id,
|
||||
"email_label": email.label,
|
||||
"email_primary": email.is_primary,
|
||||
}
|
||||
if address_list is not None and address_list_entry is not None:
|
||||
provenance.update(
|
||||
{
|
||||
"address_list_id": address_list.id,
|
||||
"address_list_entry_id": address_list_entry.id,
|
||||
"address_list_name": address_list.name,
|
||||
"address_list_entry_kind": address_list_entry.target_kind,
|
||||
}
|
||||
)
|
||||
return RecipientSnapshotItem(
|
||||
contact_id=contact.id,
|
||||
display_name=contact.display_name,
|
||||
email=email.email,
|
||||
email_label=email.label,
|
||||
fields={
|
||||
"given_name": contact.given_name,
|
||||
"family_name": contact.family_name,
|
||||
"organization": contact.organization,
|
||||
"role_title": contact.role_title,
|
||||
"phone": primary_phone,
|
||||
"tags": list(contact.tags or ()),
|
||||
},
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
def _contact_provenance(contact: Contact) -> dict[str, Any]:
|
||||
return {
|
||||
"module": "addresses",
|
||||
"contact_id": contact.id,
|
||||
"address_book_id": contact.address_book_id,
|
||||
"source_kind": contact.source_kind,
|
||||
"source_ref": contact.source_ref,
|
||||
"source_revision": contact.source_revision,
|
||||
}
|
||||
|
||||
|
||||
def _address_book_provenance(book: AddressBook) -> dict[str, Any]:
|
||||
return {
|
||||
"module": "addresses",
|
||||
"address_book_id": book.id,
|
||||
"scope_type": book.scope_type,
|
||||
"scope_id": book.scope_id,
|
||||
"tenant_id": book.tenant_id,
|
||||
"source_kind": book.source_kind,
|
||||
"source_ref": book.source_ref,
|
||||
}
|
||||
|
||||
|
||||
def _entry_email(entry: AddressListEntry) -> ContactEmail | None:
|
||||
if entry.contact_postal_address_id is not None:
|
||||
return None
|
||||
if entry.contact_email is not None:
|
||||
return entry.contact_email
|
||||
return next((email for email in entry.contact.emails if email.is_primary), entry.contact.emails[0] if entry.contact.emails else None)
|
||||
|
||||
|
||||
def _contact_write_result(contact: Contact) -> ContactWriteResult:
|
||||
primary_email = next((email.email for email in contact.emails if email.is_primary), contact.emails[0].email if contact.emails else None)
|
||||
return ContactWriteResult(
|
||||
contact_id=contact.id,
|
||||
address_book_id=contact.address_book_id,
|
||||
display_name=contact.display_name,
|
||||
email=primary_email,
|
||||
source_kind=contact.source_kind,
|
||||
provenance={
|
||||
**_contact_provenance(contact),
|
||||
"contact_provenance": contact.provenance,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _recipient_source_ref(book: AddressBook, *, recipient_count: int, latest_contact_updated_at: datetime | None = None) -> RecipientSourceRef:
|
||||
return RecipientSourceRef(
|
||||
source_id=f"addresses:address_book:{book.id}",
|
||||
source_label=book.name,
|
||||
source_kind=book.source_kind,
|
||||
source_revision=_recipient_source_revision(book, latest_contact_updated_at),
|
||||
recipient_count=recipient_count,
|
||||
provenance={
|
||||
"module": "addresses",
|
||||
"address_book_id": book.id,
|
||||
"scope_type": book.scope_type,
|
||||
"scope_id": book.scope_id,
|
||||
"tenant_id": book.tenant_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _address_list_source_ref(address_list: AddressList, *, recipient_count: int, latest_entry_updated_at: datetime | None = None) -> RecipientSourceRef:
|
||||
return RecipientSourceRef(
|
||||
source_id=f"addresses:address_list:{address_list.id}",
|
||||
source_label=address_list.name,
|
||||
source_kind="address_list",
|
||||
source_revision=_address_list_source_revision(address_list, latest_entry_updated_at),
|
||||
recipient_count=recipient_count,
|
||||
provenance={
|
||||
"module": "addresses",
|
||||
"address_book_id": address_list.address_book_id,
|
||||
"address_list_id": address_list.id,
|
||||
"scope_type": address_list.address_book.scope_type,
|
||||
"scope_id": address_list.address_book.scope_id,
|
||||
"tenant_id": address_list.tenant_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _active_address_book_contacts(session: Any, address_book_id: str) -> list[Contact]:
|
||||
return (
|
||||
session.query(Contact)
|
||||
.filter(Contact.address_book_id == address_book_id, Contact.deleted_at.is_(None))
|
||||
.order_by(Contact.display_name.asc(), Contact.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _address_book_contact_updated_at(session: Any, address_book_ids: list[str]) -> dict[str, datetime]:
|
||||
if not address_book_ids:
|
||||
return {}
|
||||
revisions: dict[str, datetime] = {}
|
||||
contact_rows = (
|
||||
session.query(Contact.address_book_id, func.max(Contact.updated_at))
|
||||
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_(None))
|
||||
.group_by(Contact.address_book_id)
|
||||
.all()
|
||||
)
|
||||
deletion_rows = (
|
||||
session.query(Contact.address_book_id, func.max(Contact.deleted_at))
|
||||
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_not(None))
|
||||
.group_by(Contact.address_book_id)
|
||||
.all()
|
||||
)
|
||||
email_rows = (
|
||||
session.query(Contact.address_book_id, func.max(ContactEmail.updated_at))
|
||||
.join(Contact, ContactEmail.contact_id == Contact.id)
|
||||
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_(None))
|
||||
.group_by(Contact.address_book_id)
|
||||
.all()
|
||||
)
|
||||
phone_rows = (
|
||||
session.query(Contact.address_book_id, func.max(ContactPhone.updated_at))
|
||||
.join(Contact, ContactPhone.contact_id == Contact.id)
|
||||
.filter(Contact.address_book_id.in_(address_book_ids), Contact.deleted_at.is_(None))
|
||||
.group_by(Contact.address_book_id)
|
||||
.all()
|
||||
)
|
||||
for address_book_id, updated_at in [*contact_rows, *deletion_rows, *email_rows, *phone_rows]:
|
||||
if updated_at is None:
|
||||
continue
|
||||
key = str(address_book_id)
|
||||
revisions[key] = max(revisions.get(key, updated_at), updated_at)
|
||||
return revisions
|
||||
|
||||
|
||||
def _address_list_updated_at(session: Any, address_list_ids: list[str]) -> dict[str, datetime]:
|
||||
if not address_list_ids:
|
||||
return {}
|
||||
revisions: dict[str, datetime] = {}
|
||||
entry_rows = (
|
||||
session.query(AddressListEntry.address_list_id, func.max(AddressListEntry.updated_at))
|
||||
.filter(AddressListEntry.address_list_id.in_(address_list_ids))
|
||||
.group_by(AddressListEntry.address_list_id)
|
||||
.all()
|
||||
)
|
||||
contact_rows = (
|
||||
session.query(AddressListEntry.address_list_id, func.max(Contact.updated_at))
|
||||
.join(Contact, AddressListEntry.contact_id == Contact.id)
|
||||
.filter(AddressListEntry.address_list_id.in_(address_list_ids), Contact.deleted_at.is_(None))
|
||||
.group_by(AddressListEntry.address_list_id)
|
||||
.all()
|
||||
)
|
||||
email_rows = (
|
||||
session.query(AddressListEntry.address_list_id, func.max(ContactEmail.updated_at))
|
||||
.join(ContactEmail, AddressListEntry.contact_email_id == ContactEmail.id)
|
||||
.filter(AddressListEntry.address_list_id.in_(address_list_ids))
|
||||
.group_by(AddressListEntry.address_list_id)
|
||||
.all()
|
||||
)
|
||||
for address_list_id, updated_at in [*entry_rows, *contact_rows, *email_rows]:
|
||||
if updated_at is None:
|
||||
continue
|
||||
key = str(address_list_id)
|
||||
revisions[key] = max(revisions.get(key, updated_at), updated_at)
|
||||
return revisions
|
||||
|
||||
|
||||
def _recipient_source_revision(book: AddressBook, latest_contact_updated_at: datetime | None = None) -> str:
|
||||
stamps: list[datetime] = [book.updated_at]
|
||||
if latest_contact_updated_at is not None:
|
||||
stamps.append(latest_contact_updated_at)
|
||||
return max(stamps).isoformat()
|
||||
|
||||
|
||||
def _address_list_source_revision(address_list: AddressList, latest_entry_updated_at: datetime | None = None) -> str:
|
||||
stamps: list[datetime] = [address_list.updated_at]
|
||||
if latest_entry_updated_at is not None:
|
||||
stamps.append(latest_entry_updated_at)
|
||||
return max(stamps).isoformat()
|
||||
488
src/govoplan_addresses/backend/carddav.py
Normal file
488
src/govoplan_addresses/backend/carddav.py
Normal file
@@ -0,0 +1,488 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
from defusedxml import ElementTree as SafeElementTree
|
||||
|
||||
|
||||
class AddressCardDAVError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class AddressCardDAVSyncUnsupported(AddressCardDAVError):
|
||||
pass
|
||||
|
||||
|
||||
class AddressCardDAVNotFound(AddressCardDAVError):
|
||||
pass
|
||||
|
||||
|
||||
class AddressCardDAVPreconditionFailed(AddressCardDAVError):
|
||||
pass
|
||||
|
||||
|
||||
class AddressCardDAVTransport(Protocol):
|
||||
def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AddressCardDAVObject:
|
||||
href: str
|
||||
etag: str | None = None
|
||||
address_data: str | None = None
|
||||
deleted: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AddressCardDAVReportResult:
|
||||
objects: list[AddressCardDAVObject] = field(default_factory=list)
|
||||
sync_token: str | None = None
|
||||
ctag: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AddressCardDAVAddressBook:
|
||||
collection_url: str
|
||||
href: str
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
ctag: str | None = None
|
||||
sync_token: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AddressCardDAVWriteResult:
|
||||
href: str
|
||||
etag: str | None = None
|
||||
status: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _DiscoveryResponse:
|
||||
href: str
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
ctag: str | None = None
|
||||
sync_token: str | None = None
|
||||
is_addressbook: bool = False
|
||||
principal_hrefs: tuple[str, ...] = ()
|
||||
addressbook_home_set_hrefs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _DiscoveryDraft:
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
ctag: str | None = None
|
||||
sync_token: str | None = None
|
||||
is_addressbook: bool = False
|
||||
principal_hrefs: list[str] = field(default_factory=list)
|
||||
addressbook_home_set_hrefs: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class AddressCardDAVClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
collection_url: str,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
bearer_token: str | None = None,
|
||||
timeout: int = 30,
|
||||
transport: AddressCardDAVTransport | None = None,
|
||||
) -> None:
|
||||
self.collection_url = ensure_collection_url(collection_url)
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.bearer_token = bearer_token
|
||||
self.timeout = timeout
|
||||
self.transport = transport or urllib_transport
|
||||
|
||||
def propfind_collection(self) -> AddressCardDAVReportResult:
|
||||
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:CS="http://calendarserver.org/ns/">
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
<D:sync-token/>
|
||||
<CS:getctag/>
|
||||
</D:prop>
|
||||
</D:propfind>"""
|
||||
payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207})
|
||||
return parse_multistatus(payload)
|
||||
|
||||
def discover_addressbooks(self) -> list[AddressCardDAVAddressBook]:
|
||||
start_url = self.collection_url
|
||||
addressbooks: dict[str, AddressCardDAVAddressBook] = {}
|
||||
home_urls: list[str] = []
|
||||
principal_urls: list[str] = []
|
||||
visited_urls: set[tuple[str, str]] = set()
|
||||
errors: list[str] = []
|
||||
|
||||
def add_home_href(base_url: str, href: str) -> None:
|
||||
url = ensure_collection_url(absolute_dav_url(base_url, href))
|
||||
if url not in home_urls:
|
||||
home_urls.append(url)
|
||||
|
||||
def add_principal_href(base_url: str, href: str) -> None:
|
||||
url = ensure_collection_url(absolute_dav_url(base_url, href))
|
||||
if url not in principal_urls:
|
||||
principal_urls.append(url)
|
||||
|
||||
def add_addressbook(base_url: str, response: _DiscoveryResponse) -> None:
|
||||
if not response.is_addressbook:
|
||||
return
|
||||
url = ensure_collection_url(absolute_dav_url(base_url, response.href or base_url))
|
||||
addressbooks[url] = AddressCardDAVAddressBook(
|
||||
collection_url=url,
|
||||
href=response.href,
|
||||
display_name=response.display_name,
|
||||
description=response.description,
|
||||
ctag=response.ctag,
|
||||
sync_token=response.sync_token,
|
||||
)
|
||||
|
||||
def propfind(url: str, depth: str) -> list[_DiscoveryResponse]:
|
||||
key = (url, depth)
|
||||
if key in visited_urls:
|
||||
return []
|
||||
visited_urls.add(key)
|
||||
return self.propfind_discovery(url, depth=depth)
|
||||
|
||||
try:
|
||||
for response in propfind(start_url, "0"):
|
||||
add_addressbook(start_url, response)
|
||||
for href in response.addressbook_home_set_hrefs:
|
||||
add_home_href(start_url, href)
|
||||
for href in response.principal_hrefs:
|
||||
add_principal_href(start_url, href)
|
||||
except AddressCardDAVError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
for principal_url in principal_urls[:6]:
|
||||
try:
|
||||
for response in propfind(principal_url, "0"):
|
||||
for href in response.addressbook_home_set_hrefs:
|
||||
add_home_href(principal_url, href)
|
||||
except AddressCardDAVError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if not home_urls:
|
||||
home_urls.append(start_url)
|
||||
|
||||
for home_url in home_urls:
|
||||
try:
|
||||
for response in propfind(home_url, "1"):
|
||||
add_addressbook(home_url, response)
|
||||
except AddressCardDAVError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if not addressbooks and errors:
|
||||
raise AddressCardDAVError(f"CardDAV discovery did not find any address books: {errors[0]}")
|
||||
return sorted(addressbooks.values(), key=lambda item: ((item.display_name or item.collection_url).lower(), item.collection_url))
|
||||
|
||||
def propfind_discovery(self, url: str, *, depth: str) -> list[_DiscoveryResponse]:
|
||||
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/">
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
<D:current-user-principal/>
|
||||
<D:principal-URL/>
|
||||
<D:resourcetype/>
|
||||
<D:sync-token/>
|
||||
<CARD:addressbook-home-set/>
|
||||
<CARD:addressbook-description/>
|
||||
<CS:getctag/>
|
||||
</D:prop>
|
||||
</D:propfind>"""
|
||||
payload = self.request("PROPFIND", ensure_collection_url(url), body=body, depth=depth, expected={207})
|
||||
return parse_discovery_multistatus(payload)
|
||||
|
||||
def list_objects(self) -> AddressCardDAVReportResult:
|
||||
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<CARD:addressbook-query xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<CARD:address-data/>
|
||||
</D:prop>
|
||||
</CARD:addressbook-query>"""
|
||||
payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207})
|
||||
return parse_multistatus(payload)
|
||||
|
||||
def sync_collection(self, sync_token: str) -> AddressCardDAVReportResult:
|
||||
body = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:sync-collection xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
|
||||
<D:sync-token>{xml_escape(sync_token)}</D:sync-token>
|
||||
<D:sync-level>1</D:sync-level>
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<CARD:address-data/>
|
||||
</D:prop>
|
||||
</D:sync-collection>""".encode("utf-8")
|
||||
try:
|
||||
payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207})
|
||||
except AddressCardDAVError as exc:
|
||||
raise AddressCardDAVSyncUnsupported(str(exc)) from exc
|
||||
return parse_multistatus(payload)
|
||||
|
||||
def fetch_object(self, href: str) -> str:
|
||||
payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200})
|
||||
return payload.decode("utf-8")
|
||||
|
||||
def put_object(self, href: str, vcard: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> AddressCardDAVWriteResult:
|
||||
headers = {"Content-Type": "text/vcard; charset=utf-8"}
|
||||
if create:
|
||||
headers["If-None-Match"] = "*"
|
||||
elif etag and not overwrite:
|
||||
headers["If-Match"] = etag
|
||||
elif not overwrite:
|
||||
raise AddressCardDAVPreconditionFailed(f"PUT {href} requires an ETag or explicit overwrite.")
|
||||
status, response_headers, _payload = self.request_raw(
|
||||
"PUT",
|
||||
self.object_url(href),
|
||||
body=vcard.encode("utf-8"),
|
||||
depth=None,
|
||||
expected={200, 201, 204},
|
||||
extra_headers=headers,
|
||||
)
|
||||
return AddressCardDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
|
||||
|
||||
def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> AddressCardDAVWriteResult:
|
||||
headers: dict[str, str] = {}
|
||||
if etag and not overwrite:
|
||||
headers["If-Match"] = etag
|
||||
elif not overwrite:
|
||||
raise AddressCardDAVPreconditionFailed(f"DELETE {href} requires an ETag or explicit overwrite.")
|
||||
status, response_headers, _payload = self.request_raw(
|
||||
"DELETE",
|
||||
self.object_url(href),
|
||||
body=None,
|
||||
depth=None,
|
||||
expected={200, 202, 204, 404},
|
||||
extra_headers=headers,
|
||||
)
|
||||
return AddressCardDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
|
||||
|
||||
def object_url(self, href: str) -> str:
|
||||
return urllib.parse.urljoin(self.collection_url, href)
|
||||
|
||||
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
|
||||
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
|
||||
return payload
|
||||
|
||||
def request_raw(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
body: bytes | None,
|
||||
depth: str | None,
|
||||
expected: set[int],
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
) -> tuple[int, Mapping[str, str], bytes]:
|
||||
headers: dict[str, str] = {
|
||||
"Accept": "application/xml,text/vcard,*/*",
|
||||
"User-Agent": "govoplan-addresses-carddav/0.1",
|
||||
}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/xml; charset=utf-8"
|
||||
if depth is not None:
|
||||
headers["Depth"] = depth
|
||||
if self.bearer_token:
|
||||
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
||||
elif self.username and self.password:
|
||||
token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii")
|
||||
headers["Authorization"] = f"Basic {token}"
|
||||
if extra_headers:
|
||||
headers.update(dict(extra_headers))
|
||||
status, response_headers, payload = self.transport(method, url, headers, body, self.timeout)
|
||||
if status not in expected:
|
||||
if status == 412:
|
||||
raise AddressCardDAVPreconditionFailed(f"{method} {url} returned HTTP {status}")
|
||||
if status == 404:
|
||||
raise AddressCardDAVNotFound(f"{method} {url} returned HTTP {status}")
|
||||
raise AddressCardDAVError(f"{method} {url} returned HTTP {status}")
|
||||
return status, response_headers, payload
|
||||
|
||||
|
||||
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
||||
url = validate_http_url(url)
|
||||
try:
|
||||
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated CardDAV HTTP(S) URL. # nosec B310
|
||||
return response.status, dict(response.headers.items()), response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, dict(exc.headers.items()), exc.read()
|
||||
except urllib.error.URLError as exc:
|
||||
raise AddressCardDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
||||
except ValueError as exc:
|
||||
raise AddressCardDAVError(f"{method} {url} failed: {exc}") from exc
|
||||
|
||||
|
||||
def parse_multistatus(payload: bytes) -> AddressCardDAVReportResult:
|
||||
try:
|
||||
root = SafeElementTree.fromstring(payload)
|
||||
except SafeElementTree.ParseError as exc:
|
||||
raise AddressCardDAVError(f"Invalid CardDAV XML response: {exc}") from exc
|
||||
objects: list[AddressCardDAVObject] = []
|
||||
sync_token = first_child_text(root, "sync-token")
|
||||
ctag = first_child_text(root, "getctag")
|
||||
for response in child_elements(root, "response"):
|
||||
href = first_child_text(response, "href")
|
||||
if not href:
|
||||
continue
|
||||
deleted = False
|
||||
etag = None
|
||||
address_data = None
|
||||
for propstat in child_elements(response, "propstat"):
|
||||
status = first_child_text(propstat, "status") or ""
|
||||
prop = first_child(propstat, "prop")
|
||||
if prop is None:
|
||||
continue
|
||||
if " 404 " in status or status.endswith(" 404"):
|
||||
deleted = True
|
||||
continue
|
||||
if " 200 " not in status and not status.endswith(" 200"):
|
||||
continue
|
||||
etag = first_child_text(prop, "getetag") or etag
|
||||
address_data = first_child_text(prop, "address-data") or address_data
|
||||
sync_token = first_child_text(prop, "sync-token") or sync_token
|
||||
ctag = first_child_text(prop, "getctag") or ctag
|
||||
objects.append(AddressCardDAVObject(href=href, etag=strip_weak_etag(etag), address_data=address_data, deleted=deleted))
|
||||
return AddressCardDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag)
|
||||
|
||||
|
||||
def parse_discovery_multistatus(payload: bytes) -> list[_DiscoveryResponse]:
|
||||
try:
|
||||
root = SafeElementTree.fromstring(payload)
|
||||
except SafeElementTree.ParseError as exc:
|
||||
raise AddressCardDAVError(f"Invalid CardDAV XML response: {exc}") from exc
|
||||
return [parsed for response in child_elements(root, "response") if (parsed := _parse_discovery_response(response)) is not None]
|
||||
|
||||
|
||||
def _parse_discovery_response(response: Any) -> _DiscoveryResponse | None:
|
||||
href = first_child_text(response, "href")
|
||||
if not href:
|
||||
return None
|
||||
draft = _DiscoveryDraft()
|
||||
for propstat in child_elements(response, "propstat"):
|
||||
_apply_discovery_propstat(draft, propstat)
|
||||
return _DiscoveryResponse(
|
||||
href=href,
|
||||
display_name=draft.display_name,
|
||||
description=draft.description,
|
||||
ctag=draft.ctag,
|
||||
sync_token=draft.sync_token,
|
||||
is_addressbook=draft.is_addressbook,
|
||||
principal_hrefs=dedupe_tuple(draft.principal_hrefs),
|
||||
addressbook_home_set_hrefs=dedupe_tuple(draft.addressbook_home_set_hrefs),
|
||||
)
|
||||
|
||||
|
||||
def _apply_discovery_propstat(draft: _DiscoveryDraft, propstat: Any) -> None:
|
||||
if not discovery_propstat_is_success(propstat):
|
||||
return
|
||||
prop = first_child(propstat, "prop")
|
||||
if prop is None:
|
||||
return
|
||||
for item in prop:
|
||||
_apply_discovery_property(draft, item)
|
||||
|
||||
|
||||
def discovery_propstat_is_success(propstat: Any) -> bool:
|
||||
status = first_child_text(propstat, "status") or ""
|
||||
return not status or " 200 " in status or status.endswith(" 200") or " 207 " in status
|
||||
|
||||
|
||||
def _apply_discovery_property(draft: _DiscoveryDraft, item: Any) -> None:
|
||||
name = local_name(item.tag)
|
||||
text = item.text.strip() if item.text else ""
|
||||
if name == "displayname" and text:
|
||||
draft.display_name = text
|
||||
elif name == "addressbook-description" and text:
|
||||
draft.description = text
|
||||
elif name == "getctag" and text:
|
||||
draft.ctag = text
|
||||
elif name == "sync-token" and text:
|
||||
draft.sync_token = text
|
||||
elif name == "resourcetype":
|
||||
draft.is_addressbook = draft.is_addressbook or any(local_name(child.tag) == "addressbook" for child in item)
|
||||
elif name in {"current-user-principal", "principal-URL"}:
|
||||
draft.principal_hrefs.extend(nested_href_texts(item))
|
||||
elif name == "addressbook-home-set":
|
||||
draft.addressbook_home_set_hrefs.extend(nested_href_texts(item))
|
||||
|
||||
|
||||
def dedupe_tuple(values: list[str]) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(values))
|
||||
|
||||
|
||||
def ensure_collection_url(value: str) -> str:
|
||||
value = value.strip()
|
||||
if value and "://" not in value and not value.startswith("/"):
|
||||
value = f"https://{value}"
|
||||
url = validate_http_url(value)
|
||||
return url if url.endswith("/") else f"{url}/"
|
||||
|
||||
|
||||
def validate_http_url(value: str) -> str:
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise AddressCardDAVError("CardDAV URL must be an absolute HTTP(S) URL")
|
||||
if parsed.username or parsed.password:
|
||||
raise AddressCardDAVError("CardDAV URL must not include embedded credentials")
|
||||
return urllib.parse.urlunparse(parsed)
|
||||
|
||||
|
||||
def absolute_dav_url(base_url: str, href: str) -> str:
|
||||
return urllib.parse.urljoin(ensure_collection_url(base_url), href)
|
||||
|
||||
|
||||
def strip_weak_etag(value: str | None) -> str | None:
|
||||
return value.strip() if value else None
|
||||
|
||||
|
||||
def response_etag(headers: Mapping[str, str]) -> str | None:
|
||||
for key, value in headers.items():
|
||||
if key.lower() == "etag":
|
||||
return strip_weak_etag(value)
|
||||
return None
|
||||
|
||||
|
||||
def xml_escape(value: str) -> str:
|
||||
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def first_child(element: Any, name: str) -> Any | None:
|
||||
for child in element:
|
||||
if local_name(child.tag) == name:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def first_child_text(element: Any, name: str) -> str | None:
|
||||
found = first_child(element, name)
|
||||
if found is None or found.text is None:
|
||||
return None
|
||||
return found.text.strip()
|
||||
|
||||
|
||||
def child_elements(element: Any, name: str) -> list[Any]:
|
||||
return [child for child in element if local_name(child.tag) == name]
|
||||
|
||||
|
||||
def nested_href_texts(element: Any) -> list[str]:
|
||||
hrefs: list[str] = []
|
||||
for child in element.iter():
|
||||
if local_name(child.tag) == "href" and child.text and child.text.strip():
|
||||
hrefs.append(child.text.strip())
|
||||
return hrefs
|
||||
|
||||
|
||||
def local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
||||
2
src/govoplan_addresses/backend/db/__init__.py
Normal file
2
src/govoplan_addresses/backend/db/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Address module database models."""
|
||||
|
||||
330
src/govoplan_addresses/backend/db/models.py
Normal file
330
src/govoplan_addresses/backend/db/models.py
Normal file
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class AddressBook(Base, TimestampMixin):
|
||||
__tablename__ = "addresses_address_books"
|
||||
__table_args__ = (
|
||||
Index("ix_addresses_address_books_scope", "tenant_id", "scope_type", "scope_id"),
|
||||
Index(
|
||||
"uq_addresses_address_books_active_name",
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"name",
|
||||
unique=True,
|
||||
sqlite_where=text("deleted_at IS NULL"),
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
source_kind: Mapped[str] = mapped_column(String(30), default="local", nullable=False, index=True)
|
||||
source_ref: Mapped[str | None] = mapped_column(String(1000))
|
||||
read_only: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
sync_status: Mapped[str | None] = mapped_column(String(30))
|
||||
sync_error: Mapped[str | None] = mapped_column(Text)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
contacts: Mapped[list["Contact"]] = relationship(back_populates="address_book", cascade="all, delete-orphan")
|
||||
address_lists: Mapped[list["AddressList"]] = relationship(back_populates="address_book", cascade="all, delete-orphan")
|
||||
sync_sources: Mapped[list["AddressSyncSource"]] = relationship(back_populates="address_book", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Contact(Base, TimestampMixin):
|
||||
__tablename__ = "addresses_contacts"
|
||||
__table_args__ = (
|
||||
Index("ix_addresses_contacts_book_name", "address_book_id", "display_name"),
|
||||
Index("ix_addresses_contacts_tenant_name", "tenant_id", "display_name"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
address_book_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("addresses_address_books.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
given_name: Mapped[str | None] = mapped_column(String(255))
|
||||
family_name: Mapped[str | None] = mapped_column(String(255))
|
||||
organization: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||
role_title: Mapped[str | None] = mapped_column(String(255))
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
source_kind: Mapped[str] = mapped_column(String(30), default="local", nullable=False, index=True)
|
||||
source_ref: Mapped[str | None] = mapped_column(String(1000))
|
||||
source_payload_kind: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
source_payload_raw: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
address_book: Mapped[AddressBook] = relationship(back_populates="contacts")
|
||||
emails: Mapped[list["ContactEmail"]] = relationship(back_populates="contact", cascade="all, delete-orphan", order_by="ContactEmail.order_index")
|
||||
phones: Mapped[list["ContactPhone"]] = relationship(back_populates="contact", cascade="all, delete-orphan", order_by="ContactPhone.order_index")
|
||||
postal_addresses: Mapped[list["ContactPostalAddress"]] = relationship(
|
||||
back_populates="contact",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ContactPostalAddress.order_index",
|
||||
)
|
||||
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ContactEmail(Base, TimestampMixin):
|
||||
__tablename__ = "addresses_contact_emails"
|
||||
__table_args__ = (
|
||||
Index("ix_addresses_contact_emails_lookup", "email"),
|
||||
Index("ix_addresses_contact_emails_contact_primary", "contact_id", "is_primary"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
label: Mapped[str | None] = mapped_column(String(80))
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
contact: Mapped[Contact] = relationship(back_populates="emails")
|
||||
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_email")
|
||||
|
||||
|
||||
class ContactPhone(Base, TimestampMixin):
|
||||
__tablename__ = "addresses_contact_phones"
|
||||
__table_args__ = (Index("ix_addresses_contact_phones_contact_primary", "contact_id", "is_primary"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
label: Mapped[str | None] = mapped_column(String(80))
|
||||
phone: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
contact: Mapped[Contact] = relationship(back_populates="phones")
|
||||
|
||||
|
||||
class ContactPostalAddress(Base, TimestampMixin):
|
||||
__tablename__ = "addresses_contact_postal_addresses"
|
||||
__table_args__ = (Index("ix_addresses_contact_postal_addresses_contact_primary", "contact_id", "is_primary"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
label: Mapped[str | None] = mapped_column(String(80))
|
||||
street: Mapped[str | None] = mapped_column(String(500))
|
||||
postal_code: Mapped[str | None] = mapped_column(String(40))
|
||||
locality: Mapped[str | None] = mapped_column(String(255))
|
||||
region: Mapped[str | None] = mapped_column(String(255))
|
||||
country: Mapped[str | None] = mapped_column(String(255))
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
contact: Mapped[Contact] = relationship(back_populates="postal_addresses")
|
||||
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_postal_address")
|
||||
|
||||
|
||||
class AddressList(Base, TimestampMixin):
|
||||
__tablename__ = "addresses_address_lists"
|
||||
__table_args__ = (
|
||||
Index("ix_addresses_address_lists_book_name", "address_book_id", "name"),
|
||||
Index(
|
||||
"uq_addresses_address_lists_active_name",
|
||||
"address_book_id",
|
||||
"name",
|
||||
unique=True,
|
||||
sqlite_where=text("deleted_at IS NULL"),
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
source_kind: Mapped[str] = mapped_column(String(30), default="local", nullable=False, index=True)
|
||||
source_ref: Mapped[str | None] = mapped_column(String(1000))
|
||||
read_only: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
address_book: Mapped[AddressBook] = relationship(back_populates="address_lists")
|
||||
entries: Mapped[list["AddressListEntry"]] = relationship(
|
||||
back_populates="address_list",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AddressListEntry.order_index",
|
||||
)
|
||||
|
||||
|
||||
class AddressListEntry(Base, TimestampMixin):
|
||||
__tablename__ = "addresses_address_list_entries"
|
||||
__table_args__ = (
|
||||
Index("ix_addresses_address_list_entries_list_order", "address_list_id", "order_index"),
|
||||
Index("ix_addresses_address_list_entries_contact", "contact_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
address_list_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_lists.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
contact_email_id: Mapped[str | None] = mapped_column(ForeignKey("addresses_contact_emails.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
contact_postal_address_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("addresses_contact_postal_addresses.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
target_kind: Mapped[str] = mapped_column(String(30), default="contact", nullable=False, index=True)
|
||||
label: Mapped[str | None] = mapped_column(String(255))
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
address_list: Mapped[AddressList] = relationship(back_populates="entries")
|
||||
contact: Mapped[Contact] = relationship(back_populates="address_list_entries")
|
||||
contact_email: Mapped[ContactEmail | None] = relationship(back_populates="address_list_entries")
|
||||
contact_postal_address: Mapped[ContactPostalAddress | None] = relationship(back_populates="address_list_entries")
|
||||
|
||||
|
||||
class AddressSyncSource(Base, TimestampMixin):
|
||||
__tablename__ = "addresses_sync_sources"
|
||||
__table_args__ = (
|
||||
Index("ix_addresses_sync_sources_book_status", "address_book_id", "status"),
|
||||
Index("ix_addresses_sync_sources_connector", "tenant_id", "connector_type"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
connector_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
external_account_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
external_address_book_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
sync_direction: Mapped[str] = mapped_column(String(30), default="read_only", nullable=False, index=True)
|
||||
read_only: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(30), default="idle", nullable=False, index=True)
|
||||
sync_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
etag: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
remote_revision: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
last_attempted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_diagnostic: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
address_book: Mapped[AddressBook] = relationship(back_populates="sync_sources")
|
||||
tombstones: Mapped[list["AddressSyncTombstone"]] = relationship(back_populates="sync_source", cascade="all, delete-orphan")
|
||||
conflicts: Mapped[list["AddressSyncConflict"]] = relationship(back_populates="sync_source", cascade="all, delete-orphan")
|
||||
diagnostics: Mapped[list["AddressSyncDiagnostic"]] = relationship(back_populates="sync_source", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class AddressSyncTombstone(Base, TimestampMixin):
|
||||
__tablename__ = "addresses_sync_tombstones"
|
||||
__table_args__ = (
|
||||
Index("ix_addresses_sync_tombstones_source_remote", "sync_source_id", "remote_uid"),
|
||||
Index("ix_addresses_sync_tombstones_source_href", "sync_source_id", "resource_href"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
sync_source_id: Mapped[str] = mapped_column(ForeignKey("addresses_sync_sources.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
contact_id: Mapped[str | None] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
remote_uid: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
resource_href: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
local_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
sync_source: Mapped[AddressSyncSource] = relationship(back_populates="tombstones")
|
||||
address_book: Mapped[AddressBook] = relationship()
|
||||
contact: Mapped[Contact | None] = relationship()
|
||||
|
||||
|
||||
class AddressSyncConflict(Base, TimestampMixin):
|
||||
__tablename__ = "addresses_sync_conflicts"
|
||||
__table_args__ = (
|
||||
Index("ix_addresses_sync_conflicts_source_status", "sync_source_id", "status"),
|
||||
Index("ix_addresses_sync_conflicts_contact_status", "contact_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
sync_source_id: Mapped[str] = mapped_column(ForeignKey("addresses_sync_sources.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
contact_id: Mapped[str | None] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
remote_uid: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
resource_href: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
field_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
local_value: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
remote_value: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
local_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
remote_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), default="open", nullable=False, index=True)
|
||||
resolution: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
resolved_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
sync_source: Mapped[AddressSyncSource] = relationship(back_populates="conflicts")
|
||||
address_book: Mapped[AddressBook] = relationship()
|
||||
contact: Mapped[Contact | None] = relationship()
|
||||
|
||||
|
||||
class AddressSyncDiagnostic(Base, TimestampMixin):
|
||||
__tablename__ = "addresses_sync_diagnostics"
|
||||
__table_args__ = (
|
||||
Index("ix_addresses_sync_diagnostics_source_created", "sync_source_id", "created_at"),
|
||||
Index("ix_addresses_sync_diagnostics_source_severity", "sync_source_id", "severity"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
sync_source_id: Mapped[str] = mapped_column(ForeignKey("addresses_sync_sources.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
severity: Mapped[str] = mapped_column(String(30), default="info", nullable=False, index=True)
|
||||
code: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
details: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
sync_source: Mapped[AddressSyncSource] = relationship(back_populates="diagnostics")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AddressBook",
|
||||
"AddressList",
|
||||
"AddressListEntry",
|
||||
"AddressSyncConflict",
|
||||
"AddressSyncDiagnostic",
|
||||
"AddressSyncSource",
|
||||
"AddressSyncTombstone",
|
||||
"Contact",
|
||||
"ContactEmail",
|
||||
"ContactPhone",
|
||||
"ContactPostalAddress",
|
||||
"new_uuid",
|
||||
]
|
||||
195
src/govoplan_addresses/backend/manifest.py
Normal file
195
src/govoplan_addresses/backend/manifest.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_addresses.backend.capabilities import (
|
||||
CAPABILITY_ADDRESSES_CONTACT_WRITER,
|
||||
CAPABILITY_ADDRESSES_LOOKUP,
|
||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
||||
)
|
||||
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="Addresses",
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission("addresses:address_book:read", "View address books", "List address books visible to the current principal."),
|
||||
_permission("addresses:address_book:write", "Manage address books", "Create and edit local address books."),
|
||||
_permission("addresses:address_book:delete", "Delete address books", "Soft-delete local address books."),
|
||||
_permission("addresses:address_book:admin", "Administer address books", "Manage system-scoped address books and future sync sources."),
|
||||
_permission("addresses:address_list:read", "View address lists", "List reusable address lists and their entries."),
|
||||
_permission("addresses:address_list:write", "Manage address lists", "Create and edit reusable address lists."),
|
||||
_permission("addresses:address_list:delete", "Delete address lists", "Soft-delete reusable address lists."),
|
||||
_permission("addresses:contact:read", "View contacts", "List and lookup contacts in visible address books."),
|
||||
_permission("addresses:contact:write", "Manage contacts", "Create and edit local contacts."),
|
||||
_permission("addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."),
|
||||
_permission("addresses:sync:read", "View address sync", "Inspect address sync sources, conflicts, tombstones, and diagnostics."),
|
||||
_permission("addresses:sync:write", "Manage address sync", "Bind address books to external sources and record sync state."),
|
||||
_permission("addresses:sync:admin", "Administer address sync", "Administer address sync connectors and future destructive sync operations."),
|
||||
)
|
||||
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="address_book_manager",
|
||||
name="Address book manager",
|
||||
description="Manage visible local address books and contacts.",
|
||||
permissions=(
|
||||
"addresses:address_book:read",
|
||||
"addresses:address_book:write",
|
||||
"addresses:address_book:delete",
|
||||
"addresses:address_list:read",
|
||||
"addresses:address_list:write",
|
||||
"addresses:address_list:delete",
|
||||
"addresses:contact:read",
|
||||
"addresses:contact:write",
|
||||
"addresses:contact:delete",
|
||||
"addresses:sync:read",
|
||||
"addresses:sync:write",
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="address_book_reader",
|
||||
name="Address book reader",
|
||||
description="Read visible address books and contacts.",
|
||||
permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:sync:read"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressSyncSource, Contact
|
||||
|
||||
return {
|
||||
"address_books": session.query(AddressBook).filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None)).count(),
|
||||
"address_lists": session.query(AddressList).filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None)).count(),
|
||||
"contacts": session.query(Contact).filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None)).count(),
|
||||
"sync_sources": session.query(AddressSyncSource).filter(AddressSyncSource.tenant_id == tenant_id, AddressSyncSource.enabled.is_(True)).count(),
|
||||
}
|
||||
|
||||
|
||||
def _addresses_router(_context: ModuleContext):
|
||||
from govoplan_addresses.backend.router import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="addresses",
|
||||
name="Addresses",
|
||||
version="0.1.8",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"),
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
route_factory=_addresses_router,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),),
|
||||
frontend=FrontendModule(
|
||||
module_id="addresses",
|
||||
package_name="@govoplan/addresses-webui",
|
||||
routes=(FrontendRoute(path="/address-book", component="AddressBookPage", required_any=("addresses:contact:read",), order=80),),
|
||||
nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="addresses",
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
addresses_models.AddressSyncDiagnostic,
|
||||
addresses_models.AddressSyncConflict,
|
||||
addresses_models.AddressSyncTombstone,
|
||||
addresses_models.AddressSyncSource,
|
||||
addresses_models.AddressListEntry,
|
||||
addresses_models.AddressList,
|
||||
addresses_models.ContactPostalAddress,
|
||||
addresses_models.ContactPhone,
|
||||
addresses_models.ContactEmail,
|
||||
addresses_models.Contact,
|
||||
addresses_models.AddressBook,
|
||||
label="Addresses",
|
||||
),
|
||||
retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.",
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]).lookup_capability(context),
|
||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__(
|
||||
"govoplan_addresses.backend.capabilities",
|
||||
fromlist=["recipient_source_capability"],
|
||||
).recipient_source_capability(context),
|
||||
CAPABILITY_ADDRESSES_CONTACT_WRITER: lambda context: __import__(
|
||||
"govoplan_addresses.backend.capabilities",
|
||||
fromlist=["contact_writer_capability"],
|
||||
).contact_writer_capability(context),
|
||||
},
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
addresses_models.AddressSyncDiagnostic,
|
||||
addresses_models.AddressSyncConflict,
|
||||
addresses_models.AddressSyncTombstone,
|
||||
addresses_models.AddressSyncSource,
|
||||
addresses_models.AddressListEntry,
|
||||
addresses_models.AddressList,
|
||||
addresses_models.AddressBook,
|
||||
addresses_models.Contact,
|
||||
addresses_models.ContactEmail,
|
||||
addresses_models.ContactPhone,
|
||||
addresses_models.ContactPostalAddress,
|
||||
label="Addresses",
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="addresses.boundary",
|
||||
title="Reusable address ownership",
|
||||
summary="Reusable person, organization, household, postal, and email recipient sources belong to the addresses module.",
|
||||
body=(
|
||||
"Campaigns may keep immutable campaign-local recipient snapshots, but durable address directories, "
|
||||
"recipient-source definitions, consent metadata, provenance, deduplication, and import/export workflows "
|
||||
"are owned by govoplan-addresses."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "operator", "module_admin"),
|
||||
related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
|
||||
order=30,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
2
src/govoplan_addresses/backend/migrations/__init__.py
Normal file
2
src/govoplan_addresses/backend/migrations/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Address module migration package."""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Address module Alembic revisions."""
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""v0.1.8 addresses baseline
|
||||
|
||||
Revision ID: b8c9d0e1f2a3
|
||||
Revises: None
|
||||
Create Date: 2026-07-13 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b8c9d0e1f2a3"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"addresses_address_books",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("scope_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("source_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("source_ref", sa.String(length=1000), nullable=True),
|
||||
sa.Column("read_only", sa.Boolean(), nullable=False),
|
||||
sa.Column("sync_status", sa.String(length=30), nullable=True),
|
||||
sa.Column("sync_error", sa.Text(), nullable=True),
|
||||
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_address_books")),
|
||||
)
|
||||
op.create_index(op.f("ix_addresses_address_books_created_by_account_id"), "addresses_address_books", ["created_by_account_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_books_deleted_at"), "addresses_address_books", ["deleted_at"], unique=False)
|
||||
op.create_index("ix_addresses_address_books_scope", "addresses_address_books", ["tenant_id", "scope_type", "scope_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_books_scope_id"), "addresses_address_books", ["scope_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_books_scope_type"), "addresses_address_books", ["scope_type"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_books_source_kind"), "addresses_address_books", ["source_kind"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_books_tenant_id"), "addresses_address_books", ["tenant_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_books_updated_by_account_id"), "addresses_address_books", ["updated_by_account_id"], unique=False)
|
||||
op.create_index(
|
||||
"uq_addresses_address_books_active_name",
|
||||
"addresses_address_books",
|
||||
["tenant_id", "scope_type", "scope_id", "name"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("deleted_at IS NULL"),
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"addresses_contacts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("address_book_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("given_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("family_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("organization", sa.String(length=255), nullable=True),
|
||||
sa.Column("role_title", sa.String(length=255), nullable=True),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("tags", sa.JSON(), nullable=False),
|
||||
sa.Column("source_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("source_ref", sa.String(length=1000), nullable=True),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["address_book_id"],
|
||||
["addresses_address_books.id"],
|
||||
name=op.f("fk_addresses_contacts_address_book_id_addresses_address_books"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contacts")),
|
||||
)
|
||||
op.create_index(op.f("ix_addresses_contacts_address_book_id"), "addresses_contacts", ["address_book_id"], unique=False)
|
||||
op.create_index("ix_addresses_contacts_book_name", "addresses_contacts", ["address_book_id", "display_name"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_contacts_created_by_account_id"), "addresses_contacts", ["created_by_account_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_contacts_deleted_at"), "addresses_contacts", ["deleted_at"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_contacts_display_name"), "addresses_contacts", ["display_name"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_contacts_organization"), "addresses_contacts", ["organization"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_contacts_source_kind"), "addresses_contacts", ["source_kind"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_contacts_tenant_id"), "addresses_contacts", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_addresses_contacts_tenant_name", "addresses_contacts", ["tenant_id", "display_name"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_contacts_updated_by_account_id"), "addresses_contacts", ["updated_by_account_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"addresses_contact_emails",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("contact_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("label", sa.String(length=80), nullable=True),
|
||||
sa.Column("email", sa.String(length=320), nullable=False),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["contact_id"], ["addresses_contacts.id"], name=op.f("fk_addresses_contact_emails_contact_id_addresses_contacts"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contact_emails")),
|
||||
)
|
||||
op.create_index(op.f("ix_addresses_contact_emails_contact_id"), "addresses_contact_emails", ["contact_id"], unique=False)
|
||||
op.create_index("ix_addresses_contact_emails_contact_primary", "addresses_contact_emails", ["contact_id", "is_primary"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_contact_emails_email"), "addresses_contact_emails", ["email"], unique=False)
|
||||
op.create_index("ix_addresses_contact_emails_lookup", "addresses_contact_emails", ["email"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"addresses_contact_phones",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("contact_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("label", sa.String(length=80), nullable=True),
|
||||
sa.Column("phone", sa.String(length=100), nullable=False),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["contact_id"], ["addresses_contacts.id"], name=op.f("fk_addresses_contact_phones_contact_id_addresses_contacts"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contact_phones")),
|
||||
)
|
||||
op.create_index(op.f("ix_addresses_contact_phones_contact_id"), "addresses_contact_phones", ["contact_id"], unique=False)
|
||||
op.create_index("ix_addresses_contact_phones_contact_primary", "addresses_contact_phones", ["contact_id", "is_primary"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"addresses_contact_postal_addresses",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("contact_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("label", sa.String(length=80), nullable=True),
|
||||
sa.Column("street", sa.String(length=500), nullable=True),
|
||||
sa.Column("postal_code", sa.String(length=40), nullable=True),
|
||||
sa.Column("locality", sa.String(length=255), nullable=True),
|
||||
sa.Column("region", sa.String(length=255), nullable=True),
|
||||
sa.Column("country", sa.String(length=255), nullable=True),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["contact_id"],
|
||||
["addresses_contacts.id"],
|
||||
name=op.f("fk_addresses_contact_postal_addresses_contact_id_addresses_contacts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contact_postal_addresses")),
|
||||
)
|
||||
op.create_index(op.f("ix_addresses_contact_postal_addresses_contact_id"), "addresses_contact_postal_addresses", ["contact_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_addresses_contact_postal_addresses_contact_primary",
|
||||
"addresses_contact_postal_addresses",
|
||||
["contact_id", "is_primary"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("addresses_contact_postal_addresses")
|
||||
op.drop_table("addresses_contact_phones")
|
||||
op.drop_table("addresses_contact_emails")
|
||||
op.drop_table("addresses_contacts")
|
||||
op.drop_table("addresses_address_books")
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""v0.1.9 addresses source payload metadata
|
||||
|
||||
Revision ID: c9d0e1f2a4b
|
||||
Revises: b8c9d0e1f2a3
|
||||
Create Date: 2026-07-13 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c9d0e1f2a4b"
|
||||
down_revision = "b8c9d0e1f2a3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("addresses_contacts", sa.Column("source_payload_kind", sa.String(length=40), nullable=True))
|
||||
op.add_column("addresses_contacts", sa.Column("source_payload_raw", sa.Text(), nullable=True))
|
||||
op.add_column("addresses_contacts", sa.Column("source_revision", sa.String(length=255), nullable=True))
|
||||
op.create_index(op.f("ix_addresses_contacts_source_payload_kind"), "addresses_contacts", ["source_payload_kind"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_contacts_source_revision"), "addresses_contacts", ["source_revision"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_addresses_contacts_source_revision"), table_name="addresses_contacts")
|
||||
op.drop_index(op.f("ix_addresses_contacts_source_payload_kind"), table_name="addresses_contacts")
|
||||
op.drop_column("addresses_contacts", "source_revision")
|
||||
op.drop_column("addresses_contacts", "source_payload_raw")
|
||||
op.drop_column("addresses_contacts", "source_payload_kind")
|
||||
@@ -0,0 +1,115 @@
|
||||
"""addresses address lists
|
||||
|
||||
Revision ID: d0e1f2a4b5c
|
||||
Revises: c9d0e1f2a4b
|
||||
Create Date: 2026-07-13 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d0e1f2a4b5c"
|
||||
down_revision = "c9d0e1f2a4b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"addresses_address_lists",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("address_book_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("source_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("source_ref", sa.String(length=1000), nullable=True),
|
||||
sa.Column("read_only", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["address_book_id"],
|
||||
["addresses_address_books.id"],
|
||||
name=op.f("fk_addresses_address_lists_address_book_id_addresses_address_books"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_address_lists")),
|
||||
)
|
||||
op.create_index(op.f("ix_addresses_address_lists_address_book_id"), "addresses_address_lists", ["address_book_id"], unique=False)
|
||||
op.create_index("ix_addresses_address_lists_book_name", "addresses_address_lists", ["address_book_id", "name"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_lists_created_by_account_id"), "addresses_address_lists", ["created_by_account_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_lists_deleted_at"), "addresses_address_lists", ["deleted_at"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_lists_source_kind"), "addresses_address_lists", ["source_kind"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_lists_tenant_id"), "addresses_address_lists", ["tenant_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_lists_updated_by_account_id"), "addresses_address_lists", ["updated_by_account_id"], unique=False)
|
||||
op.create_index(
|
||||
"uq_addresses_address_lists_active_name",
|
||||
"addresses_address_lists",
|
||||
["address_book_id", "name"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("deleted_at IS NULL"),
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"addresses_address_list_entries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("address_list_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("contact_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("contact_email_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("contact_postal_address_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("target_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("label", sa.String(length=255), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["address_list_id"],
|
||||
["addresses_address_lists.id"],
|
||||
name=op.f("fk_addresses_address_list_entries_address_list_id_addresses_address_lists"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["contact_email_id"],
|
||||
["addresses_contact_emails.id"],
|
||||
name=op.f("fk_addresses_address_list_entries_contact_email_id_addresses_contact_emails"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["contact_id"],
|
||||
["addresses_contacts.id"],
|
||||
name=op.f("fk_addresses_address_list_entries_contact_id_addresses_contacts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["contact_postal_address_id"],
|
||||
["addresses_contact_postal_addresses.id"],
|
||||
name=op.f("fk_addresses_address_list_entries_contact_postal_address_id_addresses_contact_postal_addresses"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_address_list_entries")),
|
||||
)
|
||||
op.create_index(op.f("ix_addresses_address_list_entries_address_list_id"), "addresses_address_list_entries", ["address_list_id"], unique=False)
|
||||
op.create_index("ix_addresses_address_list_entries_contact", "addresses_address_list_entries", ["contact_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_list_entries_contact_email_id"), "addresses_address_list_entries", ["contact_email_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_list_entries_contact_id"), "addresses_address_list_entries", ["contact_id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_addresses_address_list_entries_contact_postal_address_id"),
|
||||
"addresses_address_list_entries",
|
||||
["contact_postal_address_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index("ix_addresses_address_list_entries_list_order", "addresses_address_list_entries", ["address_list_id", "order_index"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_address_list_entries_target_kind"), "addresses_address_list_entries", ["target_kind"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("addresses_address_list_entries")
|
||||
op.drop_table("addresses_address_lists")
|
||||
@@ -0,0 +1,193 @@
|
||||
"""addresses sync infrastructure
|
||||
|
||||
Revision ID: e1f2a4b5c6d
|
||||
Revises: d0e1f2a4b5c
|
||||
Create Date: 2026-07-14 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "e1f2a4b5c6d"
|
||||
down_revision = "d0e1f2a4b5c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"addresses_sync_sources",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("address_book_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("connector_type", sa.String(length=60), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("external_account_ref", sa.String(length=1000), nullable=True),
|
||||
sa.Column("external_address_book_ref", sa.String(length=1000), nullable=True),
|
||||
sa.Column("sync_direction", sa.String(length=30), nullable=False),
|
||||
sa.Column("read_only", sa.Boolean(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("sync_token", sa.Text(), nullable=True),
|
||||
sa.Column("etag", sa.String(length=1000), nullable=True),
|
||||
sa.Column("remote_revision", sa.String(length=1000), nullable=True),
|
||||
sa.Column("last_attempted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("last_diagnostic", sa.JSON(), nullable=True),
|
||||
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["address_book_id"],
|
||||
["addresses_address_books.id"],
|
||||
name=op.f("fk_addresses_sync_sources_address_book_id_addresses_address_books"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_sources")),
|
||||
)
|
||||
op.create_index(op.f("ix_addresses_sync_sources_address_book_id"), "addresses_sync_sources", ["address_book_id"], unique=False)
|
||||
op.create_index("ix_addresses_sync_sources_book_status", "addresses_sync_sources", ["address_book_id", "status"], unique=False)
|
||||
op.create_index("ix_addresses_sync_sources_connector", "addresses_sync_sources", ["tenant_id", "connector_type"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_sources_connector_type"), "addresses_sync_sources", ["connector_type"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_sources_created_by_account_id"), "addresses_sync_sources", ["created_by_account_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_sources_enabled"), "addresses_sync_sources", ["enabled"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_sources_last_attempted_at"), "addresses_sync_sources", ["last_attempted_at"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_sources_last_success_at"), "addresses_sync_sources", ["last_success_at"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_sources_status"), "addresses_sync_sources", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_sources_sync_direction"), "addresses_sync_sources", ["sync_direction"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_sources_tenant_id"), "addresses_sync_sources", ["tenant_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_sources_updated_by_account_id"), "addresses_sync_sources", ["updated_by_account_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"addresses_sync_tombstones",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("sync_source_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("address_book_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("contact_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("remote_uid", sa.String(length=1000), nullable=True),
|
||||
sa.Column("resource_href", sa.String(length=1000), nullable=True),
|
||||
sa.Column("local_deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("remote_deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["address_book_id"],
|
||||
["addresses_address_books.id"],
|
||||
name=op.f("fk_addresses_sync_tombstones_address_book_id_addresses_address_books"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["contact_id"],
|
||||
["addresses_contacts.id"],
|
||||
name=op.f("fk_addresses_sync_tombstones_contact_id_addresses_contacts"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["sync_source_id"],
|
||||
["addresses_sync_sources.id"],
|
||||
name=op.f("fk_addresses_sync_tombstones_sync_source_id_addresses_sync_sources"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_tombstones")),
|
||||
)
|
||||
op.create_index(op.f("ix_addresses_sync_tombstones_address_book_id"), "addresses_sync_tombstones", ["address_book_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_tombstones_contact_id"), "addresses_sync_tombstones", ["contact_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_tombstones_local_deleted_at"), "addresses_sync_tombstones", ["local_deleted_at"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_tombstones_remote_deleted_at"), "addresses_sync_tombstones", ["remote_deleted_at"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_tombstones_synced_at"), "addresses_sync_tombstones", ["synced_at"], unique=False)
|
||||
op.create_index("ix_addresses_sync_tombstones_source_href", "addresses_sync_tombstones", ["sync_source_id", "resource_href"], unique=False)
|
||||
op.create_index("ix_addresses_sync_tombstones_source_remote", "addresses_sync_tombstones", ["sync_source_id", "remote_uid"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_tombstones_sync_source_id"), "addresses_sync_tombstones", ["sync_source_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_tombstones_tenant_id"), "addresses_sync_tombstones", ["tenant_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"addresses_sync_conflicts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("sync_source_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("address_book_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("contact_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("remote_uid", sa.String(length=1000), nullable=True),
|
||||
sa.Column("resource_href", sa.String(length=1000), nullable=True),
|
||||
sa.Column("field_path", sa.String(length=500), nullable=False),
|
||||
sa.Column("local_value", sa.JSON(), nullable=True),
|
||||
sa.Column("remote_value", sa.JSON(), nullable=True),
|
||||
sa.Column("local_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("remote_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("resolution", sa.String(length=60), nullable=True),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("resolved_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["address_book_id"],
|
||||
["addresses_address_books.id"],
|
||||
name=op.f("fk_addresses_sync_conflicts_address_book_id_addresses_address_books"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["contact_id"],
|
||||
["addresses_contacts.id"],
|
||||
name=op.f("fk_addresses_sync_conflicts_contact_id_addresses_contacts"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["sync_source_id"],
|
||||
["addresses_sync_sources.id"],
|
||||
name=op.f("fk_addresses_sync_conflicts_sync_source_id_addresses_sync_sources"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_conflicts")),
|
||||
)
|
||||
op.create_index(op.f("ix_addresses_sync_conflicts_address_book_id"), "addresses_sync_conflicts", ["address_book_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_conflicts_contact_id"), "addresses_sync_conflicts", ["contact_id"], unique=False)
|
||||
op.create_index("ix_addresses_sync_conflicts_contact_status", "addresses_sync_conflicts", ["contact_id", "status"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_conflicts_resolved_at"), "addresses_sync_conflicts", ["resolved_at"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_conflicts_resolved_by_account_id"), "addresses_sync_conflicts", ["resolved_by_account_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_conflicts_status"), "addresses_sync_conflicts", ["status"], unique=False)
|
||||
op.create_index("ix_addresses_sync_conflicts_source_status", "addresses_sync_conflicts", ["sync_source_id", "status"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_conflicts_sync_source_id"), "addresses_sync_conflicts", ["sync_source_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_conflicts_tenant_id"), "addresses_sync_conflicts", ["tenant_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"addresses_sync_diagnostics",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("sync_source_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("severity", sa.String(length=30), nullable=False),
|
||||
sa.Column("code", sa.String(length=120), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=False),
|
||||
sa.Column("details", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["sync_source_id"],
|
||||
["addresses_sync_sources.id"],
|
||||
name=op.f("fk_addresses_sync_diagnostics_sync_source_id_addresses_sync_sources"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_diagnostics")),
|
||||
)
|
||||
op.create_index(op.f("ix_addresses_sync_diagnostics_code"), "addresses_sync_diagnostics", ["code"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_diagnostics_severity"), "addresses_sync_diagnostics", ["severity"], unique=False)
|
||||
op.create_index("ix_addresses_sync_diagnostics_source_created", "addresses_sync_diagnostics", ["sync_source_id", "created_at"], unique=False)
|
||||
op.create_index("ix_addresses_sync_diagnostics_source_severity", "addresses_sync_diagnostics", ["sync_source_id", "severity"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_diagnostics_sync_source_id"), "addresses_sync_diagnostics", ["sync_source_id"], unique=False)
|
||||
op.create_index(op.f("ix_addresses_sync_diagnostics_tenant_id"), "addresses_sync_diagnostics", ["tenant_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("addresses_sync_diagnostics")
|
||||
op.drop_table("addresses_sync_conflicts")
|
||||
op.drop_table("addresses_sync_tombstones")
|
||||
op.drop_table("addresses_sync_sources")
|
||||
1136
src/govoplan_addresses/backend/router.py
Normal file
1136
src/govoplan_addresses/backend/router.py
Normal file
File diff suppressed because it is too large
Load Diff
510
src/govoplan_addresses/backend/schemas.py
Normal file
510
src/govoplan_addresses/backend/schemas.py
Normal file
@@ -0,0 +1,510 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
|
||||
|
||||
AddressBookScope = Literal["user", "group", "tenant", "system"]
|
||||
AddressSyncDirection = Literal["read_only", "import", "export", "two_way"]
|
||||
AddressSyncStatus = Literal["idle", "running", "succeeded", "failed", "conflict", "disabled"]
|
||||
AddressSyncDiagnosticSeverity = Literal["debug", "info", "warning", "error"]
|
||||
AddressSyncConflictStatus = Literal["open", "resolved", "ignored"]
|
||||
AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "manual", "ignored"]
|
||||
AddressCardDavAuthType = Literal["none", "basic", "bearer"]
|
||||
AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"]
|
||||
|
||||
|
||||
class ContactEmailPayload(BaseModel):
|
||||
label: str | None = Field(default=None, max_length=80)
|
||||
email: str = Field(max_length=320)
|
||||
is_primary: bool = False
|
||||
|
||||
|
||||
class ContactPhonePayload(BaseModel):
|
||||
label: str | None = Field(default=None, max_length=80)
|
||||
phone: str = Field(max_length=100)
|
||||
is_primary: bool = False
|
||||
|
||||
|
||||
class ContactPostalAddressPayload(BaseModel):
|
||||
label: str | None = Field(default=None, max_length=80)
|
||||
street: str | None = Field(default=None, max_length=500)
|
||||
postal_code: str | None = Field(default=None, max_length=40)
|
||||
locality: str | None = Field(default=None, max_length=255)
|
||||
region: str | None = Field(default=None, max_length=255)
|
||||
country: str | None = Field(default=None, max_length=255)
|
||||
is_primary: bool = False
|
||||
|
||||
|
||||
class AddressBookCreateRequest(BaseModel):
|
||||
scope_type: AddressBookScope = "user"
|
||||
group_id: str | None = Field(default=None, max_length=36)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AddressBookUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AddressBookResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
scope_type: AddressBookScope
|
||||
scope_id: str | None = None
|
||||
name: str
|
||||
description: str | None = None
|
||||
source_kind: str
|
||||
source_ref: str | None = None
|
||||
read_only: bool
|
||||
sync_status: str | None = None
|
||||
sync_error: str | None = None
|
||||
contact_count: int = 0
|
||||
deleted_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AddressBookListResponse(BaseModel):
|
||||
address_books: list[AddressBookResponse]
|
||||
|
||||
|
||||
class AddressListCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AddressListUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AddressListEntryCreateRequest(BaseModel):
|
||||
contact_id: str = Field(max_length=36)
|
||||
contact_email_id: str | None = Field(default=None, max_length=36)
|
||||
contact_postal_address_id: str | None = Field(default=None, max_length=36)
|
||||
label: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AddressListResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
address_book_id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
source_kind: str
|
||||
source_ref: str | None = None
|
||||
read_only: bool
|
||||
entry_count: int = 0
|
||||
deleted_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AddressListListResponse(BaseModel):
|
||||
address_lists: list[AddressListResponse]
|
||||
|
||||
|
||||
class AddressListEntryResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
address_list_id: str
|
||||
contact_id: str
|
||||
contact_email_id: str | None = None
|
||||
contact_postal_address_id: str | None = None
|
||||
target_kind: str
|
||||
label: str | None = None
|
||||
order_index: int
|
||||
contact_display_name: str
|
||||
email: str | None = None
|
||||
postal_address: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AddressListEntryListResponse(BaseModel):
|
||||
entries: list[AddressListEntryResponse]
|
||||
|
||||
|
||||
class ContactCreateRequest(BaseModel):
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
given_name: str | None = Field(default=None, max_length=255)
|
||||
family_name: str | None = Field(default=None, max_length=255)
|
||||
organization: str | None = Field(default=None, max_length=255)
|
||||
role_title: str | None = Field(default=None, max_length=255)
|
||||
note: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
emails: list[ContactEmailPayload] = Field(default_factory=list)
|
||||
phones: list[ContactPhonePayload] = Field(default_factory=list)
|
||||
postal_addresses: list[ContactPostalAddressPayload] = Field(default_factory=list)
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ContactUpdateRequest(BaseModel):
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
given_name: str | None = Field(default=None, max_length=255)
|
||||
family_name: str | None = Field(default=None, max_length=255)
|
||||
organization: str | None = Field(default=None, max_length=255)
|
||||
role_title: str | None = Field(default=None, max_length=255)
|
||||
note: str | None = None
|
||||
tags: list[str] | None = None
|
||||
emails: list[ContactEmailPayload] | None = None
|
||||
phones: list[ContactPhonePayload] | None = None
|
||||
postal_addresses: list[ContactPostalAddressPayload] | None = None
|
||||
provenance: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ContactEmailResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
label: str | None = None
|
||||
email: str
|
||||
is_primary: bool
|
||||
|
||||
|
||||
class ContactPhoneResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
label: str | None = None
|
||||
phone: str
|
||||
is_primary: bool
|
||||
|
||||
|
||||
class ContactPostalAddressResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
label: str | None = None
|
||||
street: str | None = None
|
||||
postal_code: str | None = None
|
||||
locality: str | None = None
|
||||
region: str | None = None
|
||||
country: str | None = None
|
||||
is_primary: bool
|
||||
|
||||
|
||||
class ContactResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
address_book_id: str
|
||||
display_name: str
|
||||
given_name: str | None = None
|
||||
family_name: str | None = None
|
||||
organization: str | None = None
|
||||
role_title: str | None = None
|
||||
note: str | None = None
|
||||
tags: list[str]
|
||||
source_kind: str
|
||||
source_ref: str | None = None
|
||||
source_payload_kind: str | None = None
|
||||
source_revision: str | None = None
|
||||
provenance: dict[str, Any]
|
||||
emails: list[ContactEmailResponse]
|
||||
phones: list[ContactPhoneResponse]
|
||||
postal_addresses: list[ContactPostalAddressResponse]
|
||||
deleted_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ContactListResponse(BaseModel):
|
||||
contacts: list[ContactResponse]
|
||||
|
||||
|
||||
class AddressLookupResponse(BaseModel):
|
||||
contacts: list[ContactResponse]
|
||||
|
||||
|
||||
class AddressBookWriteDecisionResponse(BaseModel):
|
||||
address_book_id: str
|
||||
address_book_label: str | None = None
|
||||
operation: str
|
||||
allowed: bool
|
||||
reason: str
|
||||
message: str
|
||||
scope_type: AddressBookScope | None = None
|
||||
scope_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
source_kind: str | None = None
|
||||
read_only: bool = False
|
||||
required_scopes: list[str] = Field(default_factory=list)
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AddressBookWriteTargetsResponse(BaseModel):
|
||||
targets: list[AddressBookWriteDecisionResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AddressSyncSourceCreateRequest(BaseModel):
|
||||
connector_type: str = Field(min_length=1, max_length=60)
|
||||
display_name: str = Field(min_length=1, max_length=255)
|
||||
external_account_ref: str | None = Field(default=None, max_length=1000)
|
||||
external_address_book_ref: str | None = Field(default=None, max_length=1000)
|
||||
sync_direction: AddressSyncDirection = "read_only"
|
||||
read_only: bool | None = None
|
||||
enabled: bool = True
|
||||
sync_token: str | None = None
|
||||
etag: str | None = Field(default=None, max_length=1000)
|
||||
remote_revision: str | None = Field(default=None, max_length=1000)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AddressSyncSourceUpdateRequest(BaseModel):
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
external_account_ref: str | None = Field(default=None, max_length=1000)
|
||||
external_address_book_ref: str | None = Field(default=None, max_length=1000)
|
||||
sync_direction: AddressSyncDirection | None = None
|
||||
read_only: bool | None = None
|
||||
enabled: bool | None = None
|
||||
sync_token: str | None = None
|
||||
etag: str | None = Field(default=None, max_length=1000)
|
||||
remote_revision: str | None = Field(default=None, max_length=1000)
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AddressSyncAttemptFinishRequest(BaseModel):
|
||||
status: Literal["succeeded", "failed", "conflict"]
|
||||
sync_token: str | None = None
|
||||
etag: str | None = Field(default=None, max_length=1000)
|
||||
remote_revision: str | None = Field(default=None, max_length=1000)
|
||||
error: str | None = None
|
||||
diagnostic: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AddressSyncSourceResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
address_book_id: str
|
||||
connector_type: str
|
||||
display_name: str
|
||||
external_account_ref: str | None = None
|
||||
external_address_book_ref: str | None = None
|
||||
sync_direction: str
|
||||
read_only: bool
|
||||
enabled: bool
|
||||
status: str
|
||||
sync_token: str | None = None
|
||||
etag: str | None = None
|
||||
remote_revision: str | None = None
|
||||
last_attempted_at: datetime | None = None
|
||||
last_success_at: datetime | None = None
|
||||
last_error: str | None = None
|
||||
last_diagnostic: dict[str, Any] | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AddressSyncSourceListResponse(BaseModel):
|
||||
sync_sources: list[AddressSyncSourceResponse]
|
||||
|
||||
|
||||
class AddressSyncDiagnosticCreateRequest(BaseModel):
|
||||
severity: AddressSyncDiagnosticSeverity = "info"
|
||||
code: str = Field(min_length=1, max_length=120)
|
||||
message: str = Field(min_length=1)
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AddressSyncDiagnosticResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
sync_source_id: str
|
||||
severity: str
|
||||
code: str
|
||||
message: str
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AddressSyncDiagnosticListResponse(BaseModel):
|
||||
diagnostics: list[AddressSyncDiagnosticResponse]
|
||||
|
||||
|
||||
class AddressSyncTombstoneCreateRequest(BaseModel):
|
||||
contact_id: str | None = Field(default=None, max_length=36)
|
||||
remote_uid: str | None = Field(default=None, max_length=1000)
|
||||
resource_href: str | None = Field(default=None, max_length=1000)
|
||||
local_deleted_at: datetime | None = None
|
||||
remote_deleted_at: datetime | None = None
|
||||
synced_at: datetime | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AddressSyncTombstoneResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
sync_source_id: str
|
||||
address_book_id: str
|
||||
contact_id: str | None = None
|
||||
remote_uid: str | None = None
|
||||
resource_href: str | None = None
|
||||
local_deleted_at: datetime | None = None
|
||||
remote_deleted_at: datetime | None = None
|
||||
synced_at: datetime | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AddressSyncTombstoneListResponse(BaseModel):
|
||||
tombstones: list[AddressSyncTombstoneResponse]
|
||||
|
||||
|
||||
class AddressSyncConflictCreateRequest(BaseModel):
|
||||
contact_id: str | None = Field(default=None, max_length=36)
|
||||
remote_uid: str | None = Field(default=None, max_length=1000)
|
||||
resource_href: str | None = Field(default=None, max_length=1000)
|
||||
field_path: str = Field(min_length=1, max_length=500)
|
||||
local_value: dict[str, Any] | None = None
|
||||
remote_value: dict[str, Any] | None = None
|
||||
local_updated_at: datetime | None = None
|
||||
remote_updated_at: datetime | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AddressSyncConflictResolveRequest(BaseModel):
|
||||
resolution: AddressSyncConflictResolution
|
||||
status: Literal["resolved", "ignored"] = "resolved"
|
||||
merged_payload: ContactCreateRequest | None = None
|
||||
|
||||
|
||||
class AddressSyncConflictResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
sync_source_id: str
|
||||
address_book_id: str
|
||||
contact_id: str | None = None
|
||||
remote_uid: str | None = None
|
||||
resource_href: str | None = None
|
||||
field_path: str
|
||||
local_value: dict[str, Any] | None = None
|
||||
remote_value: dict[str, Any] | None = None
|
||||
local_updated_at: datetime | None = None
|
||||
remote_updated_at: datetime | None = None
|
||||
status: str
|
||||
resolution: str | None = None
|
||||
resolved_at: datetime | None = None
|
||||
resolved_by_account_id: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AddressSyncConflictListResponse(BaseModel):
|
||||
conflicts: list[AddressSyncConflictResponse]
|
||||
|
||||
|
||||
class AddressCardDavDiscoveryRequest(BaseModel):
|
||||
url: str = Field(min_length=1, max_length=2000)
|
||||
auth_type: AddressCardDavAuthType = "none"
|
||||
username: str | None = Field(default=None, max_length=320)
|
||||
password: SecretStr | None = None
|
||||
bearer_token: SecretStr | None = None
|
||||
credential_ref: str | None = Field(default=None, max_length=1000)
|
||||
source_id: str | None = Field(default=None, max_length=36)
|
||||
|
||||
|
||||
class AddressCardDavAddressBookResponse(BaseModel):
|
||||
collection_url: str
|
||||
href: str
|
||||
display_name: str | None = None
|
||||
description: str | None = None
|
||||
ctag: str | None = None
|
||||
sync_token: str | None = None
|
||||
|
||||
|
||||
class AddressCardDavDiscoveryResponse(BaseModel):
|
||||
address_books: list[AddressCardDavAddressBookResponse]
|
||||
|
||||
|
||||
class AddressCardDavSourceCreateRequest(BaseModel):
|
||||
collection_url: str = Field(min_length=1, max_length=2000)
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
auth_type: AddressCardDavAuthType = "none"
|
||||
username: str | None = Field(default=None, max_length=320)
|
||||
password: SecretStr | None = None
|
||||
bearer_token: SecretStr | None = None
|
||||
credential_ref: str | None = Field(default=None, max_length=1000)
|
||||
sync_direction: AddressSyncDirection = "read_only"
|
||||
read_only: bool | None = None
|
||||
sync_token: str | None = None
|
||||
etag: str | None = Field(default=None, max_length=1000)
|
||||
remote_revision: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class AddressSyncRunRequest(BaseModel):
|
||||
force_full: bool = False
|
||||
password: SecretStr | None = None
|
||||
bearer_token: SecretStr | None = None
|
||||
|
||||
|
||||
class AddressSyncPlanStats(BaseModel):
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
deleted: int = 0
|
||||
conflicts: int = 0
|
||||
unchanged: int = 0
|
||||
errors: int = 0
|
||||
fetched: int = 0
|
||||
full_sync: bool = False
|
||||
used_sync_token: bool = False
|
||||
sync_token: str | None = None
|
||||
etag: str | None = None
|
||||
remote_revision: str | None = None
|
||||
|
||||
|
||||
class AddressSyncPlanItemResponse(BaseModel):
|
||||
action: AddressSyncPlanAction
|
||||
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
|
||||
|
||||
|
||||
class AddressSyncPlanResponse(BaseModel):
|
||||
sync_source: AddressSyncSourceResponse
|
||||
stats: AddressSyncPlanStats
|
||||
items: list[AddressSyncPlanItemResponse]
|
||||
|
||||
|
||||
class VCardImportRequest(BaseModel):
|
||||
content: str = Field(min_length=1)
|
||||
|
||||
|
||||
class VCardImportIssue(BaseModel):
|
||||
index: int
|
||||
message: str
|
||||
severity: Literal["warning", "error"] = "error"
|
||||
field: str | None = None
|
||||
line: int | None = None
|
||||
|
||||
|
||||
class VCardImportResponse(BaseModel):
|
||||
imported: int
|
||||
skipped: int
|
||||
contacts: list[ContactResponse]
|
||||
issues: list[VCardImportIssue] = Field(default_factory=list)
|
||||
1993
src/govoplan_addresses/backend/service.py
Normal file
1993
src/govoplan_addresses/backend/service.py
Normal file
File diff suppressed because it is too large
Load Diff
500
src/govoplan_addresses/backend/vcard.py
Normal file
500
src/govoplan_addresses/backend/vcard.py
Normal file
@@ -0,0 +1,500 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from govoplan_addresses.backend.db.models import Contact
|
||||
from govoplan_addresses.backend.schemas import (
|
||||
ContactCreateRequest,
|
||||
ContactEmailPayload,
|
||||
ContactPhonePayload,
|
||||
ContactPostalAddressPayload,
|
||||
)
|
||||
|
||||
|
||||
class VCardError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ParsedVCard:
|
||||
payload: ContactCreateRequest
|
||||
raw: str
|
||||
source_ref: str | None = None
|
||||
source_revision: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ParsedVCardIssue:
|
||||
index: int
|
||||
message: str
|
||||
severity: Literal["warning", "error"] = "error"
|
||||
field: str | None = None
|
||||
line: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VCardParseResult:
|
||||
cards: list[ParsedVCard]
|
||||
issues: list[ParsedVCardIssue]
|
||||
skipped: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _VCardDraft:
|
||||
fn: str | None = None
|
||||
given_name: str | None = None
|
||||
family_name: str | None = None
|
||||
organization: str | None = None
|
||||
role_title: str | None = None
|
||||
note: str | None = None
|
||||
version: str | None = None
|
||||
uid: str | None = None
|
||||
revision: str | None = None
|
||||
tags: list[str] = field(default_factory=list)
|
||||
emails: list[ContactEmailPayload] = field(default_factory=list)
|
||||
phones: list[ContactPhonePayload] = field(default_factory=list)
|
||||
addresses: list[ContactPostalAddressPayload] = field(default_factory=list)
|
||||
urls: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _normalize_lines(content: str) -> list[str]:
|
||||
raw_lines = content.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
lines: list[str] = []
|
||||
for line in raw_lines:
|
||||
if line.startswith((" ", "\t")) and lines:
|
||||
lines[-1] += line[1:]
|
||||
elif line:
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def _split_unescaped(value: str, separator: str) -> list[str]:
|
||||
parts: list[str] = []
|
||||
current: list[str] = []
|
||||
escaped = False
|
||||
for char in value:
|
||||
if escaped:
|
||||
current.append(char)
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
current.append(char)
|
||||
escaped = True
|
||||
elif char == separator:
|
||||
parts.append("".join(current))
|
||||
current = []
|
||||
else:
|
||||
current.append(char)
|
||||
parts.append("".join(current))
|
||||
return parts
|
||||
|
||||
|
||||
def _unescape_text(value: str) -> str:
|
||||
return (
|
||||
value.replace("\\n", "\n")
|
||||
.replace("\\N", "\n")
|
||||
.replace("\\,", ",")
|
||||
.replace("\\;", ";")
|
||||
.replace("\\\\", "\\")
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def _escape_text(value: str | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace("\r\n", "\n")
|
||||
.replace("\r", "\n")
|
||||
.replace("\n", "\\n")
|
||||
.replace(";", "\\;")
|
||||
.replace(",", "\\,")
|
||||
)
|
||||
|
||||
|
||||
def _parse_head(head: str) -> tuple[str, dict[str, list[str]]]:
|
||||
parts = head.split(";")
|
||||
name = parts[0].split(".")[-1].upper()
|
||||
params: dict[str, list[str]] = {}
|
||||
for part in parts[1:]:
|
||||
if not part:
|
||||
continue
|
||||
if "=" in part:
|
||||
key, raw_value = part.split("=", 1)
|
||||
values = [item.strip().strip('"') for item in raw_value.split(",") if item.strip()]
|
||||
else:
|
||||
key = "TYPE"
|
||||
values = [part.strip().strip('"')]
|
||||
params.setdefault(key.upper(), []).extend(values)
|
||||
return name, params
|
||||
|
||||
|
||||
def _label_from_params(params: dict[str, list[str]]) -> str | None:
|
||||
ignored = {"INTERNET", "VOICE", "PREF"}
|
||||
for value in params.get("TYPE", []):
|
||||
normalized = value.strip().lower()
|
||||
if normalized and normalized.upper() not in ignored:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def _line_value(line: str, *, card_index: int) -> tuple[str, dict[str, list[str]], str]:
|
||||
if ":" not in line:
|
||||
raise VCardError(f"vCard {card_index}: line is missing ':' separator.")
|
||||
head, value = line.split(":", 1)
|
||||
name, params = _parse_head(head)
|
||||
return name, params, value
|
||||
|
||||
|
||||
def _is_pref(params: dict[str, list[str]]) -> bool:
|
||||
values = [value.strip().upper() for value in params.get("TYPE", [])]
|
||||
values.extend(value.strip().upper() for value in params.get("PREF", []))
|
||||
return "PREF" in values or "1" in values
|
||||
|
||||
|
||||
def _card_blocks(content: str) -> list[list[str]]:
|
||||
result = _card_blocks_with_issues(content)
|
||||
if result.issues:
|
||||
raise VCardError(result.issues[0].message)
|
||||
return result.cards
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CardBlockResult:
|
||||
cards: list[list[str]]
|
||||
issues: list[ParsedVCardIssue]
|
||||
|
||||
|
||||
def _card_blocks_with_issues(content: str) -> _CardBlockResult:
|
||||
lines = _normalize_lines(content)
|
||||
blocks: list[list[str]] = []
|
||||
issues: list[ParsedVCardIssue] = []
|
||||
current: list[str] | None = None
|
||||
for line in lines:
|
||||
card_index = len(blocks) + 1
|
||||
try:
|
||||
name, _params, value = _line_value(line, card_index=card_index)
|
||||
except VCardError as exc:
|
||||
issues.append(ParsedVCardIssue(index=card_index, message=str(exc), field="line"))
|
||||
continue
|
||||
if name == "BEGIN" and line.split(":", 1)[1].upper() == "VCARD":
|
||||
if current is not None:
|
||||
issues.append(ParsedVCardIssue(index=card_index, message="Nested vCard BEGIN is not supported."))
|
||||
current = None
|
||||
continue
|
||||
current = [line]
|
||||
elif name == "END" and value.upper() == "VCARD":
|
||||
if current is None:
|
||||
issues.append(ParsedVCardIssue(index=card_index, message="vCard END appears before BEGIN."))
|
||||
continue
|
||||
current.append(line)
|
||||
blocks.append(current)
|
||||
current = None
|
||||
elif current is not None:
|
||||
current.append(line)
|
||||
if current is not None:
|
||||
issues.append(ParsedVCardIssue(index=len(blocks) + 1, message="vCard BEGIN has no matching END."))
|
||||
if not blocks:
|
||||
issues.append(ParsedVCardIssue(index=0, message="No vCard entries found."))
|
||||
return _CardBlockResult(cards=blocks, issues=issues)
|
||||
|
||||
|
||||
def _parse_card(index: int, block: list[str]) -> tuple[ParsedVCard | None, list[ParsedVCardIssue]]:
|
||||
draft = _VCardDraft()
|
||||
issues: list[ParsedVCardIssue] = []
|
||||
|
||||
for line in block:
|
||||
parsed = _parse_card_line(index, line, issues)
|
||||
if parsed is not None:
|
||||
name, params, value = parsed
|
||||
_apply_card_property(index, draft, name, params, value, issues)
|
||||
|
||||
if not _draft_has_identity(draft):
|
||||
issues.append(ParsedVCardIssue(index=index, message=f"vCard {index}: contact has no name or email."))
|
||||
return None, issues
|
||||
|
||||
raw = "\n".join(block)
|
||||
payload = _draft_contact_payload(draft)
|
||||
return ParsedVCard(payload=payload, raw=raw, source_ref=draft.uid, source_revision=draft.revision), issues
|
||||
|
||||
|
||||
def _parse_card_line(
|
||||
index: int,
|
||||
line: str,
|
||||
issues: list[ParsedVCardIssue],
|
||||
) -> tuple[str, dict[str, list[str]], str] | None:
|
||||
try:
|
||||
return _line_value(line, card_index=index)
|
||||
except VCardError as exc:
|
||||
issues.append(ParsedVCardIssue(index=index, message=str(exc), field="line"))
|
||||
return None
|
||||
|
||||
|
||||
def _apply_card_property(
|
||||
index: int,
|
||||
draft: _VCardDraft,
|
||||
name: str,
|
||||
params: dict[str, list[str]],
|
||||
value: str,
|
||||
issues: list[ParsedVCardIssue],
|
||||
) -> None:
|
||||
if name in {"BEGIN", "END"}:
|
||||
return
|
||||
if _apply_card_metadata(index, draft, name, value, issues):
|
||||
return
|
||||
if _apply_card_identity(draft, name, value):
|
||||
return
|
||||
_apply_card_contact_detail(index, draft, name, params, value, issues)
|
||||
|
||||
|
||||
def _apply_card_metadata(
|
||||
index: int,
|
||||
draft: _VCardDraft,
|
||||
name: str,
|
||||
value: str,
|
||||
issues: list[ParsedVCardIssue],
|
||||
) -> bool:
|
||||
if name == "VERSION":
|
||||
draft.version = value.strip()
|
||||
if draft.version and draft.version not in {"3.0", "4.0"}:
|
||||
issues.append(ParsedVCardIssue(index=index, severity="warning", field="VERSION", message=f"vCard version {draft.version} is not fully supported."))
|
||||
return True
|
||||
if name == "UID":
|
||||
draft.uid = _unescape_text(value) or draft.uid
|
||||
return True
|
||||
if name == "REV":
|
||||
draft.revision = _unescape_text(value) or draft.revision
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _apply_card_identity(draft: _VCardDraft, name: str, value: str) -> bool:
|
||||
if name == "FN":
|
||||
draft.fn = _unescape_text(value)
|
||||
return True
|
||||
if name == "N":
|
||||
parts = [_unescape_text(item) for item in _split_unescaped(value, ";")]
|
||||
draft.family_name = parts[0] if len(parts) > 0 and parts[0] else draft.family_name
|
||||
draft.given_name = parts[1] if len(parts) > 1 and parts[1] else draft.given_name
|
||||
return True
|
||||
if name == "ORG":
|
||||
organization_parts = _unescaped_nonempty_values(value, ";")
|
||||
draft.organization = " / ".join(organization_parts) or draft.organization
|
||||
return True
|
||||
if name in {"TITLE", "ROLE"}:
|
||||
draft.role_title = _unescape_text(value) or draft.role_title
|
||||
return True
|
||||
if name == "NOTE":
|
||||
draft.note = _unescape_text(value) or draft.note
|
||||
return True
|
||||
if name == "CATEGORIES":
|
||||
draft.tags.extend(_unescaped_nonempty_values(value, ","))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _apply_card_contact_detail(
|
||||
index: int,
|
||||
draft: _VCardDraft,
|
||||
name: str,
|
||||
params: dict[str, list[str]],
|
||||
value: str,
|
||||
issues: list[ParsedVCardIssue],
|
||||
) -> None:
|
||||
if name == "EMAIL":
|
||||
_append_card_email(index, draft, params, value, issues)
|
||||
elif name == "TEL":
|
||||
_append_card_phone(draft, params, value)
|
||||
elif name == "ADR":
|
||||
_append_card_address(draft, params, value)
|
||||
elif name == "URL":
|
||||
url = _unescape_text(value)
|
||||
if url:
|
||||
draft.urls.append(url)
|
||||
|
||||
|
||||
def _append_card_email(
|
||||
index: int,
|
||||
draft: _VCardDraft,
|
||||
params: dict[str, list[str]],
|
||||
value: str,
|
||||
issues: list[ParsedVCardIssue],
|
||||
) -> None:
|
||||
email = _unescape_text(value)
|
||||
if not email:
|
||||
return
|
||||
if "@" not in email:
|
||||
issues.append(ParsedVCardIssue(index=index, severity="warning", field="EMAIL", message=f"Skipped invalid email address: {email}"))
|
||||
return
|
||||
draft.emails.append(ContactEmailPayload(label=_label_from_params(params), email=email, is_primary=_is_pref(params) or not draft.emails))
|
||||
|
||||
|
||||
def _append_card_phone(draft: _VCardDraft, params: dict[str, list[str]], value: str) -> None:
|
||||
phone = _unescape_text(value)
|
||||
if phone:
|
||||
draft.phones.append(ContactPhonePayload(label=_label_from_params(params), phone=phone, is_primary=_is_pref(params) or not draft.phones))
|
||||
|
||||
|
||||
def _append_card_address(draft: _VCardDraft, params: dict[str, list[str]], value: str) -> None:
|
||||
parts = [_unescape_text(item) for item in _split_unescaped(value, ";")]
|
||||
while len(parts) < 7:
|
||||
parts.append("")
|
||||
if not any(parts):
|
||||
return
|
||||
street = "\n".join(part for part in (parts[1], parts[2]) if part)
|
||||
draft.addresses.append(
|
||||
ContactPostalAddressPayload(
|
||||
label=_label_from_params(params),
|
||||
street=street or None,
|
||||
locality=parts[3] or None,
|
||||
region=parts[4] or None,
|
||||
postal_code=parts[5] or None,
|
||||
country=parts[6] or None,
|
||||
is_primary=_is_pref(params) or not draft.addresses,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _unescaped_nonempty_values(value: str, separator: str) -> list[str]:
|
||||
return [text for text in (_unescape_text(item) for item in _split_unescaped(value, separator)) if text]
|
||||
|
||||
|
||||
def _draft_has_identity(draft: _VCardDraft) -> bool:
|
||||
return bool(draft.fn or draft.given_name or draft.family_name or draft.emails)
|
||||
|
||||
|
||||
def _draft_contact_payload(draft: _VCardDraft) -> ContactCreateRequest:
|
||||
payload = ContactCreateRequest(
|
||||
display_name=draft.fn,
|
||||
given_name=draft.given_name,
|
||||
family_name=draft.family_name,
|
||||
organization=draft.organization,
|
||||
role_title=draft.role_title,
|
||||
note=draft.note,
|
||||
tags=draft.tags,
|
||||
emails=draft.emails,
|
||||
phones=draft.phones,
|
||||
postal_addresses=draft.addresses,
|
||||
provenance={
|
||||
"vcard": {
|
||||
"version": draft.version,
|
||||
"uid": draft.uid,
|
||||
"revision": draft.revision,
|
||||
"urls": draft.urls,
|
||||
}
|
||||
},
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def parse_vcards_with_issues(content: str) -> VCardParseResult:
|
||||
blocks = _card_blocks_with_issues(content)
|
||||
parsed: list[ParsedVCard] = []
|
||||
issues = list(blocks.issues)
|
||||
skipped = 0
|
||||
for index, block in enumerate(blocks.cards, start=1):
|
||||
card, card_issues = _parse_card(index, block)
|
||||
issues.extend(card_issues)
|
||||
if card is None:
|
||||
skipped += 1
|
||||
else:
|
||||
parsed.append(card)
|
||||
return VCardParseResult(cards=parsed, issues=issues, skipped=skipped)
|
||||
|
||||
|
||||
def parse_vcards(content: str) -> list[ParsedVCard]:
|
||||
result = parse_vcards_with_issues(content)
|
||||
errors = [issue for issue in result.issues if issue.severity == "error"]
|
||||
if errors:
|
||||
raise VCardError(errors[0].message)
|
||||
return result.cards
|
||||
|
||||
|
||||
def contact_to_vcard(contact: Contact) -> str:
|
||||
lines = _contact_identity_lines(contact)
|
||||
lines.extend(_contact_email_lines(contact))
|
||||
lines.extend(_contact_phone_lines(contact))
|
||||
lines.extend(_contact_address_lines(contact))
|
||||
lines.extend(_contact_note_and_tag_lines(contact))
|
||||
lines.extend(_contact_url_lines(contact))
|
||||
lines.append("END:VCARD")
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
|
||||
def _contact_identity_lines(contact: Contact) -> list[str]:
|
||||
lines = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
f"FN:{_escape_text(contact.display_name)}",
|
||||
f"N:{_escape_text(contact.family_name)};{_escape_text(contact.given_name)};;;",
|
||||
]
|
||||
if contact.source_ref:
|
||||
lines.append(f"UID:{_escape_text(contact.source_ref)}")
|
||||
if contact.source_revision:
|
||||
lines.append(f"REV:{_escape_text(contact.source_revision)}")
|
||||
if contact.organization:
|
||||
lines.append(f"ORG:{_escape_text(contact.organization)}")
|
||||
if contact.role_title:
|
||||
lines.append(f"TITLE:{_escape_text(contact.role_title)}")
|
||||
return lines
|
||||
|
||||
|
||||
def _contact_email_lines(contact: Contact) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for email in contact.emails:
|
||||
label = f";TYPE={_escape_text(email.label)}" if email.label else ""
|
||||
lines.append(f"EMAIL{label}:{_escape_text(email.email)}")
|
||||
return lines
|
||||
|
||||
|
||||
def _contact_phone_lines(contact: Contact) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for phone in contact.phones:
|
||||
label = f";TYPE={_escape_text(phone.label)}" if phone.label else ""
|
||||
lines.append(f"TEL{label}:{_escape_text(phone.phone)}")
|
||||
return lines
|
||||
|
||||
|
||||
def _contact_address_lines(contact: Contact) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for address in contact.postal_addresses:
|
||||
label = f";TYPE={_escape_text(address.label)}" if address.label else ""
|
||||
lines.append(
|
||||
"ADR"
|
||||
f"{label}:;;{_escape_text(address.street)};{_escape_text(address.locality)};"
|
||||
f"{_escape_text(address.region)};{_escape_text(address.postal_code)};{_escape_text(address.country)}"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def _contact_note_and_tag_lines(contact: Contact) -> list[str]:
|
||||
lines: list[str] = []
|
||||
if contact.note:
|
||||
lines.append(f"NOTE:{_escape_text(contact.note)}")
|
||||
if contact.tags:
|
||||
lines.append(f"CATEGORIES:{','.join(_escape_text(tag) for tag in contact.tags)}")
|
||||
return lines
|
||||
|
||||
|
||||
def _contact_url_lines(contact: Contact) -> list[str]:
|
||||
lines: list[str] = []
|
||||
urls = _contact_vcard_urls(contact)
|
||||
if isinstance(urls, list):
|
||||
for url in urls:
|
||||
if isinstance(url, str) and url.strip():
|
||||
lines.append(f"URL:{_escape_text(url.strip())}")
|
||||
return lines
|
||||
|
||||
|
||||
def _contact_vcard_urls(contact: Contact) -> object:
|
||||
if not isinstance(contact.provenance, dict):
|
||||
return None
|
||||
vcard = contact.provenance.get("vcard")
|
||||
if not isinstance(vcard, dict):
|
||||
return None
|
||||
return vcard.get("urls")
|
||||
|
||||
|
||||
def contacts_to_vcard(contacts: list[Contact]) -> str:
|
||||
return "".join(contact_to_vcard(contact) for contact in contacts)
|
||||
Reference in New Issue
Block a user