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

@@ -4,17 +4,48 @@
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`govoplan-addresses` is the planned reusable address and recipient-source
module. It should own long-lived address directories and make them available to
campaigns, mail, forms, reporting, portal, and postbox modules through platform
`govoplan-addresses` is the reusable address and recipient-source module. It
owns long-lived address directories and makes them available to campaigns,
mail, forms, reporting, portal, and postbox modules through platform
capabilities.
The campaign module may import campaign-local recipient tables, but reusable
address management belongs here.
## Current State
Milestone 1 is implemented. The module now owns persistent local address books
and contact CRUD under `/api/v1/addresses`, contributes `/address-book` to the
WebUI, and registers address permissions, role templates, database migrations,
tenant summaries, and uninstall guards.
The first UI supports user, group, tenant, and system-scoped address books,
multi-value contact methods, soft deletion, restore, read-only lookup/search,
and vCard import/export for common contact fields. Imported vCards preserve
source payload and revision metadata for later sync/conflict work.
The backend and WebUI also support classical address lists: reusable groupings
of contacts or specific contact methods within one address book. Campaigns can
import address books and address lists through the core-mediated
`addresses.recipient_source` capability without importing address-module
internals. Broader operational `Verteiler` with mixed users, identities,
groups, functions, raw recipients, and nested lists belong in
`govoplan-dist-lists`.
The backend now also contains connector-neutral sync infrastructure. Address
books can be bound to external sources, sync attempts can record status,
tokens, ETags, revisions, diagnostics, tombstones, and conflicts, and read-only
or one-way-import sources make the owning address book read-only for normal
write paths. CardDAV discovery, source binding, dry-run preview, inbound vCard
sync, outbound create/update/delete for writable CardDAV sources, diagnostics,
tombstones, conflict persistence, source disconnect/delete UX, and a first sync
inspection UI are implemented. The conflict review UI compares stored local and
remote field payloads, can apply a stored remote vCard payload, and supports
manual per-field local/remote merge choices.
## Boundary
`govoplan-addresses` should own:
`govoplan-addresses` owns:
- Adrema-style person, organization, household, and postal-address records
- reusable email address lists and postal-letter recipient views
@@ -31,21 +62,46 @@ It must not own:
- SMTP/IMAP transport
- file storage
- global identity authentication or RBAC evaluation
- operational distribution lists/`Verteiler` with mixed recipient types
## First Capability
## First Capabilities
The first useful contract should be a read-only recipient-source capability,
for example `addresses.recipientSource`.
The module exposes three core-mediated capabilities:
It should let a consumer request a stable snapshot containing:
- `addresses.lookup`: read-only contact/recipient lookup for autocomplete.
- `addresses.recipient_source`: immutable recipient snapshots for campaign,
reporting, mail-build, forms, portal, and postbox workflows.
- `addresses.contact_writer`: address-book-scoped write decisions and contact
creation for local or otherwise writable sources.
`addresses.recipient_source` returns:
- source id and display label
- normalized recipient rows
- email and postal address fields
- legal-basis and consent metadata
- email recipient fields
- source update marker
- provenance fields suitable for audit and campaign reports
Recipient sources currently include complete address books and classical
address lists. Address-list source IDs use `addresses:address_list:<id>` and
preserve the address-list entry ID in recipient provenance. Address-list entries
may point at a whole contact, a concrete email address, or a concrete postal
address. Email-oriented consumers snapshot email targets and whole-contact
entries with a usable email address; postal-only entries remain valid list
members for later postal/document workflows.
Consumers must store their own immutable snapshot when they need historical
evidence. The addresses module remains the owner of the reusable source, not of
the consumer's historical records.
the consumer's historical records. Consumers must resolve these capabilities
through the platform registry and must not import address ORM/service internals.
`addresses.contact_writer` returns an explicit decision before a consumer shows
or executes write actions: allowed/blocked, reason, user-facing message,
required scopes, source kind, read-only state, and provenance. The decision is
address-book specific; broader policy modules may later contribute to the same
decision path, but consumers should not import or duplicate policy logic.
## Design Documents
- [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md)
- [Implementation plan](docs/IMPLEMENTATION_PLAN.md)

View File

@@ -0,0 +1,193 @@
# GovOPlaN Addresses Module Architecture
## Decision
`govoplan-addresses` owns reusable contact and recipient-source data. Campaigns,
mail, scheduling, portal, postbox, forms, reporting, and other modules consume
address data through core-mediated capabilities and APIs, not by importing
address-module internals.
The implementation reference for contact data is vCard. CardDAV is the primary
address-book sync protocol. LDAP/Active Directory, Exchange/Microsoft 365,
Google Contacts, CSV/XLSX, LDIF, and batch vCard import/export are connector
targets layered on top of the same local model and sync contracts.
## Ownership
`govoplan-addresses` owns:
- scoped address books
- contacts, organizations, households, and postal/email/phone address data
- vCard import/export and vCard-compatible field mapping
- reusable recipient sources and classical address lists
- contact tags, categories, communication preferences, consent, and legal basis
- deduplication, merge, address quality checks, and suppression lists
- contact provenance, audit history, soft delete, and restore
- external-source bindings, sync state, conflicts, and read-only source markers
It does not own:
- campaign-local recipient snapshots and evidence
- mail transport, mailbox access, or delivery queues
- calendar events or iCalendar event storage
- global identity authentication or authorization decisions
- organization structure or internal function assignments
- operational distribution lists/`Verteiler` with mixed users, identities,
groups, functions, roles, raw recipients, and nested lists
## Scopes
Address books can live in these scopes:
- `user`: personal address books and remembered contacts
- `group`: team/shared address books
- `tenant`: tenant-wide directories and approved shared lists
- `system`: platform-wide public/shared directories where policy allows it
The scope determines visibility, default permissions, sync credentials, and
whether downstream modules may reuse or mutate entries.
## Data Model Principles
The canonical model should preserve enough vCard semantics to round-trip common
fields:
- name components and formatted names
- nicknames and display names
- email addresses, phone numbers, postal addresses, URLs, notes, categories
- organizations, titles, roles, departments, and relationships
- birthday/anniversary where allowed by policy
- photos/avatars where storage and privacy policy allow them
- calendar or scheduling addresses where present
- source IDs, revisions, ETags, sync tokens, and provenance
The model should support both normalized query fields and a preserved original
representation for import/export and conflict handling.
The local baseline implements scoped address books, contacts, normalized
email/phone/postal-address tables, tags, source kind/reference fields,
first-class source payload/revision fields, and provenance JSON. Imported
vCards preserve raw source payload and revision metadata for audit/debugging.
Sync sources, attempt state, tombstones, conflicts, and diagnostics are now
first-class backend tables and API resources. Connector-specific diffing,
CardDAV discovery, and conflict-resolution UI remain part of the connector
milestones.
## Capabilities
The first stable capabilities are:
- `addresses.recipient_source`: return immutable recipient snapshots for
campaigns, forms, reporting, and other send/build workflows.
- `addresses.lookup`: provide read-only lookup and autocomplete for mail,
campaign, scheduling, postbox, portal, and case workflows.
- `addresses.contact_writer`: provide address-book-scoped write target decisions
and contact creation for local or otherwise writable sources.
Capabilities use DTOs and source IDs. Consumers must not receive ORM objects or
write address tables directly. Consumers that need historical evidence must
store their own immutable snapshot with source ID, source revision, and
provenance; they must not treat live address records as historical evidence.
`addresses.recipient_source` exposes both complete address books and classical
address lists. Address-book sources use `addresses:address_book:<id>`.
Address-list sources use `addresses:address_list:<id>` and include the
address-list entry ID in each recipient's provenance. The current snapshot DTO
is email-recipient oriented; postal-only list entries are valid address-list
members but are skipped by the email recipient-source path until postal
recipient DTOs are added.
The writer capability is intentionally address-book specific. It answers
whether the current principal may perform an operation such as `create_contact`,
`update_contact`, or `delete_contact` against a concrete address book. The
decision payload includes:
- `allowed`
- stable `reason`
- user-facing `message`
- required scopes
- source kind
- read-only state
- scope and tenant provenance
Policy modules or connector sync state may later add inputs to this decision,
but consumers must continue to call the address capability/API instead of
importing policy logic or address services directly. Disabled or read-only UI
actions should surface the returned `message` on hover.
## Sync Model
Every synced address book tracks or can track:
- connector type and external account/source
- external address-book ID and display name
- local address-book scope
- sync direction: read-only, one-way import, one-way export, two-way
- sync token, ETag/revision, last successful sync, last attempted sync
- deleted markers/tombstones
- conflict status and resolution decision
- connector diagnostics and rate-limit/backoff state
Sync conflict UX must show the local value, remote value, source, timestamp, and
available action. Silent overwrite is not acceptable.
Sync infrastructure is intentionally connector-neutral. CardDAV, LDAP,
Exchange/Microsoft 365, Google Contacts, CSV/XLSX/LDIF import profiles, and
future connectors must write through `addresses_sync_sources` and related
records instead of inventing connector-specific status tables. Connector jobs
may mark a source `running`, `succeeded`, `failed`, or `conflict`; read-only and
one-way-import sources propagate a read-only decision to the owning address
book, which in turn blocks normal contact writes through the existing writer
capability/API.
The first CardDAV implementation supports discovery, source binding, dry-run
preview, inbound vCard sync, outbound create/update/delete for writable
sources, sync-token/full-sync fallback, tombstones, diagnostics, and persisted
conflicts. Outbound writes use ETag preconditions; stale local state must become
a conflict instead of silently overwriting remote data. The first conflict
review UI compares stored local and remote field payloads and can apply a
stored remote vCard payload or a manual per-field local/remote merge payload.
Source disconnect/delete removes the source binding and related sync records
while keeping local contacts.
## Connector Direction
Implement connectors in this order:
1. vCard import/export and batch import.
2. CardDAV address books.
3. LDAP/Active Directory read-only directories.
4. Exchange/Microsoft 365 and Google Contacts.
5. CSV/XLSX/LDIF import mapping profiles.
Connector runtime behavior should reuse shared connector concepts where useful:
configured endpoints, credentials, dry-run, diagnostics, rate limits, and audit
events.
## Cross-Module Integration
Campaigns should consume `addresses.recipient_source` through the platform
registry and freeze snapshots into campaign versions. Mail should consume
`addresses.lookup` for autocomplete and `addresses.contact_writer` for "add
contact" workflows. Scheduling should use lookup for attendees and organizers.
Portal, postbox, cases, forms, and reporting should link to contact records by
stable IDs while keeping their own domain evidence. Cross-module UI must hide
write actions when no writable target exists, or show the writer decision
message when a disabled action remains visible for context.
Operational distribution lists belong in `govoplan-dist-lists`. They may later
consume address lists as one entry type, but they own mixed recipient expansion
for users, identities, organization units, groups, functions, roles, raw
recipients, and nested lists. Workflow and Tasks own `Umlauf` execution state;
distribution lists define who is included, not how work circulates.
## Deferred Work
The following are valuable but not required for the first functional milestone:
- automatic deduplication and merge suggestions
- two-way sync conflict UI
- Microsoft/Google connectors
- household and relationship editing
- advanced consent-policy automation
- contact activity timeline across all modules

