intermittent commit

This commit is contained in:
2026-07-14 13:22:09 +02:00
parent 420120af2f
commit f19350e65d
32 changed files with 11772 additions and 11 deletions

View File

@@ -0,0 +1,2 @@
"""GovOPlaN addresses module."""

View File

@@ -0,0 +1,2 @@
"""Backend integration for the GovOPlaN addresses module."""

View 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()

View 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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

View File

@@ -0,0 +1,2 @@
"""Address module database models."""

View 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",
]

View 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

View File

@@ -0,0 +1,2 @@
"""Address module migration package."""

View File

@@ -0,0 +1,2 @@
"""Address module Alembic revisions."""

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

File diff suppressed because it is too large Load Diff

View 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)

File diff suppressed because it is too large Load Diff

View 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)

View File

@@ -0,0 +1 @@