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