236
docs/IMPLEMENTATION_PLAN.md Normal file
View File

@@ -0,0 +1,236 @@
# GovOPlaN Addresses Implementation Plan
## Milestone 1: Functional Local Address Books
Goal: make `govoplan-addresses` useful without external sync.
Primary issue: `govoplan-addresses#3`.
Status: implemented. The persistent backend, router, migration, permissions,
role templates, lookup endpoint, restore support, multi-value contact editor,
and first WebUI are in place.
Tasks:
- [x] add backend tables and migrations for address books, contacts, contact
methods, postal addresses, tags, and provenance
- [x] add permissions and role templates for viewing and managing address books
- [x] implement address-book CRUD API
- [x] implement contact CRUD API
- [x] implement scoped WebUI views for user, group, tenant, and system books
- [x] support soft delete
- [x] add restore API/UI for soft-deleted address books and contacts
- [x] expose read-only contact lookup for the WebUI
Exit criteria:
- [x] an admin can create tenant/system books
- [x] a user can create a personal book and contacts
- [x] address data survives restart and appears in the address-book UI
- [x] disabled or read-only actions explain why they are unavailable
## Milestone 2: vCard Foundation
Goal: make the model standards-based before adding sync.
Primary issue: `govoplan-addresses#4`.
Status: implemented. The module can import and export common vCard fields for
local books, preserves imported raw vCard payload and revision metadata in
first-class source fields, reports field/card import issues, and has round-trip
and partial-import tests. Full two-way connector conflict resolution remains in
the sync milestones.
Tasks:
- [x] define vCard-compatible DTOs
- [x] preserve original imported vCard data and normalized query fields
- [x] import `.vcf` files into a selected address book
- [x] export contacts/address books as vCard
- [x] add validation for common vCard fields
- [x] add tests for round-trip import/export of names, emails, phones, postal
addresses, organization fields, notes, categories, and URLs
- [x] add field/card validation messages for batch import
- [x] promote original payload and revision handling into first-class contact
source fields
Exit criteria:
- [x] common vCard files can be imported and exported without losing core fields
- [x] normalized search fields are populated during import
- [x] invalid entries produce field-level actionable validation messages
## Milestone 3: Core Capabilities
Goal: allow other modules to use addresses without dependencies.
Primary issue: `govoplan-addresses#5`.
Tasks:
- [x] implement `addresses.lookup`
- [x] implement `addresses.recipient_source`
- [x] define immutable recipient snapshot DTOs
- [x] expose source provenance in capability responses
- [x] expose classical address lists as `addresses.recipient_source` sources
- [x] add module presence/capability tests
- [x] document consumer rules for campaign, mail, scheduling, portal, postbox, and
reporting
Exit criteria:
- [x] campaign can request a recipient source via core-mediated capability
- [x] mail/scheduling can request autocomplete candidates via core-mediated lookup
- [x] consumers do not import `govoplan_addresses`
## Milestone 4: Campaign Integration
Goal: replace campaign-local reusable address concepts with address-module
sources.
Primary issue: `govoplan-campaign#55`.
Tasks:
- [x] add campaign recipient-source picker when `addresses.recipient_source` exists
- [x] snapshot selected address-source rows into the campaign version
- [x] store source ID, revision, and provenance in campaign evidence
- [x] show stale-source warnings when an address source changed after selection
- [x] keep campaign import for one-off local recipient data
- [x] define classical address lists as reusable address-domain sources
- [x] add address-list selection and management UI in addresses/campaign
- [ ] define segments/dynamic filters before exposing them as campaign sources
Deferred deliberately: classical address lists are stable now; dynamic
segments need their own filter model, stale-source semantics, and audit
evidence before campaign can snapshot them safely.
Exit criteria:
- [x] campaign works without addresses installed
- [x] campaign offers address-source selection when addresses is installed
- [x] built campaigns remain auditable after source contacts change
## Milestone 5: Mail And Scheduling Integration
Goal: make contacts visible where users naturally need them.
Primary issues: `govoplan-mail#13` and `govoplan-scheduling#2`.
Tasks:
- [x] add mail recipient/autocomplete integration through `addresses.lookup`
at API/capability level; visible compose UI reuse follows when mail
compose exists
- [x] expose `addresses.contact_writer` for explicit address-book write
decisions, required scopes, read-only/source reasons, and provenance
- [ ] add "add sender/contact" actions in consuming UIs by using
`addresses.contact_writer`
- [x] add scheduling attendee/organizer lookup integration
- [x] preserve module independence when addresses is absent
Exit criteria:
- [x] mail and scheduling build without addresses installed
- [x] when addresses is installed, lookup improves recipient/attendee entry
- [x] address write decisions expose why actions are unavailable for read-only
sources, deleted books, missing scopes, or unsupported operations
- [ ] consuming UI write actions are hidden or explained with the
`addresses.contact_writer` decision payload
## Milestone 6: Sync Infrastructure
Goal: prepare external address books without committing to all connectors at
once.
Primary issue: `govoplan-addresses#6`.
Status: implemented for the first connector path. The connector-neutral backend
substrate is in place: sync sources, source status, read-only propagation,
tombstones, conflicts, diagnostics, attempt transitions, dry-run preview,
audit-event emission, and a first sync inspection UI.
Tasks:
- [x] add address-source connector configuration
- [x] add sync state, ETags, revisions, tokens, tombstones, and conflict records
- [x] add sync diagnostics
- [x] add audit events for sync attempts, conflicts, previews, completions, and
resolutions
- [x] support read-only and writable source flags
- [x] add dry-run and preview for connector changes
- [x] add admin/user UI for source status, diagnostics, tombstones, and conflicts
Exit criteria:
- [x] a connector can report planned creates/updates/deletes before applying them
- [x] sync failures are visible in the UI
- [x] conflicts are persisted and do not silently overwrite data
## Milestone 7: CardDAV
Goal: implement the first real standards-based sync connector.
Primary issue: `govoplan-addresses#7`.
Status: implemented for the first standards-based sync path. Discovery, source
binding, full/sync-token inbound sync, outbound create/update/delete for
writable sources, dry-run preview, diagnostics, tombstones, and persisted
conflict records are implemented. Source disconnect/delete UX and conflict
review are implemented; stored remote vCard payloads can be applied from the
review UI, and manual per-field local/remote merge choices are supported.
Tasks:
- [x] add CardDAV account/address-book discovery
- [x] sync vCard resources into scoped address books
- [x] support read-only and writable source flags
- [x] push local creates, updates, and deletes to writable CardDAV sources with
ETag preconditions
- [x] handle ETag changes, deletes, and conflicts
- [x] add connection discovery and diagnostics
- [x] add source disconnect/delete UX that keeps local contacts
- [x] add conflict review UI with local/remote field comparison
- [x] apply stored remote vCard payloads from conflict resolution
- [x] support manual per-field local/remote merge choices for stored vCard payloads
Exit criteria:
- [x] a CardDAV source can be connected, synced, and inspected
- [x] CardDAV source disconnect/delete UI
- [x] contacts can be refreshed without duplicating entries
- [x] conflict and permission states are visible to the user
- [x] outbound CardDAV writes for writable remote books
- [x] field-level conflict review UI
- [x] manual per-field merge editing
## Milestone 8: Additional Connectors And Advanced Address Features
Goal: expand beyond CardDAV after the model and sync engine are stable.
Primary issues: `govoplan-addresses#8`, `govoplan-addresses#9`,
`govoplan-addresses#10`, and `govoplan-connectors#8`.
Tasks:
- LDAP/Active Directory read-only directory connector
- Exchange/Microsoft 365 contacts connector
- Google Contacts connector
- CSV/XLSX/LDIF import mapping profiles
- classical address-list UI and static/dynamic address-domain segments
- operational distribution lists move to `govoplan-dist-lists`
- consent, legal-basis, suppression, and communication preferences
- deduplication and merge workflow
- address quality checks and normalization
- relationship/household/organization editing
Exit criteria:
- each connector or advanced feature can be enabled independently
- users can understand where data came from and whether they may edit it
- downstream modules can safely use contacts without owning them
## First Implementation Recommendation
Start with Milestone 1 and enough of Milestone 2 to define the data model
correctly. Do not start CardDAV before the local vCard-compatible storage and
API are stable.

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/addresses.css": "./webui/src/styles/addresses.css"
},
"files": [
"webui/src",
"README.md"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

24
pyproject.toml Normal file
View File

@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-addresses"
version = "0.1.8"
description = "GovOPlaN reusable address and recipient-source module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"defusedxml>=0.7.1",
"govoplan-core>=0.1.8",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_addresses = ["py.typed"]
[project.entry-points."govoplan.modules"]
addresses = "govoplan_addresses.backend.manifest:get_manifest"

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

View File

@@ -0,0 +1,901 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
from govoplan_core.db.base import utcnow
from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult
from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_CONTACT_WRITER,
CAPABILITY_ADDRESSES_LOOKUP,
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
AddressesContactWriterCapability,
AddressesLookupCapability,
AddressesRecipientSourceCapability,
AddressWriterError,
)
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressList,
AddressListEntry,
AddressSyncConflict,
AddressSyncDiagnostic,
AddressSyncSource,
AddressSyncTombstone,
Contact,
ContactEmail,
ContactPhone,
ContactPostalAddress,
)
from govoplan_addresses.backend.schemas import (
AddressBookCreateRequest,
AddressListCreateRequest,
AddressListEntryCreateRequest,
AddressCardDavSourceCreateRequest,
AddressSyncAttemptFinishRequest,
AddressSyncConflictCreateRequest,
AddressSyncConflictResolveRequest,
AddressSyncDiagnosticCreateRequest,
AddressSyncSourceCreateRequest,
AddressSyncSourceUpdateRequest,
AddressSyncTombstoneCreateRequest,
ContactCreateRequest,
ContactEmailPayload,
ContactPostalAddressPayload,
)
from govoplan_addresses.backend.manifest import manifest
from govoplan_addresses.backend.service import (
address_book_contact_counts,
address_list_entry_counts,
create_address_book,
create_address_list,
create_address_list_entry,
create_carddav_sync_source,
create_contact,
create_sync_source,
delete_address_list_entry,
delete_contact,
delete_sync_source,
export_address_book_vcard,
import_vcards,
list_address_list_entries,
list_address_lists,
list_address_books,
list_contacts,
list_sync_conflicts,
list_sync_diagnostics,
list_sync_sources,
list_sync_tombstones,
record_sync_conflict,
record_sync_diagnostic,
record_sync_tombstone,
run_sync_source,
preview_sync_source,
restore_contact,
resolve_sync_conflict,
start_sync_attempt,
finish_sync_attempt,
update_sync_source,
)
class FakeCardDavClient:
def __init__(self, objects: list[AddressCardDAVObject], *, sync_token: str = "sync-token-1", ctag: str = "ctag-1") -> None:
self.objects = objects
self.sync_token = sync_token
self.ctag = ctag
self.puts: list[dict[str, object]] = []
self.deletes: list[dict[str, object]] = []
def propfind_collection(self) -> AddressCardDAVReportResult:
return AddressCardDAVReportResult(sync_token=self.sync_token, ctag=self.ctag)
def list_objects(self) -> AddressCardDAVReportResult:
return AddressCardDAVReportResult(objects=self.objects, sync_token=self.sync_token, ctag=self.ctag)
def sync_collection(self, _sync_token: str) -> AddressCardDAVReportResult:
return self.list_objects()
def fetch_object(self, href: str) -> str:
for item in self.objects:
if item.href == href and item.address_data:
return item.address_data
raise RuntimeError(f"missing fake CardDAV object: {href}")
def put_object(self, href: str, vcard: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> AddressCardDAVWriteResult:
self.puts.append({"href": href, "vcard": vcard, "etag": etag, "create": create, "overwrite": overwrite})
return AddressCardDAVWriteResult(href=href, etag=f'"written-{len(self.puts)}"', status=201 if create else 204)
def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> AddressCardDAVWriteResult:
self.deletes.append({"href": href, "etag": etag, "overwrite": overwrite})
return AddressCardDAVWriteResult(href=href, etag=None, status=204)
class Principal:
account_id = "account-1"
group_ids = frozenset({"group-1"})
@property
def tenant_id(self) -> str:
return "tenant-1"
def has(self, scope: str) -> bool:
return scope in {
"addresses:address_book:read",
"addresses:address_book:write",
"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",
}
class ReadOnlyPrincipal(Principal):
def has(self, scope: str) -> bool:
return scope in {
"addresses:address_book:read",
"addresses:contact:read",
}
class AddressServiceTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
AddressBook.__table__,
AddressList.__table__,
Contact.__table__,
ContactEmail.__table__,
ContactPhone.__table__,
ContactPostalAddress.__table__,
AddressListEntry.__table__,
AddressSyncSource.__table__,
AddressSyncTombstone.__table__,
AddressSyncConflict.__table__,
AddressSyncDiagnostic.__table__,
ChangeSequenceEntry.__table__,
],
)
self.session = sessionmaker(bind=engine)()
self.principal = Principal()
def test_scoped_book_contact_lookup_and_soft_delete(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal"))
self.session.commit()
self.session.refresh(book)
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(given_name="Ada", family_name="Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]),
)
self.session.commit()
self.session.refresh(contact)
self.assertEqual([item.id for item in list_address_books(self.session, self.principal)], [book.id])
self.assertEqual(address_book_contact_counts(self.session, [book.id])[book.id], 1)
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, query="ada")], [contact.id])
self.assertEqual(contact.emails[0].email, "ada@example.local")
self.assertTrue(contact.emails[0].is_primary)
delete_contact(self.session, self.principal, contact.id)
self.session.commit()
self.assertEqual(list_contacts(self.session, self.principal, address_book_id=book.id), [])
self.assertEqual(address_book_contact_counts(self.session, [book.id]).get(book.id, 0), 0)
archived = list_contacts(self.session, self.principal, address_book_id=book.id, include_deleted=True)
self.assertEqual([item.id for item in archived], [contact.id])
restore_contact(self.session, self.principal, contact.id)
self.session.commit()
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
def test_vcard_import_and_export_preserves_common_fields(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported"))
self.session.commit()
self.session.refresh(book)
result = import_vcards(
self.session,
self.principal,
book.id,
"""BEGIN:VCARD
VERSION:4.0
FN:Ada Lovelace
N:Lovelace;Ada;;;
ORG:Analytical Engine Office
TITLE:Mathematician
EMAIL;TYPE=work:ada@example.local
TEL;TYPE=work:+49 30 123
ADR;TYPE=work:;;Main Street 1;Berlin;BE;10115;Germany
CATEGORIES:science,engineering
NOTE:First programmer
URL:https://example.local/ada
END:VCARD
""",
)
self.session.commit()
self.session.refresh(result.contacts[0])
self.assertEqual(result.skipped, 0)
self.assertEqual(result.issues, [])
contact = result.contacts[0]
self.assertEqual(contact.display_name, "Ada Lovelace")
self.assertEqual(contact.organization, "Analytical Engine Office")
self.assertEqual(contact.role_title, "Mathematician")
self.assertEqual(contact.tags, ["science", "engineering"])
self.assertEqual(contact.emails[0].label, "work")
self.assertEqual(contact.phones[0].phone, "+49 30 123")
self.assertEqual(contact.postal_addresses[0].locality, "Berlin")
self.assertEqual(contact.source_kind, "vcard")
self.assertEqual(contact.source_payload_kind, "vcard")
self.assertIn("BEGIN:VCARD", contact.source_payload_raw)
self.assertEqual(contact.provenance["vcard"]["urls"], ["https://example.local/ada"])
_book, content = export_address_book_vcard(self.session, self.principal, book.id)
self.assertIn("BEGIN:VCARD", content)
self.assertIn("FN:Ada Lovelace", content)
self.assertIn("EMAIL;TYPE=work:ada@example.local", content)
self.assertIn("CATEGORIES:science,engineering", content)
self.assertIn("URL:https://example.local/ada", content)
def test_vcard_import_reports_field_issues_without_blocking_valid_cards(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported"))
self.session.commit()
self.session.refresh(book)
result = import_vcards(
self.session,
self.principal,
book.id,
"""BEGIN:VCARD
VERSION:4.0
FN:Valid Person
EMAIL:not-an-email
EMAIL;TYPE=work:valid@example.local
END:VCARD
BEGIN:VCARD
VERSION:4.0
NOTE:No identity
END:VCARD
""",
)
self.session.commit()
self.assertEqual(len(result.contacts), 1)
self.assertEqual(result.skipped, 1)
self.assertEqual(result.contacts[0].emails[0].email, "valid@example.local")
self.assertTrue(any(issue.field == "EMAIL" and issue.severity == "warning" for issue in result.issues))
self.assertTrue(any(issue.severity == "error" for issue in result.issues))
def test_manifest_and_address_capabilities(self) -> None:
provided = {item.name for item in manifest.provides_interfaces}
self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided)
self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided)
self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided)
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients"))
self.session.commit()
self.session.refresh(book)
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(display_name="Ada Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]),
)
self.session.commit()
self.session.refresh(contact)
lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="ada")
self.assertEqual(len(lookup), 1)
self.assertEqual(lookup[0].email, "ada@example.local")
self.assertEqual(lookup[0].contact_id, contact.id)
warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10)
self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"])
recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal)
self.assertEqual(len(recipient_sources), 1)
self.assertEqual(recipient_sources[0].source_id, f"addresses:address_book:{book.id}")
self.assertEqual(recipient_sources[0].recipient_count, 1)
snapshot = AddressesRecipientSourceCapability().snapshot_address_book(self.session, self.principal, address_book_id=book.id)
self.assertEqual(snapshot.source_id, f"addresses:address_book:{book.id}")
self.assertEqual(snapshot.recipients[0].email, "ada@example.local")
self.assertEqual(snapshot.recipients[0].provenance["contact_id"], contact.id)
writer = AddressesContactWriterCapability()
targets = writer.list_write_targets(self.session, self.principal)
self.assertEqual(len(targets), 1)
self.assertTrue(targets[0].allowed)
self.assertEqual(targets[0].reason, "allowed")
self.assertEqual(targets[0].required_scopes, ("addresses:address_book:read", "addresses:contact:write"))
created = writer.create_contact(
self.session,
self.principal,
address_book_id=book.id,
payload=ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]),
provenance={"consumer_module": "test"},
)
self.assertEqual(created.email, "grace@example.local")
self.assertEqual(created.provenance["source_kind"], "local")
self.assertEqual(created.provenance["contact_provenance"]["consumer_module"], "test")
self.session.commit()
missing_scope = writer.can_write_to_address_book(self.session, ReadOnlyPrincipal(), address_book_id=book.id)
self.assertFalse(missing_scope.allowed)
self.assertEqual(missing_scope.reason, "missing_scope")
self.assertEqual(missing_scope.required_scopes, ("addresses:address_book:read", "addresses:contact:write"))
book.read_only = True
self.session.commit()
read_only = writer.can_write_to_address_book(self.session, self.principal, address_book_id=book.id)
self.assertFalse(read_only.allowed)
self.assertEqual(read_only.reason, "address_book_read_only")
self.assertTrue(read_only.read_only)
with self.assertRaises(AddressWriterError) as blocked:
writer.create_contact(
self.session,
self.principal,
address_book_id=book.id,
payload=ContactCreateRequest(display_name="Blocked Contact", emails=[ContactEmailPayload(email="blocked@example.local")]),
)
self.assertEqual(blocked.exception.decision.reason, "address_book_read_only")
def test_address_lists_group_contacts_and_expose_recipient_sources(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal"))
other_book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Other"))
self.session.commit()
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
emails=[
ContactEmailPayload(label="work", email="ada.work@example.local", is_primary=True),
ContactEmailPayload(label="private", email="ada.private@example.local"),
],
postal_addresses=[
ContactPostalAddressPayload(label="office", street="Main Street 1", postal_code="10115", locality="Berlin", country="Germany", is_primary=True),
],
),
)
other_contact = create_contact(
self.session,
self.principal,
other_book.id,
ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]),
)
self.session.commit()
self.session.refresh(contact)
self.session.refresh(other_contact)
address_list = create_address_list(self.session, self.principal, book.id, AddressListCreateRequest(name="Mathematicians"))
self.session.commit()
self.session.refresh(address_list)
entry = create_address_list_entry(
self.session,
self.principal,
address_list.id,
AddressListEntryCreateRequest(contact_id=contact.id, contact_email_id=contact.emails[1].id, label="Private address"),
)
postal_entry = create_address_list_entry(
self.session,
self.principal,
address_list.id,
AddressListEntryCreateRequest(contact_id=contact.id, contact_postal_address_id=contact.postal_addresses[0].id, label="Office address"),
)
self.session.commit()
self.session.refresh(entry)
self.session.refresh(postal_entry)
self.assertEqual([item.id for item in list_address_lists(self.session, self.principal, address_book_id=book.id)], [address_list.id])
self.assertEqual(address_list_entry_counts(self.session, [address_list.id])[address_list.id], 2)
entries = list_address_list_entries(self.session, self.principal, address_list.id)
self.assertEqual(entries[0].contact_email.email, "ada.private@example.local")
self.assertEqual(entries[1].target_kind, "postal_address")
self.assertEqual(entries[1].contact_postal_address.locality, "Berlin")
with self.assertRaisesRegex(ValueError, "same address book"):
create_address_list_entry(
self.session,
self.principal,
address_list.id,
AddressListEntryCreateRequest(contact_id=other_contact.id),
)
recipient_source = AddressesRecipientSourceCapability()
sources = recipient_source.list_sources(self.session, self.principal)
self.assertIn(f"addresses:address_list:{address_list.id}", {source.source_id for source in sources})
snapshot = recipient_source.snapshot(self.session, self.principal, source_id=f"addresses:address_list:{address_list.id}")
self.assertEqual(snapshot.source_kind, "address_list")
self.assertEqual(len(snapshot.recipients), 1)
self.assertEqual(snapshot.recipients[0].email, "ada.private@example.local")
self.assertEqual(snapshot.recipients[0].provenance["address_list_id"], address_list.id)
self.assertEqual(snapshot.recipients[0].provenance["address_list_entry_id"], entry.id)
delete_address_list_entry(self.session, self.principal, entry.id)
delete_address_list_entry(self.session, self.principal, postal_entry.id)
self.session.commit()
self.assertEqual(list_address_list_entries(self.session, self.principal, address_list.id), [])
def test_sync_source_marks_read_only_books_and_can_be_made_writable(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="CardDAV"))
self.session.commit()
self.session.refresh(book)
sync_source = create_sync_source(
self.session,
self.principal,
book.id,
AddressSyncSourceCreateRequest(
connector_type="carddav",
display_name="CardDAV Personal",
external_account_ref="account://carddav/personal",
external_address_book_ref="/remote/addressbooks/personal/",
sync_direction="read_only",
metadata={"base_url": "https://carddav.example.local"},
),
)
self.session.commit()
self.session.refresh(book)
self.session.refresh(sync_source)
self.assertEqual(book.source_kind, "carddav")
self.assertEqual(book.source_ref, sync_source.id)
self.assertTrue(book.read_only)
self.assertEqual(book.sync_status, "idle")
self.assertEqual([item.id for item in list_sync_sources(self.session, self.principal)], [sync_source.id])
decision = AddressesContactWriterCapability().can_write_to_address_book(self.session, self.principal, address_book_id=book.id)
self.assertFalse(decision.allowed)
self.assertEqual(decision.reason, "address_book_read_only")
with self.assertRaisesRegex(ValueError, "read-only"):
create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(display_name="Read Only", emails=[ContactEmailPayload(email="readonly@example.local")]),
)
sync_source = update_sync_source(
self.session,
self.principal,
sync_source.id,
AddressSyncSourceUpdateRequest(sync_direction="two_way", read_only=False, metadata={"base_url": "https://carddav.example.local"}),
)
self.session.commit()
self.session.refresh(book)
self.session.refresh(sync_source)
self.assertFalse(sync_source.read_only)
self.assertFalse(book.read_only)
self.assertTrue(AddressesContactWriterCapability().can_write_to_address_book(self.session, self.principal, address_book_id=book.id).allowed)
def test_sync_attempts_diagnostics_tombstones_and_conflicts_are_persisted(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Synced"))
self.session.commit()
self.session.refresh(book)
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(display_name="Ada Lovelace", emails=[ContactEmailPayload(email="ada@example.local")]),
)
sync_source = create_sync_source(
self.session,
self.principal,
book.id,
AddressSyncSourceCreateRequest(connector_type="carddav", display_name="Writable CardDAV", sync_direction="two_way", read_only=False),
)
self.session.commit()
self.session.refresh(contact)
self.session.refresh(sync_source)
start_sync_attempt(self.session, self.principal, sync_source.id)
self.session.commit()
self.session.refresh(sync_source)
self.assertEqual(sync_source.status, "running")
finish_sync_attempt(
self.session,
self.principal,
sync_source.id,
AddressSyncAttemptFinishRequest(status="succeeded", sync_token="sync-token-1", etag="etag-1", remote_revision="rev-1"),
)
diagnostic = record_sync_diagnostic(
self.session,
self.principal,
sync_source.id,
AddressSyncDiagnosticCreateRequest(severity="warning", code="carddav.rate_limit", message="Remote server asked us to slow down."),
)
tombstone = record_sync_tombstone(
self.session,
self.principal,
sync_source.id,
AddressSyncTombstoneCreateRequest(contact_id=contact.id, remote_uid="ada-uid", resource_href="/contacts/ada.vcf"),
)
conflict = record_sync_conflict(
self.session,
self.principal,
sync_source.id,
AddressSyncConflictCreateRequest(
contact_id=contact.id,
remote_uid="ada-uid",
resource_href="/contacts/ada.vcf",
field_path="emails[0].email",
local_value={"email": "ada@example.local"},
remote_value={"email": "ada.remote@example.local"},
),
)
self.session.commit()
self.session.refresh(sync_source)
self.session.refresh(diagnostic)
self.session.refresh(tombstone)
self.session.refresh(conflict)
self.assertEqual(sync_source.sync_token, "sync-token-1")
self.assertEqual(sync_source.status, "conflict")
self.assertEqual(list_sync_diagnostics(self.session, self.principal, sync_source.id)[0].id, diagnostic.id)
self.assertEqual(list_sync_tombstones(self.session, self.principal, sync_source.id)[0].id, tombstone.id)
self.assertEqual(list_sync_conflicts(self.session, self.principal, sync_source.id)[0].id, conflict.id)
resolved = resolve_sync_conflict(
self.session,
self.principal,
conflict.id,
AddressSyncConflictResolveRequest(resolution="manual", status="resolved"),
)
self.session.commit()
self.session.refresh(sync_source)
self.session.refresh(resolved)
self.assertEqual(resolved.status, "resolved")
self.assertEqual(resolved.resolution, "manual")
self.assertEqual(list_sync_conflicts(self.session, self.principal, sync_source.id), [])
self.assertEqual(sync_source.status, "idle")
def test_sync_conflict_merge_resolution_applies_merged_payload(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Merged"))
self.session.commit()
self.session.refresh(book)
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(display_name="Local Name", emails=[ContactEmailPayload(email="local@example.local")]),
)
sync_source = create_sync_source(
self.session,
self.principal,
book.id,
AddressSyncSourceCreateRequest(connector_type="carddav", display_name="Writable CardDAV", sync_direction="two_way", read_only=False),
)
self.session.commit()
self.session.refresh(contact)
self.session.refresh(sync_source)
local_payload = {
"display_name": "Local Name",
"emails": [{"label": None, "email": "local@example.local", "is_primary": True}],
"phones": [],
"postal_addresses": [],
"tags": [],
}
remote_payload = {
"display_name": "Remote Name",
"emails": [{"label": None, "email": "remote@example.local", "is_primary": True}],
"phones": [],
"postal_addresses": [],
"tags": [],
}
conflict = record_sync_conflict(
self.session,
self.principal,
sync_source.id,
AddressSyncConflictCreateRequest(
contact_id=contact.id,
resource_href="/contacts/local.vcf",
field_path="vcard",
local_value={"payload": local_payload},
remote_value={"payload": remote_payload, "etag": '"etag-remote"'},
metadata={"remote_payload": remote_payload, "source_revision": '"etag-remote"'},
),
)
self.session.commit()
self.session.refresh(conflict)
resolve_sync_conflict(
self.session,
self.principal,
conflict.id,
AddressSyncConflictResolveRequest(
resolution="merge",
status="resolved",
merged_payload=ContactCreateRequest(display_name="Local Name", emails=[ContactEmailPayload(email="remote@example.local")]),
),
)
self.session.commit()
self.session.refresh(contact)
self.assertEqual(contact.display_name, "Local Name")
self.assertEqual(contact.emails[0].email, "remote@example.local")
self.assertEqual(contact.source_revision, '"etag-remote"')
def test_carddav_run_imports_contacts_and_preview_detects_conflicts(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Remote"))
self.session.commit()
self.session.refresh(book)
sync_source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://carddav.example.local/addressbooks/personal/",
display_name="Remote CardDAV",
auth_type="none",
sync_direction="two_way",
read_only=False,
),
)
self.session.commit()
self.session.refresh(sync_source)
first_client = FakeCardDavClient([
AddressCardDAVObject(
href="/addressbooks/personal/ada.vcf",
etag='"etag-1"',
address_data="""BEGIN:VCARD
VERSION:4.0
UID:ada-uid
FN:Ada Lovelace
EMAIL:ada@example.local
END:VCARD
""",
)
])
plan = run_sync_source(self.session, self.principal, sync_source.id, client=first_client)
self.session.commit()
self.session.refresh(sync_source)
self.assertEqual(plan.stats.created, 1)
self.assertEqual(sync_source.status, "succeeded")
contacts = list_contacts(self.session, self.principal, address_book_id=book.id)
self.assertEqual(len(contacts), 1)
self.assertEqual(contacts[0].display_name, "Ada Lovelace")
self.assertEqual(contacts[0].source_kind, "carddav")
self.assertEqual(contacts[0].source_ref, "/addressbooks/personal/ada.vcf")
self.assertEqual(contacts[0].source_revision, '"etag-1"')
contacts[0].display_name = "Ada Local"
contacts[0].updated_at = utcnow()
self.session.commit()
conflict_client = FakeCardDavClient(
[
AddressCardDAVObject(
href="/addressbooks/personal/ada.vcf",
etag='"etag-2"',
address_data="""BEGIN:VCARD
VERSION:4.0
UID:ada-uid
FN:Ada Remote
EMAIL:ada@example.local
END:VCARD
""",
)
],
sync_token="sync-token-2",
ctag="ctag-2",
)
preview = preview_sync_source(self.session, self.principal, sync_source.id, client=conflict_client)
self.assertEqual(preview.stats.conflicts, 1)
self.assertEqual(preview.items[0].action, "conflict")
self.assertEqual(preview.items[0].contact_id, contacts[0].id)
run_conflict_client = FakeCardDavClient(
[
AddressCardDAVObject(
href="/addressbooks/personal/ada.vcf",
etag='"etag-2"',
address_data="""BEGIN:VCARD
VERSION:4.0
UID:ada-uid
FN:Ada Remote
EMAIL:ada.remote@example.local
END:VCARD
""",
)
],
sync_token="sync-token-2",
ctag="ctag-2",
)
run_sync_source(self.session, self.principal, sync_source.id, client=run_conflict_client)
self.session.commit()
conflicts = list_sync_conflicts(self.session, self.principal, sync_source.id)
self.assertEqual(len(conflicts), 1)
self.assertIn("payload", conflicts[0].remote_value)
resolved = resolve_sync_conflict(
self.session,
self.principal,
conflicts[0].id,
AddressSyncConflictResolveRequest(resolution="use_remote", status="resolved"),
)
self.session.commit()
self.session.refresh(resolved)
self.session.refresh(contacts[0])
self.assertEqual(resolved.status, "resolved")
self.assertEqual(contacts[0].display_name, "Ada Remote")
self.assertEqual(contacts[0].emails[0].email, "ada.remote@example.local")
def test_carddav_two_way_pushes_local_creates_updates_and_deletes(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Writable Remote"))
self.session.commit()
self.session.refresh(book)
sync_source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://carddav.example.local/addressbooks/personal/",
display_name="Writable CardDAV",
auth_type="none",
sync_direction="two_way",
read_only=False,
),
)
self.session.commit()
self.session.refresh(book)
self.session.refresh(sync_source)
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(display_name="Grace Hopper", emails=[ContactEmailPayload(email="grace@example.local")]),
)
self.session.commit()
self.session.refresh(contact)
create_client = FakeCardDavClient([], sync_token="sync-token-create", ctag="ctag-create")
create_plan = run_sync_source(self.session, self.principal, sync_source.id, client=create_client)
self.session.commit()
self.session.refresh(contact)
self.session.refresh(sync_source)
self.assertEqual(create_plan.stats.created, 1)
self.assertEqual(create_plan.items[-1].action, "remote_create")
self.assertEqual(len(create_client.puts), 1)
self.assertTrue(create_client.puts[0]["create"])
self.assertEqual(contact.source_ref, f"/addressbooks/personal/{contact.id}.vcf")
self.assertEqual(contact.source_revision, '"written-1"')
contact.display_name = "Grace Updated"
contact.updated_at = utcnow()
self.session.commit()
self.session.refresh(contact)
update_client = FakeCardDavClient(
[
AddressCardDAVObject(
href=contact.source_ref or "",
etag=contact.source_revision,
address_data="""BEGIN:VCARD
VERSION:4.0
FN:Grace Hopper
EMAIL:grace@example.local
END:VCARD
""",
)
],
sync_token="sync-token-update",
ctag="ctag-update",
)
update_plan = run_sync_source(self.session, self.principal, sync_source.id, client=update_client)
self.session.commit()
self.session.refresh(contact)
self.session.refresh(sync_source)
self.assertEqual(update_plan.stats.updated, 1)
self.assertEqual(update_plan.items[-1].action, "remote_update")
self.assertEqual(len(update_client.puts), 1)
self.assertFalse(update_client.puts[0]["create"])
self.assertEqual(update_client.puts[0]["etag"], '"written-1"')
self.assertIn("FN:Grace Updated", str(update_client.puts[0]["vcard"]))
delete_contact(self.session, self.principal, contact.id)
self.session.commit()
self.session.refresh(contact)
delete_client = FakeCardDavClient(
[
AddressCardDAVObject(
href=contact.source_ref or "",
etag=contact.source_revision,
address_data="""BEGIN:VCARD
VERSION:4.0
FN:Grace Updated
EMAIL:grace@example.local
END:VCARD
""",
)
],
sync_token="sync-token-delete",
ctag="ctag-delete",
)
delete_plan = run_sync_source(self.session, self.principal, sync_source.id, client=delete_client)
self.session.commit()
self.assertEqual(delete_plan.stats.deleted, 1)
self.assertEqual(delete_plan.items[-1].action, "remote_delete")
self.assertEqual(delete_client.deletes, [{"href": f"/addressbooks/personal/{contact.id}.vcf", "etag": contact.source_revision, "overwrite": False}])
self.assertEqual(len(list_sync_tombstones(self.session, self.principal, sync_source.id)), 1)
def test_delete_sync_source_disconnects_without_removing_contacts(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Disconnectable"))
self.session.commit()
self.session.refresh(book)
sync_source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://carddav.example.local/addressbooks/personal/",
display_name="Disconnectable CardDAV",
auth_type="none",
sync_direction="two_way",
read_only=False,
),
)
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(display_name="Local Contact", emails=[ContactEmailPayload(email="local@example.local")]),
)
self.session.commit()
self.session.refresh(book)
self.session.refresh(sync_source)
self.session.refresh(contact)
self.assertEqual(book.source_kind, "carddav")
self.assertEqual(book.source_ref, sync_source.id)
self.assertEqual(len(list_sync_sources(self.session, self.principal, include_disabled=True)), 1)
delete_sync_source(self.session, self.principal, sync_source.id)
self.session.commit()
self.session.refresh(book)
self.session.refresh(contact)
self.assertEqual(list_sync_sources(self.session, self.principal, include_disabled=True), [])
self.assertEqual(book.source_kind, "local")
self.assertIsNone(book.source_ref)
self.assertFalse(book.read_only)
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
if __name__ == "__main__":
unittest.main()

78
tests/test_vcard.py Normal file
View File

@@ -0,0 +1,78 @@
from __future__ import annotations
import unittest
from govoplan_addresses.backend.db.models import Contact, ContactEmail, ContactPhone, ContactPostalAddress
from govoplan_addresses.backend.vcard import contact_to_vcard, parse_vcards_with_issues
class VCardTests(unittest.TestCase):
def test_parse_vcard_preserves_folded_and_escaped_text(self) -> None:
result = parse_vcards_with_issues(
"BEGIN:VCARD\r\n"
"VERSION:4.0\r\n"
"UID:contact-1\r\n"
"FN:Ada \r\n"
" Lovelace\r\n"
"N:Lovelace;Ada;;;\r\n"
"NOTE:Line one\\nLine two\r\n"
"CATEGORIES:science\\,history,engineering\r\n"
"EMAIL;TYPE=work;PREF=1:ada@example.local\r\n"
"ADR;TYPE=work:;;Main Street 1;Berlin;BE;10115;Germany\r\n"
"URL:https://example.local/ada\r\n"
"END:VCARD\r\n"
)
self.assertEqual([], result.issues)
self.assertEqual(1, len(result.cards))
payload = result.cards[0].payload
self.assertEqual("Ada Lovelace", payload.display_name)
self.assertEqual("Line one\nLine two", payload.note)
self.assertEqual(["science,history", "engineering"], payload.tags)
self.assertEqual("work", payload.emails[0].label)
self.assertTrue(payload.emails[0].is_primary)
self.assertEqual("Berlin", payload.postal_addresses[0].locality)
self.assertEqual(["https://example.local/ada"], payload.provenance["vcard"]["urls"])
def test_contact_to_vcard_escapes_text_and_filters_urls(self) -> None:
contact = Contact(
address_book_id="book-1",
display_name="Ada Lovelace",
given_name="Ada",
family_name="Lovelace",
organization="Analytical Engine Office",
role_title="Mathematician",
note="Line one\nLine two",
tags=["science,history", "engineering"],
source_ref="contact-1",
source_revision="rev-1",
provenance={"vcard": {"urls": [" https://example.local/ada ", "", 42]}},
)
contact.emails = [ContactEmail(label="work", email="ada@example.local", is_primary=True, order_index=0)]
contact.phones = [ContactPhone(label="work", phone="+49 30 123", is_primary=True, order_index=0)]
contact.postal_addresses = [
ContactPostalAddress(
label="work",
street="Main Street 1",
locality="Berlin",
region="BE",
postal_code="10115",
country="Germany",
is_primary=True,
order_index=0,
)
]
content = contact_to_vcard(contact)
self.assertIn("UID:contact-1\r\n", content)
self.assertIn("REV:rev-1\r\n", content)
self.assertIn("NOTE:Line one\\nLine two\r\n", content)
self.assertIn("CATEGORIES:science\\,history,engineering\r\n", content)
self.assertIn("EMAIL;TYPE=work:ada@example.local\r\n", content)
self.assertIn("URL:https://example.local/ada\r\n", content)
self.assertNotIn("URL:42", content)
if __name__ == "__main__":
unittest.main()

28
webui/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/addresses.css": "./src/styles/addresses.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

596
webui/src/api/addresses.ts Normal file
View File

@@ -0,0 +1,596 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type AddressBookScope = "user" | "group" | "tenant" | "system";
export type AddressBook = {
id: string;
tenant_id?: string | null;
scope_type: AddressBookScope;
scope_id?: string | null;
name: string;
description?: string | null;
source_kind: string;
source_ref?: string | null;
read_only: boolean;
sync_status?: string | null;
sync_error?: string | null;
contact_count: number;
deleted_at?: string | null;
created_at: string;
updated_at: string;
};
export type AddressList = {
id: string;
tenant_id?: string | null;
address_book_id: string;
name: string;
description?: string | null;
source_kind: string;
source_ref?: string | null;
read_only: boolean;
entry_count: number;
deleted_at?: string | null;
created_at: string;
updated_at: string;
};
export type ContactEmail = {
id?: string;
label?: string | null;
email: string;
is_primary: boolean;
};
export type ContactPhone = {
id?: string;
label?: string | null;
phone: string;
is_primary: boolean;
};
export type ContactPostalAddress = {
id?: string;
label?: string | null;
street?: string | null;
postal_code?: string | null;
locality?: string | null;
region?: string | null;
country?: string | null;
is_primary: boolean;
};
export type Contact = {
id: string;
tenant_id?: string | null;
address_book_id: string;
display_name: string;
given_name?: string | null;
family_name?: string | null;
organization?: string | null;
role_title?: string | null;
note?: string | null;
tags: string[];
source_kind: string;
source_ref?: string | null;
source_payload_kind?: string | null;
source_revision?: string | null;
provenance: Record<string, unknown>;
emails: ContactEmail[];
phones: ContactPhone[];
postal_addresses: ContactPostalAddress[];
deleted_at?: string | null;
created_at: string;
updated_at: string;
};
export type AddressBookCreatePayload = {
scope_type: AddressBookScope;
group_id?: string | null;
name: string;
description?: string | null;
};
export type AddressBookUpdatePayload = {
name?: string | null;
description?: string | null;
};
export type AddressListPayload = {
name: string;
description?: string | null;
};
export type AddressListEntryPayload = {
contact_id: string;
contact_email_id?: string | null;
contact_postal_address_id?: string | null;
label?: string | null;
};
export type AddressListEntry = {
id: string;
address_list_id: string;
contact_id: string;
contact_email_id?: string | null;
contact_postal_address_id?: string | null;
target_kind: string;
label?: string | null;
order_index: number;
contact_display_name: string;
email?: string | null;
postal_address?: string | null;
created_at: string;
updated_at: string;
};
export type ContactPayload = {
display_name?: string | null;
given_name?: string | null;
family_name?: string | null;
organization?: string | null;
role_title?: string | null;
note?: string | null;
tags?: string[];
emails?: Array<Omit<ContactEmail, "id">>;
phones?: Array<Omit<ContactPhone, "id">>;
postal_addresses?: Array<Omit<ContactPostalAddress, "id">>;
provenance?: Record<string, unknown>;
};
export type AddressBookWriteOperation = "create_contact" | "update_contact" | "delete_contact";
export type AddressBookWriteDecision = {
address_book_id: string;
address_book_label?: string | null;
operation: string;
allowed: boolean;
reason: string;
message: string;
scope_type?: AddressBookScope | null;
scope_id?: string | null;
tenant_id?: string | null;
source_kind?: string | null;
read_only: boolean;
required_scopes: string[];
provenance: Record<string, unknown>;
};
type AddressBookListResponse = {
address_books: AddressBook[];
};
type AddressListListResponse = {
address_lists: AddressList[];
};
type AddressListEntryListResponse = {
entries: AddressListEntry[];
};
type ContactListResponse = {
contacts: Contact[];
};
type AddressBookWriteTargetsResponse = {
targets: AddressBookWriteDecision[];
};
export type VCardImportResult = {
imported: number;
skipped: number;
contacts: Contact[];
issues: Array<{ index: number; message: string; severity: "warning" | "error"; field?: string | null; line?: number | null }>;
};
export type AddressSyncSource = {
id: string;
tenant_id?: string | null;
address_book_id: string;
connector_type: string;
display_name: string;
external_account_ref?: string | null;
external_address_book_ref?: string | null;
sync_direction: string;
read_only: boolean;
enabled: boolean;
status: string;
sync_token?: string | null;
etag?: string | null;
remote_revision?: string | null;
last_attempted_at?: string | null;
last_success_at?: string | null;
last_error?: string | null;
last_diagnostic?: Record<string, unknown> | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type AddressCardDavAddressBook = {
collection_url: string;
href: string;
display_name?: string | null;
description?: string | null;
ctag?: string | null;
sync_token?: string | null;
};
export type AddressSyncPlanStats = {
created: number;
updated: number;
deleted: number;
conflicts: number;
unchanged: number;
errors: number;
fetched: number;
full_sync: boolean;
used_sync_token: boolean;
sync_token?: string | null;
etag?: string | null;
remote_revision?: string | null;
};
export type AddressSyncPlanItem = {
action: "create" | "update" | "delete" | "remote_create" | "remote_update" | "remote_delete" | "conflict" | "unchanged" | "error";
href?: string | null;
remote_uid?: string | null;
contact_id?: string | null;
display_name?: string | null;
etag?: string | null;
message?: string | null;
};
export type AddressSyncPlan = {
sync_source: AddressSyncSource;
stats: AddressSyncPlanStats;
items: AddressSyncPlanItem[];
};
export type AddressSyncDiagnostic = {
id: string;
tenant_id?: string | null;
sync_source_id: string;
severity: string;
code: string;
message: string;
details: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type AddressSyncTombstone = {
id: string;
tenant_id?: string | null;
sync_source_id: string;
address_book_id: string;
contact_id?: string | null;
remote_uid?: string | null;
resource_href?: string | null;
local_deleted_at?: string | null;
remote_deleted_at?: string | null;
synced_at?: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type AddressSyncConflict = {
id: string;
tenant_id?: string | null;
sync_source_id: string;
address_book_id: string;
contact_id?: string | null;
remote_uid?: string | null;
resource_href?: string | null;
field_path: string;
local_value?: Record<string, unknown> | null;
remote_value?: Record<string, unknown> | null;
local_updated_at?: string | null;
remote_updated_at?: string | null;
status: string;
resolution?: string | null;
resolved_at?: string | null;
resolved_by_account_id?: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
};
type AddressSyncSourceListResponse = {
sync_sources: AddressSyncSource[];
};
type AddressCardDavDiscoveryResponse = {
address_books: AddressCardDavAddressBook[];
};
type AddressSyncDiagnosticListResponse = {
diagnostics: AddressSyncDiagnostic[];
};
type AddressSyncTombstoneListResponse = {
tombstones: AddressSyncTombstone[];
};
type AddressSyncConflictListResponse = {
conflicts: AddressSyncConflict[];
};
function queryString(params: Record<string, string | number | null | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== "") search.set(key, String(value));
}
const serialized = search.toString();
return serialized ? `?${serialized}` : "";
}
export async function listAddressBooks(settings: ApiSettings, options: {includeDeleted?: boolean;} = {}): Promise<AddressBook[]> {
const response = await apiFetch<AddressBookListResponse>(
settings,
`/api/v1/addresses/address-books${queryString({ include_deleted: options.includeDeleted ? "true" : null })}`
);
return response.address_books;
}
export function createAddressBook(settings: ApiSettings, payload: AddressBookCreatePayload): Promise<AddressBook> {
return apiFetch<AddressBook>(settings, "/api/v1/addresses/address-books", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateAddressBook(settings: ApiSettings, bookId: string, payload: AddressBookUpdatePayload): Promise<AddressBook> {
return apiFetch<AddressBook>(settings, `/api/v1/addresses/address-books/${bookId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteAddressBook(settings: ApiSettings, bookId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/addresses/address-books/${bookId}`, { method: "DELETE" });
}
export function restoreAddressBook(settings: ApiSettings, bookId: string): Promise<AddressBook> {
return apiFetch<AddressBook>(settings, `/api/v1/addresses/address-books/${bookId}/restore`, { method: "POST" });
}
export async function listAddressLists(
settings: ApiSettings,
options: { addressBookId?: string | null; includeDeleted?: boolean } = {}
): Promise<AddressList[]> {
const response = await apiFetch<AddressListListResponse>(
settings,
`/api/v1/addresses/address-lists${queryString({
address_book_id: options.addressBookId,
include_deleted: options.includeDeleted ? "true" : null
})}`
);
return response.address_lists;
}
export function createAddressList(settings: ApiSettings, addressBookId: string, payload: AddressListPayload): Promise<AddressList> {
return apiFetch<AddressList>(settings, `/api/v1/addresses/address-books/${addressBookId}/address-lists`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateAddressList(settings: ApiSettings, addressListId: string, payload: Partial<AddressListPayload>): Promise<AddressList> {
return apiFetch<AddressList>(settings, `/api/v1/addresses/address-lists/${addressListId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteAddressList(settings: ApiSettings, addressListId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/addresses/address-lists/${addressListId}`, { method: "DELETE" });
}
export function restoreAddressList(settings: ApiSettings, addressListId: string): Promise<AddressList> {
return apiFetch<AddressList>(settings, `/api/v1/addresses/address-lists/${addressListId}/restore`, { method: "POST" });
}
export async function listAddressListEntries(settings: ApiSettings, addressListId: string): Promise<AddressListEntry[]> {
const response = await apiFetch<AddressListEntryListResponse>(settings, `/api/v1/addresses/address-lists/${addressListId}/entries`);
return response.entries;
}
export function createAddressListEntry(
settings: ApiSettings,
addressListId: string,
payload: AddressListEntryPayload
): Promise<AddressListEntry> {
return apiFetch<AddressListEntry>(settings, `/api/v1/addresses/address-lists/${addressListId}/entries`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function deleteAddressListEntry(settings: ApiSettings, entryId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/addresses/address-list-entries/${entryId}`, { method: "DELETE" });
}
export async function listAddressBookWriteTargets(
settings: ApiSettings,
operation: AddressBookWriteOperation = "create_contact"
): Promise<AddressBookWriteDecision[]> {
const response = await apiFetch<AddressBookWriteTargetsResponse>(
settings,
`/api/v1/addresses/write-targets${queryString({ operation })}`
);
return response.targets;
}
export function getAddressBookWriteDecision(
settings: ApiSettings,
addressBookId: string,
operation: AddressBookWriteOperation = "create_contact"
): Promise<AddressBookWriteDecision> {
return apiFetch<AddressBookWriteDecision>(
settings,
`/api/v1/addresses/address-books/${addressBookId}/write-decision${queryString({ operation })}`
);
}
export async function listAddressSyncSources(
settings: ApiSettings,
options: { addressBookId?: string | null; includeDisabled?: boolean } = {}
): Promise<AddressSyncSource[]> {
const response = await apiFetch<AddressSyncSourceListResponse>(
settings,
`/api/v1/addresses/sync-sources${queryString({
address_book_id: options.addressBookId,
include_disabled: options.includeDisabled ? "true" : null
})}`
);
return response.sync_sources;
}
export function discoverCardDavAddressBooks(
settings: ApiSettings,
payload: { url: string; auth_type: "none" | "basic" | "bearer"; username?: string | null; password?: string | null; bearer_token?: string | null; credential_ref?: string | null }
): Promise<AddressCardDavAddressBook[]> {
return apiFetch<AddressCardDavDiscoveryResponse>(settings, "/api/v1/addresses/carddav/discover", {
method: "POST",
body: JSON.stringify(payload)
}).then((response) => response.address_books);
}
export function createCardDavSyncSource(
settings: ApiSettings,
addressBookId: string,
payload: {
collection_url: string;
display_name?: string | null;
auth_type: "none" | "basic" | "bearer";
username?: string | null;
password?: string | null;
bearer_token?: string | null;
credential_ref?: string | null;
sync_direction: "read_only" | "import" | "export" | "two_way";
read_only?: boolean | null;
sync_token?: string | null;
etag?: string | null;
remote_revision?: string | null;
}
): Promise<AddressSyncSource> {
return apiFetch<AddressSyncSource>(settings, `/api/v1/addresses/address-books/${addressBookId}/carddav/sources`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateAddressSyncSource(
settings: ApiSettings,
syncSourceId: string,
payload: Partial<{
display_name: string | null;
external_account_ref: string | null;
external_address_book_ref: string | null;
sync_direction: "read_only" | "import" | "export" | "two_way";
read_only: boolean | null;
enabled: boolean | null;
sync_token: string | null;
etag: string | null;
remote_revision: string | null;
metadata: Record<string, unknown> | null;
}>
): Promise<AddressSyncSource> {
return apiFetch<AddressSyncSource>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteAddressSyncSource(settings: ApiSettings, syncSourceId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}`, { method: "DELETE" });
}
export function previewAddressSyncSource(settings: ApiSettings, syncSourceId: string, options: { forceFull?: boolean } = {}): Promise<AddressSyncPlan> {
return apiFetch<AddressSyncPlan>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/preview`, {
method: "POST",
body: JSON.stringify({ force_full: Boolean(options.forceFull) })
});
}
export function runAddressSyncSource(settings: ApiSettings, syncSourceId: string, options: { forceFull?: boolean } = {}): Promise<AddressSyncPlan> {
return apiFetch<AddressSyncPlan>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/run`, {
method: "POST",
body: JSON.stringify({ force_full: Boolean(options.forceFull) })
});
}
export async function listAddressSyncDiagnostics(settings: ApiSettings, syncSourceId: string): Promise<AddressSyncDiagnostic[]> {
const response = await apiFetch<AddressSyncDiagnosticListResponse>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/diagnostics`);
return response.diagnostics;
}
export async function listAddressSyncTombstones(settings: ApiSettings, syncSourceId: string): Promise<AddressSyncTombstone[]> {
const response = await apiFetch<AddressSyncTombstoneListResponse>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/tombstones`);
return response.tombstones;
}
export async function listAddressSyncConflicts(settings: ApiSettings, syncSourceId: string, status = "open"): Promise<AddressSyncConflict[]> {
const response = await apiFetch<AddressSyncConflictListResponse>(settings, `/api/v1/addresses/sync-sources/${syncSourceId}/conflicts${queryString({ status })}`);
return response.conflicts;
}
export function resolveAddressSyncConflict(
settings: ApiSettings,
conflictId: string,
resolution = "manual",
status = "resolved",
options: { mergedPayload?: Record<string, unknown> | null } = {}
): Promise<AddressSyncConflict> {
return apiFetch<AddressSyncConflict>(settings, `/api/v1/addresses/sync-conflicts/${conflictId}/resolve`, {
method: "POST",
body: JSON.stringify({ resolution, status, merged_payload: options.mergedPayload ?? null })
});
}
export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;query?: string | null;limit?: number;includeDeleted?: boolean;} = {}): Promise<Contact[]> {
const response = await apiFetch<ContactListResponse>(
settings,
`/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, query: options.query, limit: options.limit, include_deleted: options.includeDeleted ? "true" : null })}`
);
return response.contacts;
}
export function createContact(settings: ApiSettings, addressBookId: string, payload: ContactPayload): Promise<Contact> {
return apiFetch<Contact>(settings, `/api/v1/addresses/address-books/${addressBookId}/contacts`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateContact(settings: ApiSettings, contactId: string, payload: ContactPayload): Promise<Contact> {
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteContact(settings: ApiSettings, contactId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/addresses/contacts/${contactId}`, { method: "DELETE" });
}
export function restoreContact(settings: ApiSettings, contactId: string): Promise<Contact> {
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" });
}
export function importAddressBookVcards(settings: ApiSettings, addressBookId: string, content: string): Promise<VCardImportResult> {
return apiFetch<VCardImportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, {
method: "POST",
body: JSON.stringify({ content })
});
}
export function exportAddressBookVcards(settings: ApiSettings, addressBookId: string): Promise<string> {
return apiFetch<string>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/export`);
}
export function exportContactVcard(settings: ApiSettings, contactId: string): Promise<string> {
return apiFetch<string>(settings, `/api/v1/addresses/contacts/${contactId}/vcard`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
en: {
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Add contact",
"i18n:govoplan-addresses.address_book.f6327f59": "Address Book",
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Address book scopes",
"i18n:govoplan-addresses.ada_lovelace.a69a9f8a": "Ada Lovelace",
"i18n:govoplan-addresses.carddav_ldap_import_connectors_can_be_added_late.0471f513": "CardDAV, LDAP, and import connectors can be added later.",
"i18n:govoplan-addresses.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4": "Choose addresses from personal, group, or tenant sources.",
"i18n:govoplan-addresses.contacts.b0dd615c": "Contacts",
"i18n:govoplan-addresses.data_protection.06e87cd3": "Data Protection",
"i18n:govoplan-addresses.directory.4b892fe0": "Directory",
"i18n:govoplan-addresses.email.84add5b2": "Email",
"i18n:govoplan-addresses.favorite.6b90b6a1": "Favorite",
"i18n:govoplan-addresses.finance_team.ff353e5c": "Finance Team",
"i18n:govoplan-addresses.group.171a0606": "Group",
"i18n:govoplan-addresses.group_address_books.aed5a7c0": "Group address books",
"i18n:govoplan-addresses.grace_hopper.d97e6939": "Grace Hopper",
"i18n:govoplan-addresses.helpdesk.191815bf": "Helpdesk",
"i18n:govoplan-addresses.import.d6fbc9d2": "Import",
"i18n:govoplan-addresses.manage_sources.ec758de0": "Manage sources",
"i18n:govoplan-addresses.mock.3bba2a47": "Mock",
"i18n:govoplan-addresses.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4": "Mock workspace for personal, group, and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.",
"i18n:govoplan-addresses.name.709a2322": "Name",
"i18n:govoplan-addresses.no_contacts_found.ad977b09": "No contacts found.",
"i18n:govoplan-addresses.personal.40f07323": "Personal",
"i18n:govoplan-addresses.personal_address_book.e240066d": "Personal address book",
"i18n:govoplan-addresses.planned_address_actions.1d4a056a": "Planned address actions",
"i18n:govoplan-addresses.private_contacts_and_remembered_addresses.2bd71556": "Private contacts and remembered addresses.",
"i18n:govoplan-addresses.private_contacts_remembered_recipients_and_perso.685cca95": "Private contacts, remembered recipients, and personal distribution lists.",
"i18n:govoplan-addresses.project_office.c35aa9ca": "Project Office",
"i18n:govoplan-addresses.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529": "Remember addresses used in campaigns after opt-in.",
"i18n:govoplan-addresses.scope.4651a34e": "Scope",
"i18n:govoplan-addresses.share_selected_contacts_with_a_group.604d1464": "Share selected contacts with a group.",
"i18n:govoplan-addresses.shared.50d0d8dd": "Shared",
"i18n:govoplan-addresses.shared_contact_sets_for_teams_departments_or_cam.4408edd7": "Shared contact sets for teams, departments, or campaigns.",
"i18n:govoplan-addresses.shared_group_address_books_and_lists.12bac69d": "Shared group address books and lists.",
"i18n:govoplan-addresses.shared_list.b3c94b39": "Shared list",
"i18n:govoplan-addresses.status.bae7d5be": "Status",
"i18n:govoplan-addresses.sync.905f6309": "Sync",
"i18n:govoplan-addresses.tags.848eed0f": "Tags",
"i18n:govoplan-addresses.tenant.3ca93c78": "Tenant",
"i18n:govoplan-addresses.tenant_directory.11b0e09c": "Tenant directory",
"i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Tenant directory and approved shared contacts.",
"i18n:govoplan-addresses.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9": "Tenant-wide contacts, functional mailboxes, and approved shared entries.",
"i18n:govoplan-addresses.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a": "Use contacts in To, Cc, Bcc, sender, and reply-to fields.",
"i18n:govoplan-addresses.used_recently.af75cea7": "Used recently"
},
de: {
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Kontakt hinzufügen",
"i18n:govoplan-addresses.address_book.f6327f59": "Adressbuch",
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Adressbuch-Bereiche",
"i18n:govoplan-addresses.ada_lovelace.a69a9f8a": "Ada Lovelace",
"i18n:govoplan-addresses.carddav_ldap_import_connectors_can_be_added_late.0471f513": "CardDAV-, LDAP- und Import-Anbindungen können später ergänzt werden.",
"i18n:govoplan-addresses.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4": "Adressen aus persönlichen, Gruppen- oder Mandantenquellen auswählen.",
"i18n:govoplan-addresses.contacts.b0dd615c": "Kontakte",
"i18n:govoplan-addresses.data_protection.06e87cd3": "Datenschutz",
"i18n:govoplan-addresses.directory.4b892fe0": "Verzeichnis",
"i18n:govoplan-addresses.email.84add5b2": "E-Mail",
"i18n:govoplan-addresses.favorite.6b90b6a1": "Favorit",
"i18n:govoplan-addresses.finance_team.ff353e5c": "Finanzteam",
"i18n:govoplan-addresses.group.171a0606": "Gruppe",
"i18n:govoplan-addresses.group_address_books.aed5a7c0": "Gruppenadressbücher",
"i18n:govoplan-addresses.grace_hopper.d97e6939": "Grace Hopper",
"i18n:govoplan-addresses.helpdesk.191815bf": "Helpdesk",
"i18n:govoplan-addresses.import.d6fbc9d2": "Importieren",
"i18n:govoplan-addresses.manage_sources.ec758de0": "Quellen verwalten",
"i18n:govoplan-addresses.mock.3bba2a47": "Mock",
"i18n:govoplan-addresses.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4": "Mock-Arbeitsbereich für persönliche, Gruppen- und Mandantenadressbücher. Diese Kontakte können später Autovervollständigung und wiederverwendbare Adressauswahlen speisen.",
"i18n:govoplan-addresses.name.709a2322": "Name",
"i18n:govoplan-addresses.no_contacts_found.ad977b09": "Keine Kontakte gefunden.",
"i18n:govoplan-addresses.personal.40f07323": "Persönlich",
"i18n:govoplan-addresses.personal_address_book.e240066d": "Persönliches Adressbuch",
"i18n:govoplan-addresses.planned_address_actions.1d4a056a": "Geplante Adressaktionen",
"i18n:govoplan-addresses.private_contacts_and_remembered_addresses.2bd71556": "Private Kontakte und gemerkte Adressen.",
"i18n:govoplan-addresses.private_contacts_remembered_recipients_and_perso.685cca95": "Private Kontakte, gemerkte Empfänger und persönliche Verteilerlisten.",
"i18n:govoplan-addresses.project_office.c35aa9ca": "Projektbüro",
"i18n:govoplan-addresses.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529": "In Kampagnen verwendete Adressen nach Opt-in merken.",
"i18n:govoplan-addresses.scope.4651a34e": "Bereich",
"i18n:govoplan-addresses.share_selected_contacts_with_a_group.604d1464": "Ausgewählte Kontakte mit einer Gruppe teilen.",
"i18n:govoplan-addresses.shared.50d0d8dd": "Geteilt",
"i18n:govoplan-addresses.shared_contact_sets_for_teams_departments_or_cam.4408edd7": "Geteilte Kontaktsammlungen für Teams, Abteilungen oder Kampagnen.",
"i18n:govoplan-addresses.shared_group_address_books_and_lists.12bac69d": "Geteilte Gruppenadressbücher und Listen.",
"i18n:govoplan-addresses.shared_list.b3c94b39": "Geteilte Liste",
"i18n:govoplan-addresses.status.bae7d5be": "Status",
"i18n:govoplan-addresses.sync.905f6309": "Sync",
"i18n:govoplan-addresses.tags.848eed0f": "Tags",
"i18n:govoplan-addresses.tenant.3ca93c78": "Mandant",
"i18n:govoplan-addresses.tenant_directory.11b0e09c": "Mandantenverzeichnis",
"i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Mandantenverzeichnis und freigegebene geteilte Kontakte.",
"i18n:govoplan-addresses.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9": "Mandantenweite Kontakte, Funktionspostfächer und freigegebene Einträge.",
"i18n:govoplan-addresses.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a": "Kontakte in An-, Cc-, Bcc-, Absender- und Antwortfeldern verwenden.",
"i18n:govoplan-addresses.used_recently.af75cea7": "Kürzlich verwendet"
}
};

3
webui/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { default } from "./module";
export * from "./module";
export { default as AddressBookPage } from "./features/addressbook/AddressBookPage";

24
webui/src/module.ts Normal file
View File

@@ -0,0 +1,24 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/addresses.css";
const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage"));
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const addressesModule: PlatformWebModule = {
id: "addresses",
label: "i18n:govoplan-addresses.address_book.f6327f59",
version: "1.0.0",
dependencies: [],
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
translations,
navItems: [{ to: "/address-book", label: "i18n:govoplan-addresses.address_book.f6327f59", iconName: "book-user", anyOf: ["addresses:contact:read"], order: 80 }],
routes: [{ path: "/address-book", anyOf: ["addresses:contact:read"], order: 80, render: ({ settings, auth, onAuthChange }) => createElement(AddressBookPage, { settings, auth, onAuthChange }) }]
};
export default addressesModule;

View File

@@ -0,0 +1,688 @@
.address-book-scope-list {
display: grid;
gap: 10px;
}
.address-book-scope-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
padding: 14px;
}
.address-book-scope-card strong {
display: block;
color: var(--text-strong);
}
.address-book-scope-card p {
margin: 5px 0 0;
color: var(--muted);
}
.address-error {
left: 14px;
margin: 0;
position: absolute;
right: 14px;
top: 10px;
z-index: 2;
}
.address-book-page.address-book-fullscreen {
display: grid;
grid-template-rows: 1fr;
height: calc(100vh - 115px);
overflow: hidden;
padding: 0;
position: relative;
}
.address-workspace-frame {
height: 100%;
min-height: 0;
}
.address-book-workspace {
background: var(--panel);
border: var(--border-line);
border-radius: 0;
display: grid;
grid-template-columns: minmax(250px, 310px) minmax(280px, 360px) minmax(0, 1fr);
height: 100%;
min-height: 0;
overflow: hidden;
}
.address-tree-panel,
.address-list-panel,
.address-detail-panel {
min-height: 0;
min-width: 0;
}
.address-tree-panel {
border-right: var(--border-line);
}
.address-tree-header {
background: var(--panel-header);
}
.address-tree-header .button-row {
flex-wrap: wrap;
}
.address-icon-actions {
max-width: 150px;
}
.address-icon-actions .btn {
align-items: center;
aspect-ratio: 1;
display: inline-flex;
justify-content: center;
min-height: 30px;
padding: 0;
width: 30px;
}
.address-tree-filter-row {
border-bottom: var(--border-line);
padding: 10px 12px;
}
.address-tree-filter-row .toggle-switch-row {
margin: 0;
}
.address-tree-list {
padding: 6px 0 10px;
}
.address-book-page .explorer-tree-node .address-tree-node-content {
display: grid;
gap: 1px;
min-width: 0;
}
.address-tree-node-content strong,
.address-tree-node-content small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-tree-node-content small {
color: var(--muted);
font-size: 0.78rem;
}
.address-tree-summary {
border-top: var(--border-line);
flex: 0 0 auto;
padding: 12px;
}
.address-source-summary {
display: grid;
gap: 10px;
}
.address-source-subsummary {
border-top: var(--border-line);
display: grid;
gap: 8px;
padding-top: 10px;
}
.address-source-summary p {
color: var(--muted);
margin: 4px 0 0;
}
.address-sync-dialog {
max-width: min(960px, calc(100vw - 36px));
width: min(960px, calc(100vw - 36px));
}
.address-sync-result-list,
.address-sync-record-list,
.address-sync-plan-grid {
border: var(--border-line);
display: grid;
max-height: 260px;
overflow: auto;
}
.address-sync-result-row,
.address-sync-record-row,
.address-sync-plan-row {
align-items: center;
background: transparent;
border: 0;
border-bottom: var(--border-line);
color: var(--text);
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 9px 10px;
text-align: left;
}
.address-sync-result-row {
cursor: pointer;
}
.address-sync-result-row:hover,
.address-sync-result-row:focus-visible {
background: var(--line);
outline: none;
}
.address-sync-result-row strong,
.address-sync-result-row small,
.address-sync-record-row strong,
.address-sync-record-row small,
.address-sync-plan-row span,
.address-sync-plan-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-sync-result-row small,
.address-sync-record-row small,
.address-sync-plan-row small {
color: var(--muted);
display: block;
}
.address-sync-plan-row {
grid-template-columns: auto minmax(0, 1fr);
}
.address-sync-source-card,
.address-sync-inspector {
display: grid;
gap: 12px;
}
.address-conflict-review {
display: grid;
gap: 14px;
}
.address-conflict-grid {
border: var(--border-line);
display: grid;
grid-template-columns: minmax(120px, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto;
max-height: min(52vh, 520px);
overflow: auto;
}
.address-conflict-grid-header,
.address-conflict-row > * {
border-bottom: var(--border-line);
min-width: 0;
padding: 9px 10px;
}
.address-conflict-grid-header {
background: var(--line);
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
position: sticky;
text-transform: uppercase;
top: 0;
z-index: 1;
}
.address-conflict-row {
display: contents;
}
.address-conflict-row.has-difference > * {
background: var(--surface-subtle);
}
.address-conflict-row span {
overflow-wrap: anywhere;
}
.address-conflict-choice {
align-items: center;
display: inline-flex;
gap: 0;
white-space: nowrap;
}
.address-conflict-choice button {
background: var(--panel);
border: var(--border-line);
color: var(--text);
cursor: pointer;
font: inherit;
min-height: 28px;
padding: 3px 8px;
}
.address-conflict-choice button:first-child {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.address-conflict-choice button:last-child {
border-left: 0;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.address-conflict-choice button.is-selected {
background: var(--line);
box-shadow: inset 0 1px 2px rgba(var(--shadow-color-rgb), .12);
}
.address-list-panel,
.address-detail-panel {
display: flex;
flex-direction: column;
}
.address-list-panel {
border-right: var(--border-line);
}
.address-panel-header {
align-items: flex-start;
border-bottom: var(--border-line);
display: flex;
gap: 12px;
justify-content: space-between;
padding: 13px 14px;
}
.address-panel-header h2,
.address-detail-header h2 {
font-size: 1.05rem;
margin: 0;
}
.address-panel-header p,
.address-detail-header p {
color: var(--muted);
margin: 4px 0 0;
}
.address-contact-toolbar {
align-items: center;
display: flex;
gap: 10px;
border-bottom: var(--border-line);
padding: 10px 12px;
}
.address-contact-toolbar input {
flex: 1 1 auto;
}
.address-contact-list {
display: grid;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 8px;
align-content: start;
}
.address-contact-row {
align-items: center;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--text);
cursor: pointer;
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 0;
padding: 10px;
text-align: left;
}
.address-contact-row[draggable="true"] {
cursor: grab;
}
.address-contact-row[draggable="true"]:active {
cursor: grabbing;
}
.address-contact-row:hover,
.address-contact-row:focus-visible,
.address-contact-row.is-selected {
background: var(--line);
outline: none;
}
.address-contact-row-main {
display: grid;
gap: 2px;
min-width: 0;
}
.address-contact-row-main strong,
.address-contact-row-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-contact-row-main small {
color: var(--muted);
}
.address-contact-row-meta {
align-items: center;
display: inline-flex;
gap: 6px;
justify-content: flex-end;
min-width: 0;
}
.address-tag {
background: var(--panel-soft);
border: var(--border-line);
border-radius: 999px;
color: var(--muted);
display: inline-flex;
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
max-width: 110px;
overflow: hidden;
padding: 4px 7px;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-detail-panel {
overflow: auto;
padding: 16px;
}
.address-contact-detail {
display: grid;
gap: 16px;
}
.address-detail-header {
align-items: flex-start;
display: flex;
gap: 14px;
justify-content: space-between;
}
.address-detail-section {
border-top: var(--border-line);
display: grid;
gap: 8px;
padding-top: 12px;
}
.address-detail-section h3 {
color: var(--muted);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.05em;
margin: 0;
text-transform: uppercase;
}
.address-detail-list {
display: grid;
gap: 8px;
margin: 0;
}
.address-detail-list div {
display: grid;
gap: 2px;
}
.address-detail-list dt {
color: var(--muted);
font-size: 0.78rem;
font-weight: 800;
}
.address-detail-list dd {
margin: 0;
overflow-wrap: anywhere;
}
.address-membership-row {
align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
}
.address-membership-list {
display: grid;
gap: 8px;
}
.address-membership-row p {
margin: 0;
}
.address-detail-empty,
.address-empty-note {
color: var(--muted);
}
.address-detail-empty {
align-content: center;
display: grid;
height: 100%;
justify-items: center;
padding: 24px;
text-align: center;
}
.address-detail-empty p,
.address-empty-note {
margin: 6px 0 0;
}
.address-dialog-form {
display: grid;
gap: 14px;
}
.dialog-panel.address-contact-dialog {
width: min(1120px, calc(100vw - 40px));
max-width: calc(100vw - 40px);
}
.dialog-panel.address-member-dialog {
width: min(720px, calc(100vw - 40px));
max-width: calc(100vw - 40px);
}
.address-member-picker {
display: grid;
gap: 12px;
}
.address-member-search {
border-bottom: 0;
padding: 0;
}
.address-member-candidate-list {
border: var(--border-line);
border-radius: 6px;
display: grid;
max-height: min(460px, calc(100vh - 300px));
overflow: auto;
}
.address-member-candidate {
align-items: center;
border-bottom: var(--border-line);
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.7fr) auto;
min-width: 0;
padding: 10px 12px;
}
.address-member-candidate select {
min-width: 0;
}
.address-member-candidate:last-child {
border-bottom: 0;
}
.address-member-candidate:hover {
background: var(--panel-soft);
}
.address-vcard-textarea {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
min-height: 280px;
}
.address-form-section {
border: var(--border-line);
border-radius: 6px;
display: grid;
gap: 10px;
padding: 12px;
}
.address-form-section-heading,
.address-form-row {
align-items: center;
display: grid;
gap: 8px;
}
.address-form-section-heading {
grid-template-columns: minmax(0, 1fr) auto;
}
.address-form-list {
display: grid;
gap: 8px;
}
.address-form-row-email,
.address-form-row-phone {
grid-template-columns: 92px minmax(88px, 0.3fr) minmax(220px, 1fr) 34px;
}
.address-form-row-postal {
grid-template-columns: 92px minmax(80px, 0.2fr) minmax(180px, 1fr) minmax(90px, 0.35fr) minmax(120px, 0.45fr) minmax(110px, 0.4fr) minmax(120px, 0.45fr) 34px;
}
.address-form-row > *,
.address-form-row input,
.address-form-row select,
.address-form-row textarea {
min-width: 0;
}
.address-primary-choice {
align-items: center;
color: var(--muted);
display: inline-flex;
font-size: 0.86rem;
gap: 6px;
}
.address-form-row .btn {
align-items: center;
aspect-ratio: 1;
display: inline-flex;
justify-content: center;
min-height: 30px;
padding: 0;
width: 30px;
}
.form-grid {
display: grid;
gap: 12px;
}
.form-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.form-grid.four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.inline-actions {
justify-content: flex-end;
}
.inline-actions .btn {
align-items: center;
aspect-ratio: 1;
display: inline-flex;
justify-content: center;
min-height: 30px;
padding: 0;
width: 30px;
}
.strong-link {
font-weight: 700;
}
.link-button {
background: transparent;
border: 0;
color: var(--text-strong);
cursor: pointer;
font: inherit;
padding: 0;
text-align: left;
}
.link-button:hover {
color: var(--accent);
text-decoration: underline;
}
.is-selected-row {
background: var(--panel-soft);
}
.address-book-page .is-archived-row {
opacity: 0.72;
}
@media (max-width: 980px) {
.address-book-workspace,
.form-grid.two,
.form-grid.three,
.form-grid.four,
.address-form-row-email,
.address-form-row-phone,
.address-form-row-postal {
grid-template-columns: 1fr;
}
}