40 Commits
Author SHA1 Message Date
zemion aa390a69b9 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 21:07:51 +02:00
zemion aab4019612 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:12 +02:00
zemion 3b7f17f862 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:27 +02:00
zemion 5eaaecff34 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:18:08 +02:00
zemion eba0441acb Make package publication retries hash-safe 2026-08-04 14:32:18 +02:00
zemion ed3e556156 Harden module package publication 2026-08-04 14:02:39 +02:00
zemion f1a2952d83 Fix contact filtering and compact workspace alerts 2026-08-04 08:21:49 +02:00
zemion b3ca069644 Add protected package release workflow 2026-08-04 04:14:01 +02:00
zemion 4f6b223e44 Keep address migration identifiers PostgreSQL-safe 2026-08-03 14:16:45 +02:00
zemion f9a7185ce3 Migrate Addresses interface patterns 2026-08-03 13:40:34 +02:00
zemion 2c421022d4 Add governed tabular and LDAP address sources 2026-08-02 15:39:34 +02:00
zemion e60339a5bf Fix address source revision timestamps 2026-08-02 07:24:11 +02:00
zemion 85e0e31e3d Split address connector portfolio into work packages 2026-08-02 07:08:02 +02:00
zemion 19e9096572 Implement address quality and reversible contact merges 2026-08-02 07:03:27 +02:00
zemion 2e78b9ae50 Add governed contact point snapshots 2026-08-02 06:20:24 +02:00
zemion 67392f620f feat: declare governed external provider state 2026-08-01 17:47:50 +02:00
zemion 41ccd4c807 Add governed contact channel facts 2026-07-31 22:48:07 +02:00
zemion 90a507d9a4 docs: narrow AdreMa ownership to address provider 2026-07-31 14:52:49 +02:00
zemion 0c3d4eecb6 Document AdreMa capability assessment 2026-07-31 11:42:03 +02:00
zemion 4e149ee669 Align WebUI runtime peer dependencies 2026-07-31 02:48:55 +02:00
zemion bf1d7c9678 Paginate address book contacts 2026-07-30 05:22:09 +02:00
zemion 1545ea711e chore(webui): require patched React Router 2026-07-29 14:32:54 +02:00
zemion eab24750f9 Integrate address sources with credential envelopes 2026-07-28 19:33:08 +02:00
zemion 93dddbb8c5 chore(addresses): bump version to 0.1.9 2026-07-22 03:44:32 +02:00
zemion 04accaa206 feat(addresses): expose visible contact people search 2026-07-22 03:02:14 +02:00
zemion 75c9ece709 docs: define CardDAV credential deletion 2026-07-21 15:54:47 +02:00
zemion d005040a8e fix(installer): audit credential retirement 2026-07-21 15:46:20 +02:00
zemion 613fb15a80 fix(secrets): audit connector credential deletion 2026-07-21 15:45:39 +02:00
zemion 5dc9392290 refactor(webui): use Core date-time formatting 2026-07-21 13:52:59 +02:00
zemion 7237679a85 refactor(webui): centralize address selections 2026-07-21 13:36:04 +02:00
zemion 5ff154bc64 Remove unused address link styling 2026-07-21 13:19:20 +02:00
zemion 3ec4b3c4ad Refactor CardDAV report planning branches 2026-07-21 12:53:51 +02:00
zemion 70ee3c0148 refactor(webui): use central reasoned buttons 2026-07-21 12:38:45 +02:00
zemion 5d560d4c58 Remove caller-managed CardDAV secret references 2026-07-21 12:12:49 +02:00
zemion 8b4cf362ca Harden CardDAV credential and network boundaries 2026-07-21 12:12:18 +02:00
zemion b13e5760c8 Clean Addresses audit and test resources 2026-07-21 03:16:38 +02:00
zemion 22d12d674b Confine CardDAV requests to configured origins 2026-07-21 03:16:38 +02:00
zemion 3d52cc86f9 fix(addresses): require group visibility for book creation 2026-07-20 17:03:58 +02:00
zemion f19350e65d intermittent commit 2026-07-14 13:22:09 +02:00
zemion 420120af2f Release v0.1.8 2026-07-11 16:49:04 +02:00
53 changed files with 25430 additions and 17 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Addresses Codex Guide
## Scope
This repository owns reusable postal and electronic address records, address books, normalization, and governed address references for consuming modules.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Addresses internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- Keep identity and organization ownership in their respective modules.
- Expose optional integrations through Core capabilities and typed references.
+134 -17
View File
@@ -1,20 +1,71 @@
# GovOPlaN Addresses # GovOPlaN Addresses
`govoplan-addresses` is the planned reusable address and recipient-source <!-- govoplan-repository-type:start -->
module. It should own long-lived address directories and make them available to **Repository type:** module (domain).
campaigns, mail, forms, reporting, portal, and postbox modules through platform <!-- govoplan-repository-type:end -->
capabilities.
`govoplan-addresses` is the reusable address and recipient-source module. It
owns long-lived address directories and contact points and makes them available
to consumers through platform capabilities.
The campaign module may import campaign-local recipient tables, but reusable The campaign module may import campaign-local recipient tables, but reusable
address management belongs here. 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.
Address quality and duplicate handling are implemented as an operator workflow.
Contact points retain both their original and normalized values, field-level
provenance is append-only, and current quality states can mark a point valid,
invalid, returned, stale, or undeliverable. Those states flow into recipient
resolution with stable reason codes. The quality dialog shows bounded,
explainable duplicate suggestions and a correction queue. Merges record explicit
survivorship decisions, repair address-list memberships, preserve redirects for
stored contact references, and can be undone or split while the post-merge
evidence hash still matches.
API-managed CardDAV credentials are encrypted inside the source record. Source
deletion physically removes that credential material and records a non-secret
audit event in the same database transaction; destructive module retirement
audits every remaining credential before the owning tables are dropped. Legacy
external references are detached but are never sent to a secret provider for
deletion because Addresses cannot prove that it owns them.
## Boundary ## Boundary
`govoplan-addresses` should own: `govoplan-addresses` owns:
- Adrema-style person, organization, household, and postal-address records - scoped address books, vCard-compatible contacts, and postal/email/phone
- reusable email address lists and postal-letter recipient views contact points
- segments and reusable recipient-source definitions - classical address-only lists and recipient-source views
- consent, legal-basis, and communication-preference metadata - consent, legal-basis, and communication-preference metadata
- deduplication, merge, and address quality workflows - deduplication, merge, and address quality workflows
- import/export of reusable address directories - import/export of reusable address directories
@@ -27,21 +78,87 @@ It must not own:
- SMTP/IMAP transport - SMTP/IMAP transport
- file storage - file storage
- global identity authentication or RBAC evaluation - global identity authentication or RBAC evaluation
- typed IDM groups, identity relationships, organization structures, or
effective function assignments
- operational distribution lists/`Verteiler` with mixed recipient types
## First Capability ## Capabilities
The first useful contract should be a read-only recipient-source capability, The module exposes core-mediated capabilities for:
for example `addresses.recipientSource`.
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.contact_point_resolution`: purpose-aware, channel-neutral
resolution and immutable snapshots for email, postal, internal-mail, and
portal targets.
- `addresses.people_search`: privacy-aware contact candidates for shared people
pickers.
- `distribution.recipient_channel_facts`: current channel, governance, and
quality facts for distribution and Policy consumers.
`addresses.recipient_source` returns:
- source id and display label - source id and display label
- normalized recipient rows - normalized recipient rows
- email and postal address fields - email recipient fields
- legal-basis and consent metadata
- source update marker - source update marker
- provenance fields suitable for audit and campaign reports - provenance fields suitable for audit and campaign reports
Consumers must store their own immutable snapshot when they need historical Recipient sources currently include complete address books and classical
evidence. The addresses module remains the owner of the reusable source, not of address lists. Address-list source IDs use `addresses:address_list:<id>` and
the consumer's historical records. 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.
Legacy `addresses.recipient_source` consumers must store their own immutable
snapshot when they need historical evidence. Channel-neutral consumers may use
the dedicated freeze operation described below. The addresses module remains
the owner of reusable sources; domain consumers remain responsible for linking
their own records to snapshot evidence. 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.
For channel-neutral consumers, `addresses.contact_point_resolution` supersedes
the email-only shape without removing it. It accepts local contact IDs and
stable provider references such as `idm:identity:<id>`, applies an effective
date, communication purpose, address purpose, fallback rule, locale, and
domestic/international postal formatting, and returns candidates plus excluded
targets with stable reason codes. Bounded previews are live. A frozen snapshot
stores the complete values, source and governance revisions, provenance, and a
deterministic hash in Addresses so later contact edits cannot rewrite evidence.
The corresponding HTTP API is available below `/api/v1/addresses`:
- `POST /contact-points/resolve`
- `POST /contact-point-sources/preview`
- `POST /contact-point-snapshots`
- `GET /contact-point-snapshots/{snapshot_id}`
Quality, provenance, and reversible merge operations are available through:
- `GET /address-books/{book_id}/quality-summary`
- `GET /address-books/{book_id}/duplicate-suggestions`
- `GET|POST /contacts/{contact_id}/quality-decisions`
- `GET /contacts/{contact_id}/provenance`
- `GET /contacts/{contact_id}/redirect`
- `GET|POST /contact-merges`
- `POST /contact-merges/{merge_id}/undo`
- `POST /contact-merges/{merge_id}/split`
## Design Documents
- [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md)
- [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
- [Address quality and reversible merges](docs/QUALITY_AND_MERGE.md)
- [AdreMa capability assessment and Distribution Lists roadmap](https://git.add-ideas.de/GovOPlaN/govoplan-dist-lists/src/branch/main/docs/ADREMA_CAPABILITY_ASSESSMENT.md)
+296
View File
@@ -0,0 +1,296 @@
# 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
- vCard-compatible contacts and postal/email/phone contact-point 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
- IDM identities, typed groups, effective-dated relationships, or identity
lifecycle state
- organization structures, units, function definitions, or function
assignments
- operational distribution lists/`Verteiler` with mixed address contacts, IDM
identities/groups, functions, raw targets, Dataflow rows, 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
- organization, title, role, department, `KIND`, and `RELATED` values needed for
vCard round-trip compatibility
- 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, preserved original contact-point
values, and append-only field provenance. 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.
- `addresses.contact_point_resolution` version 1.x: resolve channel-neutral
contact points and freeze immutable recipient evidence.
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 legacy snapshot DTO
remains email-oriented for compatible campaign consumers.
Channel-neutral consumers use `addresses.contact_point_resolution`, which
supports email, postal, internal-mail, and portal targets, including postal-only
address-list entries. Requests make effective date, communication purpose,
address purpose, fallback behavior, locale, and domestic/international postal
formatting explicit. Results retain stable subject/contact/contact-point IDs,
source, preference and consent revisions, provenance, and reasons for excluded
or unresolved candidates.
Live previews are bounded to 500 rows per page and 20,000 source members per
request. Frozen snapshots persist resolved values and exclusions with a
deterministic hash; reading a snapshot never resolves the live contact again.
Mixed-audience expansion and final cross-provider Policy/channel decisions
remain owned by Distribution Lists and Policy. The contract is defined in Core,
and Addresses does not import IDM, Organizations, or Distribution Lists
implementations.
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. Because API-managed CardDAV credentials are
encrypted in the source row, the same transaction physically removes their
ciphertext and emits non-secret credential-deletion audit evidence. Destructive
module retirement audits all remaining owned credential material before table
removal. An unowned legacy reference is detached rather than passed to an
external secret provider.
LDAP and Active Directory use the same source, plan, diagnostic, tombstone, and
provider-health records. Endpoints must use LDAPS or StartTLS and may reference
only a visible reusable credential envelope; bind secrets are never copied into
source metadata. Root-DSE discovery returns candidate base DNs. A configured
source performs bounded paged searches and maps explicit attributes to contact
fields. Stable source keys plus `modifyTimestamp`, `uSNChanged`, `entryCSN`, or
a deterministic attribute digest make refreshes idempotent. Only a complete
successful search can infer deletion. A timeout, bind failure, malformed entry,
duplicate key, or configured entry limit retains existing contacts and reports
the source as failed/stale instead of creating tombstones.
## Static Tabular Imports
CSV and XLSX use versioned, scoped mapping profiles rather than live sync
sources. Profiles retain delimiter, encoding, header or worksheet selection,
stable source-key mapping, field mappings, locale and tags, row limits, and
explicit duplicate, blank-value, and existing-contact policies. Updating a
profile creates an immutable next version; prior import runs continue to point
at the reviewed version.
Preview decodes at most 10 MB and 10,000 rows, validates every referenced
column and source key, and returns an effect or diagnostic for every data row.
XLSX parsing is read-only; formulas are rejected and macros/legacy workbook
formats are not accepted. The input SHA-256 and deterministic plan hash are
stored with full effects. Apply uses exactly that plan, rejects changed target
contacts, and is idempotent. Created IDs and pre-update snapshots provide a
guarded rollback: rollback proceeds only while each imported contact still
matches its recorded post-apply hash. Arbitrary transforms remain Dataflow's
responsibility; Files and Datasources are optional origins, not prerequisites
for direct upload.
## Quality, Deduplication, And Recovery
Quality is evidence about a concrete contact point, separate from communication
consent or Policy. Effective decisions use one of `valid`, `invalid`,
`returned`, `stale`, or `undeliverable`, retain reason/evidence references, and
end an overlapping prior decision rather than rewriting history. Recipient
capabilities project the current decision into a stable status and reason code;
consumers can exclude invalid points or explicitly handle stale points without
copying Addresses rules.
Duplicate suggestions are bounded to 500 scanned contacts and 100 returned
pairs. Every score is composed from visible exact-match features such as a
normalized email, phone, postal address, or name/organization combination. A
suggestion does not mutate data.
A merge is an explicit, transactional decision. The caller selects a surviving
contact, scalar-field sources, source precedence, and either union or
survivor-only contact-point handling. The merge records before/after evidence
and hashes, field/contact-point decisions, copied quality/governance evidence,
and stable loser-to-winner redirects. Address-list entries are repointed in the
same transaction. Undo and split restore the recorded contacts and memberships
only when the current evidence still matches the post-merge hash; later edits
must be reconciled first. Core change-sequence evidence is always written. Core
audit entries are written by HTTP mutation routes without requiring the
optional Audit module.
## 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 and reusable CSV/XLSX mapping
profiles (implemented).
4. [Microsoft Graph for Microsoft 365](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/16),
[explicit on-premises Exchange profiles](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/17),
and [Google People](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/18).
5. [LDIF import](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/20)
and [selective/large-batch vCard workflows](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/21).
The live connectors use the existing sync-source model. LDAP is read-only;
Microsoft Graph and Google start with read-only/import and gate two-way mode on
conditional-write and outcome-reconciliation tests. On-premises Exchange first
probes and records an explicit supported server/API profile. CSV/XLSX, LDIF,
and uploaded vCard are static one-way imports, not writable remote sources.
Connector runtime behavior should reuse shared connector concepts where useful:
configured endpoints, credentials, dry-run, diagnostics, rate limits, and audit
events. The shared contract work is tracked in
[`govoplan-connectors#8`](https://git.add-ideas.de/GovOPlaN/govoplan-connectors/issues/8);
Addresses remains the owner of contact mapping, provenance, quality, and sync
state.
## 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 and reusable dynamic segments belong in
`govoplan-dist-lists`. They may consume address lists as one entry type, but
they own mixed recipient expansion for address contacts, IDM identities and
typed groups, organization units, functions/effective incumbents, raw targets,
Dataflow-backed rows, and nested lists. Workflow owns `Umlauf` execution state;
distribution lists define who is included, not how work circulates.
Organizations owns unit and function definitions. IDM owns effective-dated
identity-to-function assignments and typed group relationships. Identity
lifecycle status is not a business audience status; a selectable business
status is represented by a group, function, or effective-dated relationship.
Addresses may link contact points to stable provider references without copying
those provider-owned facts.
## Deferred Work
The following are valuable but not required for the first functional milestone:
- two-way sync conflict UI
- Microsoft/Google connectors
- richer vCard `KIND`/`RELATED` round-trip and provider-reference linking
- advanced consent-policy automation
- contact activity timeline across all modules
+254
View File
@@ -0,0 +1,254 @@
# 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] expose versioned channel-neutral contact-point resolution for local and
stable provider subject references
- [x] support purpose/address-purpose selection, deterministic fallback,
locale, and domestic/international postal rendering
- [x] add bounded source previews and immutable postal/email snapshots
- [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`
- [x] postal-only contacts/list entries can be resolved without changing the
legacy email recipient-source contract
## 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](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/15)
- [ ] [Microsoft Graph contacts connector for Microsoft 365](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/16)
- [ ] [On-premises Exchange connector profile](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/17)
- [ ] [Google People contacts connector](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/18)
- [ ] [Reusable CSV/XLSX import mapping profiles](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/19)
- [ ] [Bounded LDIF import profile](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/20)
- [ ] [Selective and large-batch vCard workflows](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/21)
- [x] classical address-list UI; reusable static/dynamic operational segments move
to `govoplan-dist-lists`
- [x] operational distribution lists move to `govoplan-dist-lists`
- [x] consent, legal-basis, suppression, and communication preferences
- [x] bounded, explainable deduplication and reversible merge/split workflow
- [x] contact-point quality states, normalization, original-value preservation,
field provenance, and correction dashboard
- [x] stable redirect resolution for merged contact references
- [ ] richer vCard `KIND`/`RELATED` round-trip and stable links to IDM/Organizations
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
Issues #9 and #10 are implemented. Issue #8 is complete as a portfolio split:
issues #15-#21 independently track each connector/import profile with explicit
direction, dry-run, diagnostics, provenance, recovery, and module-independence
requirements.
## 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.
+38
View File
@@ -0,0 +1,38 @@
# Addresses Interface Pattern Migration
This migration applies the GovOPlaN interface pattern language to the Address
Book route, source tree, contact directory/detail workspace, governance facts,
imports, synchronization, quality review, and reversible merge operations.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/address-book` source tree | Governed directory | Select, create, archive, restore, import, or connect source | Shared loading/empty/error, permission, read-only, help, and destructive confirmation states |
| Contact list/detail | Directory/list-detail | Inspect, create, revise, archive, restore, export, or add to list | Server pagination, stable selection, contextual contact semantics, and guarded drafts |
| Address-list editor/membership | Reference collection editor | Group reusable contact points | Same-book constraint, duplicate explanation, read-only provenance, and retained references |
| Communication governance | Effective-policy fact editor | Allow, suppress, prefer, or end a channel fact | Effective dates, legal/evidence context, required permissions, and retained history |
| CardDAV/LDAP/import/sync | External-provider operation | Preview and apply bounded external change | Explicit authority/direction, credentials, diagnostics, conflicts, stale state, and outcome evidence |
| Quality and merge | Governed correction workflow | Record quality, merge, undo, or split | Required reason, chosen survivor/field provenance, confirmation, redirects, and reversible evidence |
## Consequence And Availability Rules
- User, group, tenant, and authorized system scopes determine discovery and
management authority. Visible inherited or external sources can be read-only.
- Address lists group contact points in one address book. Cross-module reusable
recipient expansion remains owned by Distribution Lists.
- Archive/delete actions are confirmed and preserve governed history according
to retention. External-source disconnect retains local contacts but removes
source diagnostics and conflict state as explicitly stated.
- Imports and synchronization separate preview from apply; incomplete external
reads never infer deletions.
- Merge and communication-governance operations append auditable evidence and
never silently erase prior state.
- Request feedback is rendered as a compact shared alert over the full-height
workspace. It does not become a grid row or displace the source, contact, and
detail columns.
Backend and WebUI manifests publish matching route/section/action surfaces.
English and German catalogues include the owned interaction vocabulary; major
object drafts are guarded, and optional modules remain behind declared
capabilities rather than private imports.
+57
View File
@@ -0,0 +1,57 @@
# Address Quality And Reversible Merges
## Operator Workflow
Open the shield action for a selected address book to review its quality. The
dialog shows:
- the number of contacts and contact points in the bounded scan
- current invalid, returned, stale, and undeliverable contact points
- explainable duplicate suggestions with their score inputs
- active and recovered merge records
Each contact point also has a `Quality` action in the contact detail. Recording
a new state ends an overlapping current state and retains both entries in
history. Use a stable reason code and an evidence reference when the state came
from delivery, import, or correction evidence.
`valid` makes the point normally usable. `invalid`, `returned`, and
`undeliverable` make it invalid for recipient resolution. `stale` remains a
distinct status so a downstream workflow can warn, request confirmation, or
block according to Policy. A later `valid` decision is a correction; it does
not delete the earlier evidence.
## Duplicate Review
Suggestions do not merge automatically. The score is the bounded sum of named
exact-match features. The operator chooses the surviving contact and whether to
combine unique contact points or retain only the survivor's points. The API can
additionally select the source contact for each scalar field and rank source
kinds.
A successful merge:
- archives each duplicate and redirects its stable contact ID to the survivor
- records scalar and contact-point survivorship decisions
- carries field and contact-point source provenance forward
- copies applicable quality and communication-governance evidence
- repoints address-list entries to the survivor and mapped contact point
- stores deterministic before/after evidence hashes
- emits core change-sequence and audit evidence
The merge history offers `Undo` and `Split`. Both restore the exact recorded
pre-merge contacts and list memberships. Recovery is deliberately rejected when
the contact or membership evidence changed after the merge. Reconcile those
later edits before retrying; the system does not silently discard them.
## Consumer Contract
Consumers resolve live contacts through `addresses.contact_point_resolution` or
`distribution.recipient_channel_facts`. They receive quality status, stable
reason codes, evidence provenance, and the current source revision. Consumers
must not read Addresses tables or recreate quality rules. A workflow requiring
historical proof freezes a contact-point snapshot before delivery.
The duplicate and quality endpoints are bounded. `truncated=true` means the
operator should narrow the source or run a staged API review; it does not mean
that the unreturned contacts were found clean.
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.18",
"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.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+26
View File
@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-addresses"
version = "0.1.18"
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.18",
"ldap3>=2.9.1,<3",
"openpyxl>=3.1.5,<4",
]
[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
View File
@@ -0,0 +1,2 @@
"""GovOPlaN addresses module."""
@@ -0,0 +1,2 @@
"""Backend integration for the GovOPlaN addresses module."""
File diff suppressed because it is too large Load Diff
+581
View File
@@ -0,0 +1,581 @@
from __future__ import annotations
import base64
import posixpath
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
from govoplan_core.security.outbound_http import (
OutboundHttpError,
bounded_response_bytes,
build_outbound_http_opener,
validate_outbound_http_url,
)
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:
candidate = same_origin_dav_url(
self.collection_url,
href,
label="CardDAV object href",
)
collection_parts = urllib.parse.urlparse(self.collection_url)
candidate_parts = urllib.parse.urlparse(candidate)
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
collection_prefix = collection_path.rstrip("/") + "/"
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
raise AddressCardDAVError("CardDAV object href must remain inside the configured collection path")
return candidate
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:
url = validate_outbound_http_url(url, label="CardDAV URL")
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url,
data=body,
headers=dict(headers),
method=method,
)
opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CardDAV URL; redirects remain on origin. # nosec B310
response_headers = dict(response.headers.items())
return response.status, response_headers, bounded_response_bytes(
response,
headers=response_headers,
label="CardDAV response",
)
except urllib.error.HTTPError as exc:
response_headers = dict(exc.headers.items())
try:
payload = bounded_response_bytes(exc, headers=response_headers, label="CardDAV error response")
except OutboundHttpError as policy_exc:
raise AddressCardDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
return exc.code, response_headers, payload
except urllib.error.URLError as exc:
raise AddressCardDAVError(f"{method} {url} failed: {exc.reason}") from exc
except (OutboundHttpError, 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.strip())
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
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")
if parsed.query or parsed.fragment:
raise AddressCardDAVError("CardDAV URL must not include a query or fragment")
_url_origin(parsed)
return urllib.parse.urlunparse(parsed)
def absolute_dav_url(base_url: str, href: str) -> str:
return same_origin_dav_url(base_url, href, label="CardDAV discovery href")
def same_origin_dav_url(base_url: str, href: str, *, label: str) -> str:
base = ensure_collection_url(base_url)
candidate = validate_http_url(urllib.parse.urljoin(base, href))
if _url_origin(urllib.parse.urlparse(candidate)) != _url_origin(urllib.parse.urlparse(base)):
raise AddressCardDAVError(f"{label} must use the configured collection origin")
return candidate
def _url_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
try:
port = parsed.port
except ValueError as exc:
raise AddressCardDAVError("CardDAV URL has an invalid port") from exc
scheme = parsed.scheme.lower()
if port is None:
port = 443 if scheme == "https" else 80
return scheme, (parsed.hostname or "").lower(), port
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, source_url: str) -> None:
super().__init__()
self._source_origin = _url_origin(urllib.parse.urlparse(validate_http_url(source_url)))
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
del fp, msg, headers
try:
candidate = validate_http_url(newurl)
candidate = validate_outbound_http_url(candidate, label="CardDAV redirect URL")
except (AddressCardDAVError, OutboundHttpError):
return None
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
return None
method = req.get_method()
data = req.data
if code == 303 and method != "HEAD":
method, data = "GET", None
elif code in {301, 302} and method == "POST":
method, data = "GET", None
forwarded_headers = {
key: value
for key, value in req.header_items()
if key.casefold() not in {"host", "content-length"}
}
return urllib.request.Request( # noqa: S310 - candidate is validated and same-origin.
candidate,
data=data,
headers=forwarded_headers,
origin_req_host=req.origin_req_host,
unverifiable=True,
method=method,
)
def strip_weak_etag(value: str | None) -> str | None:
return value.strip() if value else None
def response_etag(headers: Mapping[str, str]) -> str | None:
for key, value in headers.items():
if key.lower() == "etag":
return strip_weak_etag(value)
return None
def xml_escape(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def first_child(element: Any, name: str) -> Any | None:
for child in element:
if local_name(child.tag) == name:
return child
return None
def first_child_text(element: Any, name: str) -> str | None:
found = first_child(element, name)
if found is None or found.text is None:
return None
return found.text.strip()
def child_elements(element: Any, name: str) -> list[Any]:
return [child for child in element if local_name(child.tag) == name]
def nested_href_texts(element: Any) -> list[str]:
hrefs: list[str] = []
for child in element.iter():
if local_name(child.tag) == "href" and child.text and child.text.strip():
hrefs.append(child.text.strip())
return hrefs
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
@@ -0,0 +1,2 @@
"""Address module database models."""
+633
View File
@@ -0,0 +1,633 @@
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, UniqueConstraint, 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"),
Index(
"ix_addresses_contacts_source_ref",
"source_ref",
postgresql_using="hash",
),
)
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")
channel_rules: Mapped[list["ContactChannelRule"]] = relationship(
back_populates="contact",
cascade="all, delete-orphan",
order_by="ContactChannelRule.created_at",
)
quality_decisions: Mapped[list["ContactPointQualityDecision"]] = relationship(
back_populates="contact",
cascade="all, delete-orphan",
order_by="ContactPointQualityDecision.created_at",
)
field_provenance: Mapped[list["ContactFieldProvenance"]] = relationship(
back_populates="contact",
cascade="all, delete-orphan",
order_by="ContactFieldProvenance.created_at",
)
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)
original_email: Mapped[str] = mapped_column(String(320), nullable=False, default="")
normalized_email: Mapped[str] = mapped_column(String(320), nullable=False, default="", index=True)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, 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="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)
original_phone: Mapped[str] = mapped_column(String(100), nullable=False, default="")
normalized_phone: Mapped[str] = mapped_column(String(100), nullable=False, default="", index=True)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, 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))
original_value: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
normalized_value: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, 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="postal_addresses")
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_postal_address")
class ContactChannelRule(Base, TimestampMixin):
__tablename__ = "addresses_contact_channel_rules"
__table_args__ = (
Index(
"ix_addresses_channel_rules_resolution",
"tenant_id",
"contact_id",
"channel",
"purpose",
),
Index(
"ix_addresses_channel_rules_effective",
"effective_from",
"effective_until",
),
)
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)
contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
channel: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
purpose: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
contact_point_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
decision: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
legal_basis: Mapped[str | None] = mapped_column(String(255), nullable=True)
evidence_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
preference_rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
locale: Mapped[str | None] = mapped_column(String(20), nullable=True)
effective_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
effective_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
contact: Mapped[Contact] = relationship(back_populates="channel_rules")
class ContactPointSnapshot(Base, TimestampMixin):
__tablename__ = "addresses_contact_point_snapshots"
__table_args__ = (
Index("ix_addresses_contact_point_snapshots_source", "tenant_id", "source_id", "created_at"),
Index("ix_addresses_contact_point_snapshots_hash", "tenant_id", "snapshot_hash"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
source_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
contract_version: Mapped[str] = mapped_column(String(20), nullable=False)
source_revision: Mapped[str] = mapped_column(String(255), nullable=False)
source_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
purpose: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
effective_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
request_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
resolution_payload: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False)
recipient_count: Mapped[int] = mapped_column(Integer, nullable=False)
excluded_count: Mapped[int] = mapped_column(Integer, nullable=False)
snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class ContactPointQualityDecision(Base, TimestampMixin):
__tablename__ = "addresses_contact_point_quality_decisions"
__table_args__ = (
Index(
"ix_addresses_quality_current",
"tenant_id",
"contact_id",
"channel",
"contact_point_id",
"effective_until",
),
Index("ix_addresses_quality_state", "tenant_id", "state", "effective_until"),
Index("ix_addresses_quality_created_by", "created_by_account_id"),
)
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)
contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
channel: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
contact_point_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
reason_code: Mapped[str] = mapped_column(String(120), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
effective_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
effective_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
contact: Mapped[Contact] = relationship(back_populates="quality_decisions")
class ContactMergeRecord(Base, TimestampMixin):
__tablename__ = "addresses_contact_merge_records"
__table_args__ = (
Index("ix_addresses_merge_winner", "tenant_id", "winner_contact_id", "created_at"),
Index("ix_addresses_merge_status", "tenant_id", "status", "created_at"),
)
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,
)
winner_contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
loser_contact_ids: Mapped[list[str]] = mapped_column(JSON, nullable=False)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
reason: Mapped[str] = mapped_column(Text, nullable=False)
survivorship: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
before_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
after_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
before_hash: Mapped[str] = mapped_column(String(64), nullable=False)
after_hash: Mapped[str] = mapped_column(String(64), nullable=False)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
recovered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
recovered_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
recovery_action: Mapped[str | None] = mapped_column(String(30), nullable=True)
recovery_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class ContactRedirect(Base, TimestampMixin):
__tablename__ = "addresses_contact_redirects"
__table_args__ = (
Index(
"uq_addresses_contact_redirects_active_source",
"tenant_id",
"source_contact_id",
unique=True,
sqlite_where=text("ended_at IS NULL"),
postgresql_where=text("ended_at IS NULL"),
),
Index("ix_addresses_contact_redirects_target", "tenant_id", "target_contact_id", "ended_at"),
)
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)
source_contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
target_contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
merge_record_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contact_merge_records.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class ContactFieldProvenance(Base, TimestampMixin):
__tablename__ = "addresses_contact_field_provenance"
__table_args__ = (
Index("ix_addresses_field_provenance_contact", "contact_id", "field_path", "created_at"),
Index("ix_addresses_field_provenance_selected", "tenant_id", "contact_id", "selected"),
)
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)
contact_id: Mapped[str] = mapped_column(
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
field_path: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
value: Mapped[Any] = mapped_column(JSON, nullable=True)
source_kind: Mapped[str] = mapped_column(String(40), nullable=False)
source_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
precedence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
selected: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
reason_code: Mapped[str] = mapped_column(String(120), nullable=False)
explanation: Mapped[str | None] = mapped_column(Text, nullable=True)
visibility: Mapped[str] = mapped_column(String(30), nullable=False, default="inherit")
merge_record_id: Mapped[str | None] = mapped_column(
ForeignKey("addresses_contact_merge_records.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
contact: Mapped[Contact] = relationship(back_populates="field_provenance")
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")
class AddressImportProfile(Base, TimestampMixin):
__tablename__ = "addresses_import_profiles"
__table_args__ = (
UniqueConstraint("profile_key", "version", name="uq_addresses_import_profile_version"),
Index("ix_addresses_import_profiles_scope", "tenant_id", "scope_type", "scope_id", "is_current"),
Index("ix_addresses_import_profiles_format", "tenant_id", "source_format"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
profile_key: Mapped[str] = mapped_column(String(36), nullable=False, default=new_uuid, index=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
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, nullable=True)
source_format: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
configuration: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class AddressImportRun(Base, TimestampMixin):
__tablename__ = "addresses_import_runs"
__table_args__ = (
Index("ix_addresses_import_runs_book_status", "address_book_id", "status", "created_at"),
Index("ix_addresses_import_runs_tenant_hash", "tenant_id", "input_hash"),
)
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,
)
profile_id: Mapped[str] = mapped_column(
ForeignKey("addresses_import_profiles.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
source_filename: Mapped[str] = mapped_column(String(500), nullable=False)
source_format: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
input_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
plan_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="previewed", index=True)
row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
statistics: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
diagnostics: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
plan_data: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
result_evidence: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
rolled_back_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
address_book: Mapped[AddressBook] = relationship()
profile: Mapped[AddressImportProfile] = relationship()
__all__ = [
"AddressBook",
"AddressImportProfile",
"AddressImportRun",
"AddressList",
"AddressListEntry",
"AddressSyncConflict",
"AddressSyncDiagnostic",
"AddressSyncSource",
"AddressSyncTombstone",
"Contact",
"ContactEmail",
"ContactPhone",
"ContactFieldProvenance",
"ContactMergeRecord",
"ContactPointQualityDecision",
"ContactPointSnapshot",
"ContactPostalAddress",
"ContactRedirect",
"new_uuid",
]
@@ -0,0 +1,172 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
AddressImportFormat = Literal["csv", "xlsx"]
AddressImportScope = Literal["user", "group", "tenant", "system"]
IMPORT_TARGET_FIELDS = frozenset(
{
"source_key",
"display_name",
"given_name",
"family_name",
"organization",
"role_title",
"note",
"email",
"phone",
"street",
"postal_code",
"locality",
"region",
"country",
"tags",
"visibility",
}
)
class AddressImportConfiguration(BaseModel):
field_mappings: dict[str, str] = Field(default_factory=dict, max_length=40)
delimiter: Literal[",", ";", "\t", "|"] = ","
encoding: Literal["utf-8", "utf-8-sig", "cp1252", "latin-1"] = "utf-8-sig"
header_row: int = Field(default=1, ge=1, le=100)
sheet_name: str | None = Field(default=None, max_length=255)
source_key_column: str | None = Field(default=None, max_length=255)
duplicate_source_key_policy: Literal["reject", "first", "last"] = "reject"
existing_contact_policy: Literal["update", "ignore", "reject"] = "update"
blank_value_policy: Literal["ignore", "clear", "reject"] = "ignore"
locale: str | None = Field(default=None, max_length=35)
default_tags: list[str] = Field(default_factory=list, max_length=100)
max_rows: int = Field(default=10_000, ge=1, le=10_000)
@model_validator(mode="after")
def validate_mappings(self) -> "AddressImportConfiguration":
invalid = sorted(set(self.field_mappings).difference(IMPORT_TARGET_FIELDS))
if invalid:
raise ValueError(f"Unsupported address import target fields: {', '.join(invalid)}")
for target, column in self.field_mappings.items():
if not target.strip() or not column.strip():
raise ValueError("Import mapping targets and source columns cannot be blank.")
if "source_key" not in self.field_mappings and not self.source_key_column:
raise ValueError("Address import profiles require a stable source-key column.")
return self
class AddressImportProfileCreateRequest(BaseModel):
scope_type: AddressImportScope = "tenant"
scope_id: str | None = Field(default=None, max_length=36)
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
source_format: AddressImportFormat
configuration: AddressImportConfiguration
class AddressImportProfileUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
configuration: AddressImportConfiguration | None = None
class AddressImportProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
profile_key: str
version: int
tenant_id: str | None = None
scope_type: str
scope_id: str | None = None
name: str
description: str | None = None
source_format: str
configuration: AddressImportConfiguration
is_current: bool
created_by_account_id: str | None = None
superseded_at: datetime | None = None
created_at: datetime
updated_at: datetime
class AddressImportProfileListResponse(BaseModel):
profiles: list[AddressImportProfileResponse] = Field(default_factory=list)
class AddressImportFilePayload(BaseModel):
filename: str = Field(min_length=1, max_length=500)
content_base64: str = Field(min_length=1, max_length=14_000_000)
class AddressImportPreviewRequest(AddressImportFilePayload):
profile_id: str = Field(min_length=1, max_length=36)
class AddressImportEffectResponse(BaseModel):
row_number: int
action: Literal["create", "update", "conflict", "unchanged", "ignored"]
source_key: str | None = None
contact_id: str | None = None
display_name: str | None = None
changed_fields: list[str] = Field(default_factory=list)
message: str | None = None
class AddressImportDiagnosticResponse(BaseModel):
severity: Literal["info", "warning", "error"]
code: str
message: str
row_number: int | None = None
field: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
class AddressImportRunResponse(BaseModel):
id: str
address_book_id: str
profile_id: str
source_filename: str
source_format: str
input_hash: str
plan_hash: str
status: str
row_count: int
statistics: dict[str, int] = Field(default_factory=dict)
diagnostics: list[AddressImportDiagnosticResponse] = Field(default_factory=list)
effects: list[AddressImportEffectResponse] = Field(default_factory=list)
can_apply: bool
result_evidence: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
applied_at: datetime | None = None
rolled_back_at: datetime | None = None
class AddressImportCommitRequest(BaseModel):
expected_plan_hash: str = Field(min_length=64, max_length=64)
class AddressImportRollbackRequest(BaseModel):
reason: str = Field(min_length=3, max_length=2000)
__all__ = [
"AddressImportCommitRequest",
"AddressImportConfiguration",
"AddressImportDiagnosticResponse",
"AddressImportEffectResponse",
"AddressImportFilePayload",
"AddressImportFormat",
"AddressImportPreviewRequest",
"AddressImportProfileCreateRequest",
"AddressImportProfileListResponse",
"AddressImportProfileResponse",
"AddressImportProfileUpdateRequest",
"AddressImportRollbackRequest",
"AddressImportRunResponse",
"IMPORT_TARGET_FIELDS",
]
+911
View File
@@ -0,0 +1,911 @@
from __future__ import annotations
import base64
import binascii
import csv
import hashlib
import json
from collections import Counter
from io import BytesIO, StringIO
from typing import Any
from sqlalchemy import and_, false, or_
from sqlalchemy.orm import Session
from govoplan_addresses.backend.db.models import (
AddressImportProfile,
AddressImportRun,
Contact,
)
from govoplan_addresses.backend.import_schemas import (
AddressImportConfiguration,
AddressImportPreviewRequest,
AddressImportProfileCreateRequest,
AddressImportProfileUpdateRequest,
AddressImportRollbackRequest,
)
from govoplan_addresses.backend.schemas import (
ContactCreateRequest,
ContactEmailPayload,
ContactPhonePayload,
ContactPostalAddressPayload,
ContactUpdateRequest,
)
from govoplan_addresses.backend.service import (
AddressBookError,
create_contact,
delete_contact,
get_visible_address_book,
get_visible_contact,
restore_contact,
update_contact,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.db.base import utcnow
MAX_IMPORT_BYTES = 10_000_000
MAX_IMPORT_COLUMNS = 200
def _account_id(principal: ApiPrincipal) -> str:
return principal.account_id
def _tenant_id(principal: ApiPrincipal) -> str:
return principal.tenant_id
def _profile_scope_predicate(principal: ApiPrincipal):
tenant_id = _tenant_id(principal)
predicates = [AddressImportProfile.scope_type == "system"]
predicates.extend(
[
and_(AddressImportProfile.tenant_id == tenant_id, AddressImportProfile.scope_type == "tenant"),
and_(
AddressImportProfile.tenant_id == tenant_id,
AddressImportProfile.scope_type == "user",
AddressImportProfile.scope_id == _account_id(principal),
),
]
)
group_ids = tuple(principal.group_ids)
if group_ids:
predicates.append(
and_(
AddressImportProfile.tenant_id == tenant_id,
AddressImportProfile.scope_type == "group",
AddressImportProfile.scope_id.in_(group_ids),
)
)
return or_(*predicates) if predicates else false()
def list_import_profiles(
session: Session,
principal: ApiPrincipal,
*,
include_history: bool = False,
) -> list[AddressImportProfile]:
query = session.query(AddressImportProfile).filter(_profile_scope_predicate(principal))
if not include_history:
query = query.filter(AddressImportProfile.is_current.is_(True))
return query.order_by(AddressImportProfile.name.asc(), AddressImportProfile.version.desc()).all()
def get_import_profile(
session: Session,
principal: ApiPrincipal,
profile_id: str,
) -> AddressImportProfile:
profile = (
session.query(AddressImportProfile)
.filter(_profile_scope_predicate(principal), AddressImportProfile.id == profile_id)
.one_or_none()
)
if profile is None:
raise AddressBookError("Address import profile not found.")
return profile
def create_import_profile(
session: Session,
principal: ApiPrincipal,
payload: AddressImportProfileCreateRequest,
) -> AddressImportProfile:
tenant_id, scope_id = _validated_profile_scope(principal, payload.scope_type, payload.scope_id)
profile = AddressImportProfile(
tenant_id=tenant_id,
scope_type=payload.scope_type,
scope_id=scope_id,
name=payload.name.strip(),
description=_trim(payload.description),
source_format=payload.source_format,
configuration=payload.configuration.model_dump(mode="json"),
is_current=True,
created_by_account_id=_account_id(principal),
)
session.add(profile)
return profile
def update_import_profile(
session: Session,
principal: ApiPrincipal,
profile_id: str,
payload: AddressImportProfileUpdateRequest,
) -> AddressImportProfile:
current = get_import_profile(session, principal, profile_id)
if not current.is_current:
raise AddressBookError("Only the current import profile version can be updated.")
current.is_current = False
current.superseded_at = utcnow()
next_profile = AddressImportProfile(
profile_key=current.profile_key,
version=current.version + 1,
tenant_id=current.tenant_id,
scope_type=current.scope_type,
scope_id=current.scope_id,
name=(payload.name.strip() if payload.name is not None else current.name),
description=(payload.description.strip() or None if payload.description is not None else current.description),
source_format=current.source_format,
configuration=(
payload.configuration.model_dump(mode="json")
if payload.configuration is not None
else dict(current.configuration or {})
),
is_current=True,
created_by_account_id=_account_id(principal),
)
session.add(next_profile)
return next_profile
def retire_import_profile(
session: Session,
principal: ApiPrincipal,
profile_id: str,
) -> None:
profile = get_import_profile(session, principal, profile_id)
if profile.scope_type == "system" and not principal.has("addresses:address_book:admin"):
raise AddressBookError("System import profiles require address-book administration permission.")
profile.is_current = False
profile.superseded_at = utcnow()
def preview_address_import(
session: Session,
principal: ApiPrincipal,
address_book_id: str,
payload: AddressImportPreviewRequest,
) -> AddressImportRun:
book = get_visible_address_book(session, principal, address_book_id)
if book.read_only:
raise AddressBookError("Static imports require a writable address book.")
profile = get_import_profile(session, principal, payload.profile_id)
raw = _decode_payload(payload.content_base64)
input_hash = hashlib.sha256(raw).hexdigest()
config = AddressImportConfiguration.model_validate(profile.configuration)
rows, parse_diagnostics = _parse_rows(
raw,
filename=payload.filename,
source_format=profile.source_format,
config=config,
)
plan_data, map_diagnostics = _plan_rows(
session,
book_id=book.id,
profile=profile,
input_hash=input_hash,
rows=rows,
config=config,
)
diagnostics = [*parse_diagnostics, *map_diagnostics]
statistics = dict(Counter(item["action"] for item in plan_data))
statistics["rows"] = len(rows)
statistics["errors"] = sum(item["severity"] == "error" for item in diagnostics)
statistics["warnings"] = sum(item["severity"] == "warning" for item in diagnostics)
plan_hash = _hash_json(
{
"profile_id": profile.id,
"profile_version": profile.version,
"address_book_id": book.id,
"input_hash": input_hash,
"plan": plan_data,
}
)
run = AddressImportRun(
tenant_id=book.tenant_id,
address_book_id=book.id,
profile_id=profile.id,
source_filename=payload.filename.strip(),
source_format=profile.source_format,
input_hash=input_hash,
plan_hash=plan_hash,
status="previewed",
row_count=len(rows),
statistics=statistics,
diagnostics=diagnostics,
plan_data=plan_data,
result_evidence={},
created_by_account_id=_account_id(principal),
)
session.add(run)
session.flush()
return run
def get_import_run(
session: Session,
principal: ApiPrincipal,
run_id: str,
) -> AddressImportRun:
visible_book_ids = [book.id for book in _visible_import_books(session, principal)]
if not visible_book_ids:
raise AddressBookError("Address import run not found.")
run = (
session.query(AddressImportRun)
.filter(AddressImportRun.id == run_id, AddressImportRun.address_book_id.in_(visible_book_ids))
.one_or_none()
)
if run is None:
raise AddressBookError("Address import run not found.")
return run
def apply_address_import(
session: Session,
principal: ApiPrincipal,
run_id: str,
*,
expected_plan_hash: str,
) -> AddressImportRun:
run = get_import_run(session, principal, run_id)
if run.status == "applied":
return run
if run.status != "previewed":
raise AddressBookError(f"Import run cannot be applied from status {run.status!r}.")
if run.plan_hash != expected_plan_hash:
raise AddressBookError("The reviewed import plan changed; create a new preview.")
if any(item.get("severity") == "error" for item in run.diagnostics or []):
raise AddressBookError("Import plans with error diagnostics cannot be applied.")
if any(item.get("action") == "conflict" for item in run.plan_data or []):
raise AddressBookError("Resolve import conflicts by correcting the file or mapping profile and preview again.")
created_ids: list[str] = []
updated: list[dict[str, Any]] = []
for item in run.plan_data or []:
action = item.get("action")
if action in {"ignored", "unchanged"}:
continue
source_ref = str(item["source_ref"])
existing = _contact_by_source_ref(session, run.address_book_id, source_ref)
if action == "create":
if existing is not None and existing.deleted_at is None:
raise AddressBookError("A target contact appeared after preview; preview the import again.")
contact = create_contact(
session,
principal,
run.address_book_id,
ContactCreateRequest.model_validate(item["payload"]),
)
_stamp_import_contact(contact, run=run, item=item)
session.flush()
created_ids.append(contact.id)
item["contact_id"] = contact.id
item["after_hash"] = _contact_hash(contact)
elif action == "update":
if existing is None:
raise AddressBookError("An import target disappeared after preview; preview the import again.")
if _contact_hash(existing) != item.get("expected_contact_hash"):
raise AddressBookError(
f'Contact "{existing.display_name}" changed after preview; preview the import again.'
)
before = _contact_snapshot(existing)
if existing.deleted_at is not None:
restore_contact(session, principal, existing.id)
contact = update_contact(
session,
principal,
existing.id,
ContactUpdateRequest.model_validate(item["payload"]),
)
_stamp_import_contact(contact, run=run, item=item)
session.flush()
updated.append({"contact_id": contact.id, "before": before, "after_hash": _contact_hash(contact)})
item["contact_id"] = contact.id
run.status = "applied"
run.applied_at = utcnow()
run.plan_data = list(run.plan_data or [])
run.result_evidence = {
"input_hash": run.input_hash,
"plan_hash": run.plan_hash,
"created_contact_ids": created_ids,
"updated_contacts": updated,
"applied_by_account_id": _account_id(principal),
"applied_at": run.applied_at.isoformat(),
}
return run
def rollback_address_import(
session: Session,
principal: ApiPrincipal,
run_id: str,
payload: AddressImportRollbackRequest,
) -> AddressImportRun:
run = get_import_run(session, principal, run_id)
if run.status == "rolled_back":
return run
if run.status != "applied":
raise AddressBookError("Only an applied import can be rolled back.")
evidence = dict(run.result_evidence or {})
updated = list(evidence.get("updated_contacts") or [])
created_ids = list(evidence.get("created_contact_ids") or [])
expected_hashes = {
str(item["contact_id"]): str(item["after_hash"])
for item in updated
}
expected_hashes.update(
{
str(item["contact_id"]): str(item["after_hash"])
for item in run.plan_data or []
if item.get("contact_id") in created_ids and item.get("after_hash")
}
)
for contact_id, expected_hash in expected_hashes.items():
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
if _contact_hash(contact) != expected_hash:
raise AddressBookError(
f'Contact "{contact.display_name}" changed after import; automatic rollback is unsafe.'
)
for contact_id in created_ids:
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
if contact.deleted_at is None:
delete_contact(session, principal, contact.id)
for item in updated:
contact = get_visible_contact(session, principal, str(item["contact_id"]), include_deleted=True)
snapshot = dict(item["before"])
if contact.deleted_at is not None:
restore_contact(session, principal, contact.id)
update_contact(
session,
principal,
contact.id,
ContactUpdateRequest.model_validate(snapshot["payload"]),
)
contact.source_kind = snapshot.get("source_kind") or "local"
contact.source_ref = snapshot.get("source_ref")
contact.source_revision = snapshot.get("source_revision")
contact.source_payload_kind = snapshot.get("source_payload_kind")
contact.source_payload_raw = snapshot.get("source_payload_raw")
contact.provenance = dict(snapshot.get("provenance") or {})
contact.metadata_ = dict(snapshot.get("metadata") or {})
run.status = "rolled_back"
run.rolled_back_at = utcnow()
run.result_evidence = {
**evidence,
"rollback_reason": payload.reason,
"rolled_back_by_account_id": _account_id(principal),
"rolled_back_at": run.rolled_back_at.isoformat(),
}
return run
def import_run_payload(run: AddressImportRun) -> dict[str, Any]:
diagnostics = list(run.diagnostics or [])
effects = [
{
"row_number": int(item["row_number"]),
"action": item["action"],
"source_key": item.get("source_key"),
"contact_id": item.get("contact_id"),
"display_name": item.get("display_name"),
"changed_fields": list(item.get("changed_fields") or []),
"message": item.get("message"),
}
for item in run.plan_data or []
]
can_apply = (
run.status == "previewed"
and not any(item.get("severity") == "error" for item in diagnostics)
and not any(item.get("action") == "conflict" for item in run.plan_data or [])
)
evidence = dict(run.result_evidence or {})
public_evidence = {
key: evidence[key]
for key in (
"input_hash",
"plan_hash",
"applied_by_account_id",
"applied_at",
"rollback_reason",
"rolled_back_by_account_id",
"rolled_back_at",
)
if evidence.get(key) is not None
}
if evidence:
public_evidence["created_contact_count"] = len(evidence.get("created_contact_ids") or [])
public_evidence["updated_contact_count"] = len(evidence.get("updated_contacts") or [])
return {
"id": run.id,
"address_book_id": run.address_book_id,
"profile_id": run.profile_id,
"source_filename": run.source_filename,
"source_format": run.source_format,
"input_hash": run.input_hash,
"plan_hash": run.plan_hash,
"status": run.status,
"row_count": run.row_count,
"statistics": dict(run.statistics or {}),
"diagnostics": diagnostics,
"effects": effects,
"can_apply": can_apply,
# Full before-images remain private rollback evidence and must not be
# projected through a normal import-run read response.
"result_evidence": public_evidence,
"created_at": run.created_at,
"updated_at": run.updated_at,
"applied_at": run.applied_at,
"rolled_back_at": run.rolled_back_at,
}
def _validated_profile_scope(
principal: ApiPrincipal,
scope_type: str,
requested_scope_id: str | None,
) -> tuple[str | None, str | None]:
if scope_type == "system":
if not principal.has("addresses:address_book:admin"):
raise AddressBookError("System import profiles require address-book administration permission.")
return None, None
tenant_id = _tenant_id(principal)
if scope_type == "tenant":
return tenant_id, tenant_id
if scope_type == "user":
return tenant_id, _account_id(principal)
if scope_type == "group":
scope_id = _trim(requested_scope_id)
if scope_id is None:
raise AddressBookError("Group import profiles require a group id.")
if scope_id not in principal.group_ids and not principal.has("addresses:address_book:admin"):
raise AddressBookError("The selected group is not visible to the current principal.")
return tenant_id, scope_id
raise AddressBookError("Unsupported import profile scope.")
def _decode_payload(encoded: str) -> bytes:
try:
raw = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise AddressBookError("Import file content is not valid base64.") from exc
if not raw:
raise AddressBookError("Import file is empty.")
if len(raw) > MAX_IMPORT_BYTES:
raise AddressBookError(f"Import files are limited to {MAX_IMPORT_BYTES} bytes.")
return raw
def _parse_rows(
raw: bytes,
*,
filename: str,
source_format: str,
config: AddressImportConfiguration,
) -> tuple[list[tuple[int, dict[str, str]]], list[dict[str, Any]]]:
if source_format == "csv":
return _parse_csv(raw, config=config)
if source_format == "xlsx":
if not filename.casefold().endswith(".xlsx"):
raise AddressBookError("XLSX imports require an .xlsx file; macros and legacy workbooks are not accepted.")
return _parse_xlsx(raw, config=config)
raise AddressBookError(f"Unsupported address import format: {source_format!r}.")
def _parse_csv(
raw: bytes,
*,
config: AddressImportConfiguration,
) -> tuple[list[tuple[int, dict[str, str]]], list[dict[str, Any]]]:
try:
text = raw.decode(config.encoding)
except UnicodeDecodeError as exc:
raise AddressBookError(f"CSV is not valid {config.encoding}: {exc}.") from exc
reader = csv.reader(StringIO(text), delimiter=config.delimiter)
all_rows = list(reader)
if len(all_rows) < config.header_row:
raise AddressBookError("CSV does not contain the configured header row.")
header = _headers(all_rows[config.header_row - 1])
result: list[tuple[int, dict[str, str]]] = []
for row_number, values in enumerate(all_rows[config.header_row :], start=config.header_row + 1):
if not any(str(value).strip() for value in values):
continue
if len(values) > MAX_IMPORT_COLUMNS:
raise AddressBookError(f"CSV row {row_number} exceeds the {MAX_IMPORT_COLUMNS}-column limit.")
result.append((row_number, _row_dict(header, values)))
if len(result) > config.max_rows:
raise AddressBookError(f"CSV exceeds the configured {config.max_rows}-row limit.")
return result, []
def _parse_xlsx(
raw: bytes,
*,
config: AddressImportConfiguration,
) -> tuple[list[tuple[int, dict[str, str]]], list[dict[str, Any]]]:
try:
from openpyxl import load_workbook
except ImportError as exc: # pragma: no cover - dependency/package failure
raise AddressBookError("XLSX import support is not installed.") from exc
try:
workbook = load_workbook(BytesIO(raw), read_only=True, data_only=False, keep_links=False)
except Exception as exc:
raise AddressBookError(f"XLSX workbook could not be read: {exc}.") from exc
if len(workbook.sheetnames) > 100:
raise AddressBookError("XLSX workbooks are limited to 100 sheets.")
if config.sheet_name:
if config.sheet_name not in workbook.sheetnames:
raise AddressBookError(f'XLSX sheet "{config.sheet_name}" was not found.')
sheet = workbook[config.sheet_name]
else:
sheet = workbook[workbook.sheetnames[0]]
rows = list(sheet.iter_rows(min_row=config.header_row, max_row=config.header_row))
if not rows:
raise AddressBookError("XLSX does not contain the configured header row.")
header = _headers([cell.value for cell in rows[0]])
result: list[tuple[int, dict[str, str]]] = []
for row_number, cells in enumerate(sheet.iter_rows(min_row=config.header_row + 1), start=config.header_row + 1):
if len(cells) > MAX_IMPORT_COLUMNS:
raise AddressBookError(f"XLSX row {row_number} exceeds the {MAX_IMPORT_COLUMNS}-column limit.")
if any(cell.data_type == "f" for cell in cells):
raise AddressBookError(f"XLSX row {row_number} contains a formula; formulas are never evaluated during import.")
values = [cell.value for cell in cells]
if not any(value is not None and str(value).strip() for value in values):
continue
result.append((row_number, _row_dict(header, values)))
if len(result) > config.max_rows:
raise AddressBookError(f"XLSX exceeds the configured {config.max_rows}-row limit.")
return result, []
def _headers(values: list[Any]) -> list[str]:
headers = [str(value).strip() if value is not None else "" for value in values]
if not headers or not any(headers):
raise AddressBookError("Import header row is empty.")
if len(headers) > MAX_IMPORT_COLUMNS:
raise AddressBookError(f"Import files are limited to {MAX_IMPORT_COLUMNS} columns.")
blank = [index + 1 for index, value in enumerate(headers) if not value]
if blank:
raise AddressBookError(f"Import header contains blank column names at positions {blank}.")
duplicates = sorted(name for name, count in Counter(headers).items() if count > 1)
if duplicates:
raise AddressBookError(f"Import header contains duplicate columns: {', '.join(duplicates)}.")
return headers
def _row_dict(headers: list[str], values: list[Any]) -> dict[str, str]:
padded = [*values, *([None] * max(0, len(headers) - len(values)))]
return {
header: "" if value is None else str(value).strip()
for header, value in zip(headers, padded, strict=False)
}
def _plan_rows(
session: Session,
*,
book_id: str,
profile: AddressImportProfile,
input_hash: str,
rows: list[tuple[int, dict[str, str]]],
config: AddressImportConfiguration,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
diagnostics: list[dict[str, Any]] = []
plan: list[dict[str, Any]] = []
headers = set(rows[0][1]) if rows else set()
referenced_columns = set(config.field_mappings.values())
if config.source_key_column:
referenced_columns.add(config.source_key_column)
missing_columns = sorted(referenced_columns.difference(headers))
for column in missing_columns:
diagnostics.append(_diagnostic("error", "missing_column", f'Configured column "{column}" is missing.', field=column))
if missing_columns:
return [], diagnostics
key_column = config.source_key_column or config.field_mappings["source_key"]
keyed_rows: list[tuple[int, dict[str, str], str]] = []
key_counts: Counter[str] = Counter()
for row_number, row in rows:
key = row.get(key_column, "").strip()
if not key:
diagnostics.append(_diagnostic("error", "missing_source_key", "Stable source key is blank.", row_number=row_number, field=key_column))
plan.append(_plan_effect(row_number, "conflict", source_key=None, message="Stable source key is blank."))
continue
key_counts[key] += 1
keyed_rows.append((row_number, row, key))
first_index: dict[str, int] = {}
last_index: dict[str, int] = {}
for index, (_row_number, _row, key) in enumerate(keyed_rows):
first_index.setdefault(key, index)
last_index[key] = index
for index, (row_number, row, key) in enumerate(keyed_rows):
if key_counts[key] > 1:
if config.duplicate_source_key_policy == "reject":
diagnostics.append(_diagnostic("error", "duplicate_source_key", f'Duplicate source key "{key}".', row_number=row_number, field=key_column))
plan.append(_plan_effect(row_number, "conflict", source_key=key, message="Duplicate source key."))
continue
chosen = first_index[key] if config.duplicate_source_key_policy == "first" else last_index[key]
if index != chosen:
diagnostics.append(_diagnostic("warning", "duplicate_source_key_ignored", f'Duplicate source key "{key}" was ignored by profile policy.', row_number=row_number, field=key_column))
plan.append(_plan_effect(row_number, "ignored", source_key=key, message="Duplicate row ignored by profile policy."))
continue
mapped, row_diagnostics = _mapped_fields(row_number, row, config=config)
diagnostics.extend(row_diagnostics)
source_ref = f"import:{profile.profile_key}:{key}"
existing = _contact_by_source_ref(session, book_id, source_ref)
payload = _payload_from_mapped(mapped, profile=profile, input_hash=input_hash, row_number=row_number, source_key=key)
display_name = payload.get("display_name") or payload.get("email") or key
if any(item["severity"] == "error" for item in row_diagnostics):
plan.append(_plan_effect(row_number, "conflict", source_key=key, display_name=display_name, source_ref=source_ref, payload=payload, message="Row validation failed."))
continue
if existing is None:
plan.append(_plan_effect(row_number, "create", source_key=key, display_name=display_name, source_ref=source_ref, payload=payload, changed_fields=sorted(mapped)))
continue
if config.existing_contact_policy == "ignore":
plan.append(_plan_effect(row_number, "ignored", source_key=key, contact_id=existing.id, display_name=existing.display_name, source_ref=source_ref, payload=payload, message="Existing contact retained by profile policy."))
continue
if config.existing_contact_policy == "reject":
diagnostics.append(_diagnostic("error", "existing_contact", f'Contact for source key "{key}" already exists.', row_number=row_number))
plan.append(_plan_effect(row_number, "conflict", source_key=key, contact_id=existing.id, display_name=existing.display_name, source_ref=source_ref, payload=payload, message="Existing contact rejected by profile policy."))
continue
changed_fields = _changed_fields(existing, mapped)
plan.append(
_plan_effect(
row_number,
"update" if changed_fields or existing.deleted_at is not None else "unchanged",
source_key=key,
contact_id=existing.id,
display_name=display_name,
source_ref=source_ref,
payload=payload,
changed_fields=changed_fields,
expected_contact_hash=_contact_hash(existing),
)
)
return sorted(plan, key=lambda item: item["row_number"]), diagnostics
def _mapped_fields(
row_number: int,
row: dict[str, str],
*,
config: AddressImportConfiguration,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
mapped: dict[str, Any] = {}
diagnostics: list[dict[str, Any]] = []
for target, column in config.field_mappings.items():
if target == "source_key":
continue
value = row.get(column, "").strip()
if not value:
if config.blank_value_policy == "reject":
diagnostics.append(_diagnostic("error", "blank_value", f'Column "{column}" is blank.', row_number=row_number, field=target))
elif config.blank_value_policy == "clear":
mapped[target] = [] if target == "tags" else None
continue
mapped[target] = [item.strip() for item in value.split(",") if item.strip()] if target == "tags" else value
if config.default_tags:
mapped["tags"] = list(dict.fromkeys([*(mapped.get("tags") or []), *config.default_tags]))
if not any(mapped.get(name) for name in ("display_name", "given_name", "family_name", "email", "organization")):
diagnostics.append(_diagnostic("error", "missing_identity", "Row has no name, email, or organization to identify the contact.", row_number=row_number))
return mapped, diagnostics
def _payload_from_mapped(
mapped: dict[str, Any],
*,
profile: AddressImportProfile,
input_hash: str,
row_number: int,
source_key: str,
) -> dict[str, Any]:
display_name = mapped.get("display_name") or " ".join(filter(None, [mapped.get("given_name"), mapped.get("family_name")])) or mapped.get("email") or mapped.get("organization")
payload: dict[str, Any] = {
key: mapped.get(key)
for key in ("given_name", "family_name", "organization", "role_title", "note", "tags")
if key in mapped
}
payload["display_name"] = display_name
if "email" in mapped:
payload["emails"] = [] if mapped["email"] is None else [ContactEmailPayload(email=mapped["email"], is_primary=True).model_dump(mode="json")]
if "phone" in mapped:
payload["phones"] = [] if mapped["phone"] is None else [ContactPhonePayload(phone=mapped["phone"], is_primary=True).model_dump(mode="json")]
postal_keys = {"street", "postal_code", "locality", "region", "country"}
if postal_keys.intersection(mapped):
postal = {key: mapped.get(key) for key in postal_keys if key in mapped}
payload["postal_addresses"] = [ContactPostalAddressPayload(**postal, is_primary=True).model_dump(mode="json")] if any(postal.values()) else []
payload["provenance"] = {
"import": {
"profile_key": profile.profile_key,
"profile_id": profile.id,
"profile_version": profile.version,
"input_hash": input_hash,
"row_number": row_number,
"source_key": source_key,
"locale": profile.configuration.get("locale"),
"visibility": mapped.get("visibility"),
}
}
return ContactCreateRequest.model_validate(payload).model_dump(
mode="json",
exclude_unset=True,
exclude_none=False,
)
def _changed_fields(contact: Contact, mapped: dict[str, Any]) -> list[str]:
current: dict[str, Any] = {
"display_name": contact.display_name,
"given_name": contact.given_name,
"family_name": contact.family_name,
"organization": contact.organization,
"role_title": contact.role_title,
"note": contact.note,
"tags": list(contact.tags or []),
"email": contact.emails[0].email if contact.emails else None,
"phone": contact.phones[0].phone if contact.phones else None,
}
if contact.postal_addresses:
postal = contact.postal_addresses[0]
current.update({key: getattr(postal, key) for key in ("street", "postal_code", "locality", "region", "country")})
return sorted(key for key, value in mapped.items() if key != "visibility" and current.get(key) != value)
def _plan_effect(
row_number: int,
action: str,
*,
source_key: str | None,
contact_id: str | None = None,
display_name: str | None = None,
source_ref: str | None = None,
payload: dict[str, Any] | None = None,
changed_fields: list[str] | None = None,
message: str | None = None,
expected_contact_hash: str | None = None,
) -> dict[str, Any]:
return {
"row_number": row_number,
"action": action,
"source_key": source_key,
"contact_id": contact_id,
"display_name": display_name,
"source_ref": source_ref,
"payload": payload or {},
"changed_fields": changed_fields or [],
"message": message,
"expected_contact_hash": expected_contact_hash,
}
def _diagnostic(
severity: str,
code: str,
message: str,
*,
row_number: int | None = None,
field: str | None = None,
) -> dict[str, Any]:
return {
"severity": severity,
"code": code,
"message": message,
"row_number": row_number,
"field": field,
"details": {},
}
def _contact_by_source_ref(session: Session, book_id: str, source_ref: str) -> Contact | None:
return (
session.query(Contact)
.filter(Contact.address_book_id == book_id, Contact.source_ref == source_ref)
.order_by(Contact.created_at.asc(), Contact.id.asc())
.first()
)
def _stamp_import_contact(contact: Contact, *, run: AddressImportRun, item: dict[str, Any]) -> None:
contact.source_kind = run.source_format
contact.source_ref = item["source_ref"]
contact.source_revision = hashlib.sha256(
f'{run.input_hash}:{item["row_number"]}:{item["source_key"]}'.encode()
).hexdigest()
contact.source_payload_kind = f"{run.source_format}-mapped-row"
contact.source_payload_raw = None
provenance = dict(contact.provenance or {})
provenance["import_run_id"] = run.id
provenance["input_hash"] = run.input_hash
provenance["plan_hash"] = run.plan_hash
contact.provenance = provenance
def _contact_snapshot(contact: Contact) -> dict[str, Any]:
return {
"payload": {
"display_name": contact.display_name,
"given_name": contact.given_name,
"family_name": contact.family_name,
"organization": contact.organization,
"role_title": contact.role_title,
"note": contact.note,
"tags": list(contact.tags or []),
"emails": [{"label": item.label, "email": item.email, "is_primary": item.is_primary} for item in contact.emails],
"phones": [{"label": item.label, "phone": item.phone, "is_primary": item.is_primary} for item in contact.phones],
"postal_addresses": [
{
"label": item.label,
"street": item.street,
"postal_code": item.postal_code,
"locality": item.locality,
"region": item.region,
"country": item.country,
"is_primary": item.is_primary,
}
for item in contact.postal_addresses
],
"provenance": dict(contact.provenance or {}),
},
"source_kind": contact.source_kind,
"source_ref": contact.source_ref,
"source_revision": contact.source_revision,
"source_payload_kind": contact.source_payload_kind,
"source_payload_raw": contact.source_payload_raw,
"provenance": dict(contact.provenance or {}),
"metadata": dict(contact.metadata_ or {}),
}
def _contact_hash(contact: Contact) -> str:
return _hash_json({**_contact_snapshot(contact), "deleted_at": contact.deleted_at.isoformat() if contact.deleted_at else None})
def _hash_json(value: object) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()).hexdigest()
def _trim(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
return normalized or None
def _visible_import_books(session: Session, principal: ApiPrincipal):
from govoplan_addresses.backend.service import list_address_books
return list_address_books(session, principal)
__all__ = [
"apply_address_import",
"create_import_profile",
"get_import_profile",
"get_import_run",
"import_run_payload",
"list_import_profiles",
"preview_address_import",
"retire_import_profile",
"rollback_address_import",
"update_import_profile",
]
+196
View File
@@ -0,0 +1,196 @@
from __future__ import annotations
import ssl
from dataclasses import dataclass
from typing import Any
from urllib.parse import unquote, urlsplit
from govoplan_core.core.connector_runtime import ConnectorContractError, ConnectorEndpoint
class AddressLdapError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class AddressLdapEntry:
dn: str
attributes: dict[str, Any]
@dataclass(frozen=True, slots=True)
class AddressLdapSearchResult:
base_dn: str
entries: tuple[AddressLdapEntry, ...]
complete: bool
page_size: int
class AddressLdapClient:
def __init__(
self,
*,
url: str,
bind_dn: str | None = None,
password: str | None = None,
start_tls: bool = True,
connect_timeout: int = 10,
receive_timeout: int = 30,
) -> None:
try:
endpoint = ConnectorEndpoint(
url=url,
tls_mode="start_tls" if start_tls else "required",
)
except ConnectorContractError as exc:
raise AddressLdapError(str(exc)) from exc
parsed = urlsplit(endpoint.url)
if parsed.scheme not in {"ldap", "ldaps"}:
raise AddressLdapError("LDAP endpoints must use ldap:// or ldaps://.")
if parsed.scheme == "ldap" and not start_tls:
raise AddressLdapError("ldap:// endpoints require StartTLS.")
if parsed.path not in {"", "/"}:
self.default_base_dn = unquote(parsed.path.lstrip("/"))
else:
self.default_base_dn = None
self.url = endpoint.url
self.host = parsed.hostname or ""
self.port = parsed.port or (636 if parsed.scheme == "ldaps" else 389)
self.use_ssl = parsed.scheme == "ldaps"
self.start_tls = parsed.scheme == "ldap" and start_tls
self.bind_dn = bind_dn
self.password = password
self.connect_timeout = max(1, min(connect_timeout, 30))
self.receive_timeout = max(1, min(receive_timeout, 120))
def discover_base_dns(self) -> tuple[str, ...]:
connection = self._connection()
try:
from ldap3 import BASE
if not connection.search(
search_base="",
search_filter="(objectClass=*)",
search_scope=BASE,
attributes=["namingContexts", "defaultNamingContext", "rootDomainNamingContext"],
):
raise AddressLdapError(_ldap_result_message(connection.result, "LDAP root DSE discovery failed."))
values: list[str] = []
for entry in connection.entries:
data = entry.entry_attributes_as_dict
for key in ("defaultNamingContext", "rootDomainNamingContext", "namingContexts"):
for value in _as_values(data.get(key)):
normalized = str(value).strip()
if normalized and normalized not in values:
values.append(normalized)
if self.default_base_dn and self.default_base_dn not in values:
values.insert(0, self.default_base_dn)
return tuple(values)
finally:
connection.unbind()
def search(
self,
*,
base_dn: str,
search_filter: str,
attributes: tuple[str, ...],
page_size: int = 500,
max_entries: int = 10_000,
) -> AddressLdapSearchResult:
normalized_base = base_dn.strip() or self.default_base_dn
if not normalized_base:
raise AddressLdapError("LDAP base DN is required.")
page_size = max(1, min(page_size, 1_000))
max_entries = max(1, min(max_entries, 10_000))
connection = self._connection()
entries: list[AddressLdapEntry] = []
complete = True
try:
try:
stream = connection.extend.standard.paged_search(
search_base=normalized_base,
search_filter=search_filter,
attributes=list(attributes),
paged_size=page_size,
generator=True,
)
for response in stream:
response_type = response.get("type")
if response_type != "searchResEntry":
continue
if len(entries) >= max_entries:
complete = False
break
entries.append(
AddressLdapEntry(
dn=str(response.get("dn") or ""),
attributes=dict(response.get("attributes") or {}),
)
)
except Exception as exc:
raise AddressLdapError(f"LDAP paged search failed: {exc}.") from exc
if connection.result and int(connection.result.get("result", 0) or 0) != 0:
raise AddressLdapError(_ldap_result_message(connection.result, "LDAP paged search failed."))
return AddressLdapSearchResult(
base_dn=normalized_base,
entries=tuple(entries),
complete=complete,
page_size=page_size,
)
finally:
connection.unbind()
def _connection(self):
try:
from ldap3 import Connection, Server, Tls
except ImportError as exc: # pragma: no cover - package failure
raise AddressLdapError("LDAP connector support is not installed.") from exc
tls = Tls(validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLS_CLIENT)
server = Server(
self.host,
port=self.port,
use_ssl=self.use_ssl,
tls=tls,
connect_timeout=self.connect_timeout,
)
try:
connection = Connection(
server,
user=self.bind_dn,
password=self.password,
receive_timeout=self.receive_timeout,
raise_exceptions=True,
)
connection.open()
if self.start_tls:
connection.start_tls()
connection.bind()
return connection
except Exception as exc:
raise AddressLdapError(f"LDAP connection or bind failed: {exc}.") from exc
def _as_values(value: Any) -> tuple[Any, ...]:
if value is None:
return ()
if isinstance(value, (list, tuple, set)):
return tuple(value)
return (value,)
def _ldap_result_message(result: dict[str, Any] | None, fallback: str) -> str:
if not result:
return fallback
description = str(result.get("description") or "").strip()
message = str(result.get("message") or "").strip()
detail = ": ".join(part for part in (description, message) if part)
return f"{fallback} {detail}".strip()
__all__ = [
"AddressLdapClient",
"AddressLdapEntry",
"AddressLdapError",
"AddressLdapSearchResult",
]
@@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, model_validator
DEFAULT_LDAP_ATTRIBUTE_MAP: dict[str, str] = {
"source_key": "entryUUID",
"source_revision": "modifyTimestamp",
"display_name": "displayName",
"given_name": "givenName",
"family_name": "sn",
"organization": "o",
"role_title": "title",
"email": "mail",
"phone": "telephoneNumber",
"street": "streetAddress",
"postal_code": "postalCode",
"locality": "l",
"region": "st",
"country": "c",
"tags": "memberOf",
}
class AddressLdapConnectionRequest(BaseModel):
url: str = Field(min_length=1, max_length=2000)
credential_ref: str | None = Field(default=None, max_length=1000)
bind_dn: str | None = Field(default=None, max_length=1000)
start_tls: bool = True
connect_timeout: int = Field(default=10, ge=1, le=30)
receive_timeout: int = Field(default=30, ge=1, le=120)
class AddressLdapDiscoveryResponse(BaseModel):
base_dns: list[str] = Field(default_factory=list)
class AddressLdapSourceCreateRequest(AddressLdapConnectionRequest):
display_name: str = Field(min_length=1, max_length=255)
base_dn: str = Field(min_length=1, max_length=2000)
search_filter: str = Field(default="(&(objectClass=person)(mail=*))", min_length=1, max_length=2000)
page_size: int = Field(default=500, ge=1, le=1000)
max_entries: int = Field(default=10_000, ge=1, le=10_000)
attribute_map: dict[str, str] = Field(default_factory=lambda: dict(DEFAULT_LDAP_ATTRIBUTE_MAP), max_length=40)
@model_validator(mode="after")
def validate_mapping(self) -> "AddressLdapSourceCreateRequest":
if "source_key" not in self.attribute_map:
raise ValueError("LDAP mappings require a stable source_key attribute.")
if not any(key in self.attribute_map for key in ("display_name", "email", "given_name", "family_name", "organization")):
raise ValueError("LDAP mappings require at least one contact identity attribute.")
if any(not key.strip() or not value.strip() for key, value in self.attribute_map.items()):
raise ValueError("LDAP mapping names and attributes cannot be blank.")
return self
class AddressLdapTestResponse(BaseModel):
success: bool
base_dn: str
sampled_entries: int
attributes: list[str] = Field(default_factory=list)
diagnostic: dict[str, Any] = Field(default_factory=dict)
__all__ = [
"AddressLdapConnectionRequest",
"AddressLdapDiscoveryResponse",
"AddressLdapSourceCreateRequest",
"AddressLdapTestResponse",
"DEFAULT_LDAP_ATTRIBUTE_MAP",
]
+557
View File
@@ -0,0 +1,557 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from sqlalchemy import inspect
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.contact_points import CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ProviderBehaviorDeclaration,
ProviderObjectDeclaration,
declared_module_architecture,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_addresses.backend.provider_state import (
CARDDAV_PROVIDER_ID,
LDAP_PROVIDER_ID,
carddav_provider_states,
ldap_provider_states,
)
_addresses_table_retirement_provider = drop_table_retirement_provider(
addresses_models.AddressImportRun,
addresses_models.AddressImportProfile,
addresses_models.ContactFieldProvenance,
addresses_models.ContactRedirect,
addresses_models.ContactMergeRecord,
addresses_models.ContactPointQualityDecision,
addresses_models.ContactPointSnapshot,
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.ContactChannelRule,
addresses_models.Contact,
addresses_models.AddressBook,
label="Addresses",
)
def _addresses_retirement_provider(session: object | None, module_id: str):
plan = _addresses_table_retirement_provider(session, module_id)
base_executor = plan.destroy_data_executor
if base_executor is None:
return plan
def executor(execute_session: object, execute_module_id: str) -> None:
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
raise RuntimeError("No database session is available for Addresses credential retirement.")
if inspect(execute_session.get_bind()).has_table(addresses_models.AddressSyncSource.__tablename__):
from govoplan_addresses.backend.service import audit_address_credentials_for_retirement
audit_address_credentials_for_retirement(execute_session)
base_executor(execute_session, execute_module_id)
return replace(
plan,
destroy_data_warnings=(
*plan.destroy_data_warnings,
"Addresses-owned encrypted connector credentials are audited and deleted with the sync-source table.",
),
destroy_data_executor=executor,
)
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:governance:read", "View communication governance", "Inspect effective-dated consent, suppression, and channel-preference facts."),
_permission("addresses:governance:write", "Manage communication governance", "Record and end consent, suppression, and channel-preference facts."),
_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:governance:read",
"addresses:governance:write",
"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:governance:read", "addresses:sync:read"),
),
)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressImportProfile,
AddressImportRun,
AddressList,
AddressSyncSource,
Contact,
ContactMergeRecord,
ContactPointQualityDecision,
ContactPointSnapshot,
)
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(),
"active_contact_merges": session.query(ContactMergeRecord).filter(ContactMergeRecord.tenant_id == tenant_id, ContactMergeRecord.status == "active").count(),
"contact_quality_decisions": session.query(ContactPointQualityDecision).filter(ContactPointQualityDecision.tenant_id == tenant_id).count(),
"contact_point_snapshots": session.query(ContactPointSnapshot).filter(ContactPointSnapshot.tenant_id == tenant_id).count(),
"sync_sources": session.query(AddressSyncSource).filter(AddressSyncSource.tenant_id == tenant_id, AddressSyncSource.enabled.is_(True)).count(),
"address_import_profiles": session.query(AddressImportProfile).filter(AddressImportProfile.tenant_id == tenant_id, AddressImportProfile.is_current.is_(True)).count(),
"address_import_runs": session.query(AddressImportRun).filter(AddressImportRun.tenant_id == tenant_id).count(),
}
def _addresses_router(_context: ModuleContext):
from govoplan_addresses.backend.router import router
return router
CARDDAV_PROVIDER = ExternalProviderDeclaration(
id=CARDDAV_PROVIDER_ID,
module_id="addresses",
label="CardDAV address-book synchronization",
maturity="synchronize",
operations=("discover", "read", "write", "delete", "synchronize", "preview"),
objects=(
ProviderObjectDeclaration(
object_type="address_book",
field_groups=("identity", "display", "sync_state"),
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
default_authority_mode="external_mirror",
),
ProviderObjectDeclaration(
object_type="contact",
field_groups=("identity", "name", "postal", "email", "phone", "source_metadata"),
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
default_authority_mode="governed_sync",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="CardDAV sync tokens, resource hrefs, and ETags are retained.",
concurrency="Conditional writes reject stale ETags and preserve explicit conflicts.",
freshness="Last attempt, last success, source status, and sync token are recorded.",
health="Transport failures, diagnostics, and unresolved conflicts are projected separately.",
max_read_items=5000,
idempotency="Stable source, href, UID, and ETag facts prevent duplicate contact effects.",
retry="Only a new governed sync attempt retries failed transport operations.",
timeout_seconds=30,
conflicts="Local and remote values remain in an explicit conflict record until resolved.",
outcome_unknown="Timed-out writes require a subsequent CardDAV read before correction or retry.",
outcome_unknown_supported=True,
evidence="Sync diagnostics, tombstones, conflicts, source revisions, and contact provenance are retained.",
audit_event_types=(
"addresses.sync.started",
"addresses.sync.finished",
"addresses.sync.conflict_recorded",
),
correction="A resolved conflict or later synchronized revision corrects state without rewriting prior evidence.",
rollback="Remote writes are not assumed to be transactionally reversible.",
compensation="A reconciled update or tombstone can compensate after the remote outcome is known.",
reconciliation="Read by resource href and compare ETag, UID, and local revision before applying changes.",
outage="Existing local contacts remain available with stale or unknown freshness.",
classifications=("personal", "confidential"),
purposes=("address-book synchronization", "governed recipient resolution"),
retention="Address-book and audit retention policies apply independently.",
secret_handling="Only credential references and sanitized authentication metadata are persisted in sync state.",
),
capability_names=(CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_CONTACT_WRITER),
documentation_topic_ids=("addresses.boundary",),
)
LDAP_PROVIDER = ExternalProviderDeclaration(
id=LDAP_PROVIDER_ID,
module_id="addresses",
label="Read-only LDAP and Active Directory contacts",
maturity="synchronize",
operations=("discover", "read", "preview", "synchronize"),
objects=(
ProviderObjectDeclaration(
object_type="contact",
field_groups=("identity", "name", "organization", "postal", "email", "phone", "source_metadata"),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_authoritative",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="Stable LDAP source keys plus modifyTimestamp, uSNChanged, entryCSN, or a deterministic attribute digest are retained.",
concurrency="LDAP is authoritative and read-only; local projections are replaced only from a complete reviewed plan.",
freshness="Last attempt, last success, remote revision, and stale provider health remain visible.",
health="TLS, bind, discovery, paging, mapping, truncation, and malformed-entry failures are separate diagnostics.",
max_read_items=10000,
idempotency="The source binding, stable key, and revision prevent duplicate contact projections.",
retry="Failed reads are retried only by a new operator or scheduled sync attempt with bounded timeouts.",
timeout_seconds=120,
conflicts="Duplicate source keys, malformed mappings, and locally changed projections block or require a fresh plan.",
outcome_unknown="Read failures never infer external deletions and retain prior local projections as stale.",
outcome_unknown_supported=True,
evidence="Source keys, revisions, mapping configuration, diagnostics, tombstones, and normalized field provenance are retained.",
audit_event_types=(
"addresses.sync_source_created",
"addresses.sync_previewed",
"addresses.sync_completed",
),
correction="Correct the directory or mapping, then run a new full preview and synchronization.",
rollback="Prior projections remain reconstructable from source revision and contact change evidence; external LDAP is never mutated.",
compensation="A later authoritative refresh restores corrected projections.",
reconciliation="Only a complete paged search may infer an absent source object and create a local tombstone.",
outage="Existing contacts remain available and visibly stale; an unavailable directory never causes deletes.",
classifications=("personal", "confidential", "restricted"),
purposes=("directory projection", "recipient resolution", "identity-linked contact discovery"),
retention="Address, audit, and records policies govern local projections and tombstone evidence.",
secret_handling="Bind secrets remain in reusable credential envelopes; URLs, previews, and diagnostics contain no credentials.",
),
capability_names=(CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_CONTACT_WRITER),
documentation_topic_ids=("addresses.ldap-directory",),
)
manifest = ModuleManifest(
id="addresses",
name="Addresses",
version="0.1.18",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox", "connectors"),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, version="1.0.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"),
),
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),),
view_surfaces=(
ViewSurface(id="addresses.page", module_id="addresses", kind="route", label="Address Book", order=80),
ViewSurface(id="addresses.sources", module_id="addresses", kind="section", label="Address sources", order=10),
ViewSurface(id="addresses.contacts", module_id="addresses", kind="section", label="Contacts", order=20),
ViewSurface(id="addresses.detail", module_id="addresses", kind="section", label="Contact detail", order=30),
ViewSurface(id="addresses.governance", module_id="addresses", kind="action", label="Communication governance", order=40),
ViewSurface(id="addresses.sync", module_id="addresses", kind="action", label="Address synchronization", order=50),
),
),
migration_spec=MigrationSpec(
module_id="addresses",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=_addresses_retirement_provider,
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_PEOPLE_SEARCH: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["people_search_capability"]).people_search_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),
CAPABILITY_RECIPIENT_CHANNEL_FACTS: lambda context: __import__(
"govoplan_addresses.backend.capabilities",
fromlist=["channel_facts_capability"],
).channel_facts_capability(context),
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION: lambda context: __import__(
"govoplan_addresses.backend.capabilities",
fromlist=["contact_point_resolution_capability"],
).contact_point_resolution_capability(context),
},
uninstall_guard_providers=(
persistent_table_uninstall_guard(
addresses_models.AddressImportRun,
addresses_models.AddressImportProfile,
addresses_models.AddressSyncDiagnostic,
addresses_models.AddressSyncConflict,
addresses_models.AddressSyncTombstone,
addresses_models.AddressSyncSource,
addresses_models.AddressListEntry,
addresses_models.AddressList,
addresses_models.ContactFieldProvenance,
addresses_models.ContactRedirect,
addresses_models.ContactMergeRecord,
addresses_models.ContactPointQualityDecision,
addresses_models.ContactPointSnapshot,
addresses_models.AddressBook,
addresses_models.Contact,
addresses_models.ContactEmail,
addresses_models.ContactPhone,
addresses_models.ContactPostalAddress,
addresses_models.ContactChannelRule,
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,
metadata={
"seed": True,
"help_contexts": [
"addresses.page",
"addresses.sources",
"addresses.contacts",
"addresses.detail",
"addresses.state.read-only",
],
},
),
DocumentationTopic(
id="addresses.contact-point-resolution",
title="Contact-point resolution and snapshots",
summary="Resolve purpose-aware channel targets and freeze immutable recipient evidence.",
body=(
"Addresses exposes a versioned contact-point capability for email, postal, internal-mail, and portal targets. "
"Callers can request an effective date, communication purpose, address purpose, fallback rule, locale, and "
"postal format. Bounded previews remain live; frozen snapshots retain the resolved values, exclusions, "
"source and governance revisions, provenance, and a deterministic evidence hash even after contacts change."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("dist_lists", "campaigns", "policy", "templates"),
order=31,
metadata={
"seed": True,
"help_contexts": [
"addresses.governance",
"addresses.field.channel",
"addresses.field.contact-point",
"addresses.field.communication-purpose",
"addresses.field.effective-period",
],
},
),
DocumentationTopic(
id="addresses.tabular-imports",
title="CSV and XLSX contact imports",
summary="Preview and apply reusable, versioned contact mappings without silent row loss.",
body=(
"CSV and XLSX files can be mapped with scoped, reusable profile versions. Each preview validates headers, "
"encodings, source keys, duplicates, blank values, workbook limits, and contact identity before any mutation. "
"The reviewed input hash and plan hash are retained with row-level effects and diagnostics. Apply is idempotent, "
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. XLSX formulas, "
"macros, and legacy workbook formats are never executed or imported."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "module_admin", "power_user"),
related_modules=("connectors", "datasources", "dataflow", "files", "audit"),
order=33,
),
DocumentationTopic(
id="addresses.ldap-directory",
title="LDAP and Active Directory address sources",
summary="Project authoritative directory contacts through a bounded, read-only synchronization source.",
body=(
"LDAP sources use LDAPS or StartTLS and reusable credential envelopes. Discovery finds available base DNs; "
"the source profile then controls a bounded paged filter and explicit attribute mapping. Preview never mutates "
"contacts. A complete successful read may create, update, or tombstone local projections; truncated or failed "
"reads suppress absence-based deletes and mark the source stale. Stable source keys, revisions, normalized fields, "
"and provenance remain attached to every retained contact."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("connectors", "idm", "access", "policy", "audit"),
order=34,
),
DocumentationTopic(
id="addresses.quality-and-merge",
title="Contact quality, duplicates, and reversible merges",
summary="Review address quality and duplicate suggestions without losing source evidence.",
body=(
"Addresses preserves original and normalized contact-point values, records field-level provenance, "
"and projects invalid, returned, stale, or undeliverable states into recipient resolution with stable "
"reason codes. Duplicate suggestions are bounded and explain their matching features. An operator can "
"choose the surviving values, merge contact points, and later undo or split the merge while the recorded "
"post-merge evidence still matches. Contact redirects keep stored references resolvable, and address-list "
"memberships are repaired transactionally. Audit remains an optional integration; the Addresses change "
"sequence and merge evidence are always retained."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("campaigns", "dist_lists", "policy", "audit"),
order=32,
),
DocumentationTopic(
id="addresses.reference.fields-and-consequences",
title="Address fields, scope, and action consequences",
summary="Scope, source authority, contact points, list membership, archival, synchronization, and merge consequences.",
body=(
"Address books are scoped to a user, group, tenant, or authorized system context. Inherited and externally authoritative "
"books may remain visible but read-only. Contacts own reusable name, organization, electronic, phone, postal, tag, note, "
"quality, and provenance facts; address lists reference contact points from the same book and do not replace Distribution "
"Lists. Archival hides a book, list, or contact from ordinary selection while preserving governed history and references. "
"CardDAV and LDAP sources expose their direction, authority, freshness, diagnostics, conflict, and stale-state behavior. "
"Imports and synchronization require preview before mutation. Contact merges select a survivor and field provenance, repair "
"list references transactionally, and retain redirects and evidence so a matching merge can be undone or split."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin", "power_user"),
related_modules=("dist_lists", "connectors", "datasources", "campaigns", "policy", "audit"),
order=35,
metadata={
"seed": True,
"help_contexts": [
"addresses.field.book-scope",
"addresses.field.contact-identity",
"addresses.field.organization",
"addresses.field.contact-point",
"addresses.action.archive",
"addresses.action.import",
"addresses.action.sync",
"addresses.action.merge",
],
"consequence_classes": {
"archive": "Removes the object from ordinary selection while retaining governed history and references.",
"import_or_sync": "Applies only a reviewed bounded plan and retains source revision, diagnostics, and provenance.",
"merge": "Repoints governed references to a survivor and retains reversible redirect and provenance evidence.",
"governance_fact": "Adds or ends an effective-dated communication decision without erasing prior facts.",
},
},
),
),
external_providers=(CARDDAV_PROVIDER, LDAP_PROVIDER),
external_provider_state_providers=(
ExternalProviderStateProviderRegistration(
module_id="addresses",
provider_id=CARDDAV_PROVIDER_ID,
provider=carddav_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="addresses",
provider_id=LDAP_PROVIDER_ID,
provider=ldap_provider_states,
),
),
architecture=declared_module_architecture(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/ADDRESS_MODULE_ARCHITECTURE.md",
test_ref="tests/test_addresses_service.py",
known_limits=("External address-book synchronization remains a bounded connector slice rather than a supported provider profile.",),
supported_authority_modes=(
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
),
owned_concepts=("contact point", "address book", "contact consent", "recipient source"),
non_owned_concepts=("identity", "organization", "campaign recipient snapshot", "procedure party"),
target_tested_providers=(CARDDAV_PROVIDER_ID,),
security_docs=("docs/ADDRESS_MODULE_ARCHITECTURE.md",),
operations_docs=("README.md",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1,2 @@
"""Address module migration package."""
@@ -0,0 +1,2 @@
"""Address module Alembic revisions."""
@@ -0,0 +1,63 @@
"""Add immutable contact-point snapshots.
Revision ID: a3b5c6d7e8f9
Revises: f2a4b5c6d7e
"""
from alembic import op
import sqlalchemy as sa
revision = "a3b5c6d7e8f9"
down_revision = "f2a4b5c6d7e"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"ix_addresses_contacts_source_ref",
"addresses_contacts",
["source_ref"],
unique=False,
postgresql_using="hash",
)
op.create_table(
"addresses_contact_point_snapshots",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("source_id", sa.String(length=255), nullable=False),
sa.Column("contract_version", sa.String(length=20), nullable=False),
sa.Column("source_revision", sa.String(length=255), nullable=False),
sa.Column("source_fingerprint", sa.String(length=64), nullable=False),
sa.Column("purpose", sa.String(length=120), nullable=True),
sa.Column("effective_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("request_payload", sa.JSON(), nullable=False),
sa.Column("resolution_payload", sa.JSON(), nullable=False),
sa.Column("recipient_count", sa.Integer(), nullable=False),
sa.Column("excluded_count", sa.Integer(), nullable=False),
sa.Column("snapshot_hash", sa.String(length=64), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_point_snapshots_tenant_id", ["tenant_id"]),
("ix_addresses_contact_point_snapshots_source_id", ["source_id"]),
("ix_addresses_contact_point_snapshots_purpose", ["purpose"]),
("ix_addresses_contact_point_snapshots_effective_at", ["effective_at"]),
("ix_addresses_contact_point_snapshots_generated_at", ["generated_at"]),
("ix_addresses_contact_point_snapshots_snapshot_hash", ["snapshot_hash"]),
("ix_addresses_contact_point_snapshots_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_contact_point_snapshots_source", ["tenant_id", "source_id", "created_at"]),
("ix_addresses_contact_point_snapshots_hash", ["tenant_id", "snapshot_hash"]),
):
op.create_index(name, "addresses_contact_point_snapshots", columns, unique=False)
def downgrade() -> None:
op.drop_table("addresses_contact_point_snapshots")
op.drop_index("ix_addresses_contacts_source_ref", table_name="addresses_contacts")
@@ -0,0 +1,281 @@
"""Add address quality, provenance, merge evidence, and redirects.
Revision ID: b4c6d7e8f9a0
Revises: a3b5c6d7e8f9
"""
from __future__ import annotations
import re
from alembic import op
import sqlalchemy as sa
revision = "b4c6d7e8f9a0"
down_revision = "a3b5c6d7e8f9"
branch_labels = None
depends_on = None
_JSON_OBJECT = sa.text("'{}'")
def upgrade() -> None:
with op.batch_alter_table("addresses_contact_emails") as batch:
batch.add_column(sa.Column("original_email", sa.String(length=320), nullable=False, server_default=""))
batch.add_column(sa.Column("normalized_email", sa.String(length=320), nullable=False, server_default=""))
batch.add_column(sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
with op.batch_alter_table("addresses_contact_phones") as batch:
batch.add_column(sa.Column("original_phone", sa.String(length=100), nullable=False, server_default=""))
batch.add_column(sa.Column("normalized_phone", sa.String(length=100), nullable=False, server_default=""))
batch.add_column(sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
with op.batch_alter_table("addresses_contact_postal_addresses") as batch:
batch.add_column(sa.Column("original_value", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
batch.add_column(sa.Column("normalized_value", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
batch.add_column(sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT))
bind = op.get_bind()
bind.execute(
sa.text(
"UPDATE addresses_contact_emails "
"SET original_email = email, normalized_email = lower(trim(email))"
)
)
phone_rows = bind.execute(
sa.text("SELECT id, phone FROM addresses_contact_phones")
).mappings().all()
for row in phone_rows:
bind.execute(
sa.text(
"UPDATE addresses_contact_phones "
"SET original_phone = :original, normalized_phone = :normalized "
"WHERE id = :id"
),
{
"id": row["id"],
"original": row["phone"],
"normalized": _normalized_phone(str(row["phone"] or "")),
},
)
postal = sa.table(
"addresses_contact_postal_addresses",
sa.column("id", sa.String()),
sa.column("label", sa.String()),
sa.column("street", sa.String()),
sa.column("postal_code", sa.String()),
sa.column("locality", sa.String()),
sa.column("region", sa.String()),
sa.column("country", sa.String()),
sa.column("original_value", sa.JSON()),
sa.column("normalized_value", sa.JSON()),
)
postal_rows = bind.execute(
sa.select(
postal.c.id,
postal.c.label,
postal.c.street,
postal.c.postal_code,
postal.c.locality,
postal.c.region,
postal.c.country,
)
).mappings().all()
for row in postal_rows:
original = {
key: row[key]
for key in ("label", "street", "postal_code", "locality", "region", "country")
}
normalized = {
key: _normalized_text(row[key])
for key in ("label", "street", "postal_code", "locality", "region", "country")
}
bind.execute(
postal.update()
.where(postal.c.id == row["id"])
.values(original_value=original, normalized_value=normalized)
)
op.create_index(
"ix_addresses_contact_emails_normalized_email",
"addresses_contact_emails",
["normalized_email"],
)
op.create_index(
"ix_addresses_contact_phones_normalized_phone",
"addresses_contact_phones",
["normalized_phone"],
)
op.create_table(
"addresses_contact_point_quality_decisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("contact_id", sa.String(length=36), nullable=False),
sa.Column("channel", sa.String(length=30), nullable=False),
sa.Column("contact_point_id", sa.String(length=36), nullable=True),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("reason_code", sa.String(length=120), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("evidence_ref", sa.String(length=1000), nullable=True),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("effective_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
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"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_point_quality_decisions_tenant_id", ["tenant_id"]),
("ix_addresses_contact_point_quality_decisions_contact_id", ["contact_id"]),
("ix_addresses_contact_point_quality_decisions_channel", ["channel"]),
("ix_addresses_contact_point_quality_decisions_contact_point_id", ["contact_point_id"]),
("ix_addresses_contact_point_quality_decisions_state", ["state"]),
("ix_addresses_contact_point_quality_decisions_effective_from", ["effective_from"]),
("ix_addresses_contact_point_quality_decisions_effective_until", ["effective_until"]),
("ix_addresses_quality_created_by", ["created_by_account_id"]),
("ix_addresses_quality_current", ["tenant_id", "contact_id", "channel", "contact_point_id", "effective_until"]),
("ix_addresses_quality_state", ["tenant_id", "state", "effective_until"]),
):
op.create_index(name, "addresses_contact_point_quality_decisions", columns)
op.create_table(
"addresses_contact_merge_records",
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("winner_contact_id", sa.String(length=36), nullable=False),
sa.Column("loser_contact_ids", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("survivorship", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
sa.Column("decisions", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("before_payload", sa.JSON(), nullable=False),
sa.Column("after_payload", sa.JSON(), nullable=False),
sa.Column("before_hash", sa.String(length=64), nullable=False),
sa.Column("after_hash", sa.String(length=64), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("recovered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("recovered_by_account_id", sa.String(length=36), nullable=True),
sa.Column("recovery_action", sa.String(length=30), nullable=True),
sa.Column("recovery_reason", sa.Text(), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
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"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["winner_contact_id"], ["addresses_contacts.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_merge_records_tenant_id", ["tenant_id"]),
("ix_addresses_contact_merge_records_address_book_id", ["address_book_id"]),
("ix_addresses_contact_merge_records_winner_contact_id", ["winner_contact_id"]),
("ix_addresses_contact_merge_records_status", ["status"]),
("ix_addresses_contact_merge_records_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_merge_winner", ["tenant_id", "winner_contact_id", "created_at"]),
("ix_addresses_merge_status", ["tenant_id", "status", "created_at"]),
):
op.create_index(name, "addresses_contact_merge_records", columns)
op.create_table(
"addresses_contact_redirects",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("source_contact_id", sa.String(length=36), nullable=False),
sa.Column("target_contact_id", sa.String(length=36), nullable=False),
sa.Column("merge_record_id", sa.String(length=36), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["merge_record_id"], ["addresses_contact_merge_records.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["source_contact_id"], ["addresses_contacts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["target_contact_id"], ["addresses_contacts.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_redirects_tenant_id", ["tenant_id"]),
("ix_addresses_contact_redirects_source_contact_id", ["source_contact_id"]),
("ix_addresses_contact_redirects_target_contact_id", ["target_contact_id"]),
("ix_addresses_contact_redirects_merge_record_id", ["merge_record_id"]),
("ix_addresses_contact_redirects_ended_at", ["ended_at"]),
("ix_addresses_contact_redirects_target", ["tenant_id", "target_contact_id", "ended_at"]),
):
op.create_index(name, "addresses_contact_redirects", columns)
op.create_index(
"uq_addresses_contact_redirects_active_source",
"addresses_contact_redirects",
["tenant_id", "source_contact_id"],
unique=True,
sqlite_where=sa.text("ended_at IS NULL"),
postgresql_where=sa.text("ended_at IS NULL"),
)
op.create_table(
"addresses_contact_field_provenance",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("contact_id", sa.String(length=36), nullable=False),
sa.Column("field_path", sa.String(length=255), nullable=False),
sa.Column("value", sa.JSON(), nullable=True),
sa.Column("source_kind", sa.String(length=40), nullable=False),
sa.Column("source_ref", sa.String(length=1000), nullable=True),
sa.Column("source_revision", sa.String(length=255), nullable=True),
sa.Column("precedence", sa.Integer(), nullable=False),
sa.Column("selected", sa.Boolean(), nullable=False),
sa.Column("reason_code", sa.String(length=120), nullable=False),
sa.Column("explanation", sa.Text(), nullable=True),
sa.Column("visibility", sa.String(length=30), nullable=False),
sa.Column("merge_record_id", sa.String(length=36), nullable=True),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False, server_default=_JSON_OBJECT),
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"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["merge_record_id"], ["addresses_contact_merge_records.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_field_provenance_tenant_id", ["tenant_id"]),
("ix_addresses_contact_field_provenance_contact_id", ["contact_id"]),
("ix_addresses_contact_field_provenance_field_path", ["field_path"]),
("ix_addresses_contact_field_provenance_selected", ["selected"]),
("ix_addresses_contact_field_provenance_merge_record_id", ["merge_record_id"]),
("ix_addresses_contact_field_provenance_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_field_provenance_contact", ["contact_id", "field_path", "created_at"]),
("ix_addresses_field_provenance_selected", ["tenant_id", "contact_id", "selected"]),
):
op.create_index(name, "addresses_contact_field_provenance", columns)
def downgrade() -> None:
op.drop_table("addresses_contact_field_provenance")
op.drop_table("addresses_contact_redirects")
op.drop_table("addresses_contact_merge_records")
op.drop_table("addresses_contact_point_quality_decisions")
with op.batch_alter_table("addresses_contact_postal_addresses") as batch:
batch.drop_column("provenance")
batch.drop_column("normalized_value")
batch.drop_column("original_value")
with op.batch_alter_table("addresses_contact_phones") as batch:
batch.drop_index("ix_addresses_contact_phones_normalized_phone")
batch.drop_column("provenance")
batch.drop_column("normalized_phone")
batch.drop_column("original_phone")
with op.batch_alter_table("addresses_contact_emails") as batch:
batch.drop_index("ix_addresses_contact_emails_normalized_email")
batch.drop_column("provenance")
batch.drop_column("normalized_email")
batch.drop_column("original_email")
def _normalized_text(value: object) -> str | None:
if value is None:
return None
normalized = " ".join(str(value).strip().casefold().split())
return normalized or None
def _normalized_phone(value: str) -> str:
prefix = "+" if value.strip().startswith("+") else ""
return prefix + re.sub(r"\D", "", value)
@@ -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,95 @@
"""add versioned address import profiles and immutable run evidence
Revision ID: c5d7e8f9a0b1
Revises: b4c6d7e8f9a0
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c5d7e8f9a0b1"
down_revision = "b4c6d7e8f9a0"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"addresses_import_profiles",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("profile_key", sa.String(length=36), nullable=False),
sa.Column("version", sa.Integer(), 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_format", sa.String(length=20), nullable=False),
sa.Column("configuration", sa.JSON(), nullable=False),
sa.Column("is_current", sa.Boolean(), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("superseded_at", sa.DateTime(timezone=True), 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"),
sa.UniqueConstraint("profile_key", "version", name="uq_addresses_import_profile_version"),
)
op.create_index("ix_addresses_import_profiles_profile_key", "addresses_import_profiles", ["profile_key"])
op.create_index("ix_addresses_import_profiles_tenant_id", "addresses_import_profiles", ["tenant_id"])
op.create_index("ix_addresses_import_profiles_scope_type", "addresses_import_profiles", ["scope_type"])
op.create_index("ix_addresses_import_profiles_scope_id", "addresses_import_profiles", ["scope_id"])
op.create_index("ix_addresses_import_profiles_source_format", "addresses_import_profiles", ["source_format"])
op.create_index("ix_addresses_import_profiles_is_current", "addresses_import_profiles", ["is_current"])
op.create_index("ix_addresses_import_profiles_created_by_account_id", "addresses_import_profiles", ["created_by_account_id"])
op.create_index("ix_addresses_import_profiles_superseded_at", "addresses_import_profiles", ["superseded_at"])
op.create_index("ix_addresses_import_profiles_scope", "addresses_import_profiles", ["tenant_id", "scope_type", "scope_id", "is_current"])
op.create_index("ix_addresses_import_profiles_format", "addresses_import_profiles", ["tenant_id", "source_format"])
op.create_table(
"addresses_import_runs",
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("profile_id", sa.String(length=36), nullable=False),
sa.Column("source_filename", sa.String(length=500), nullable=False),
sa.Column("source_format", sa.String(length=20), nullable=False),
sa.Column("input_hash", sa.String(length=64), nullable=False),
sa.Column("plan_hash", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("row_count", sa.Integer(), nullable=False),
sa.Column("statistics", sa.JSON(), nullable=False),
sa.Column("diagnostics", sa.JSON(), nullable=False),
sa.Column("plan_data", sa.JSON(), nullable=False),
sa.Column("result_evidence", sa.JSON(), nullable=False),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("rolled_back_at", sa.DateTime(timezone=True), 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"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["profile_id"], ["addresses_import_profiles.id"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("id"),
)
for column in (
"tenant_id",
"address_book_id",
"profile_id",
"source_format",
"input_hash",
"plan_hash",
"status",
"created_by_account_id",
"applied_at",
"rolled_back_at",
):
op.create_index(f"ix_addresses_import_runs_{column}", "addresses_import_runs", [column])
op.create_index("ix_addresses_import_runs_book_status", "addresses_import_runs", ["address_book_id", "status", "created_at"])
op.create_index("ix_addresses_import_runs_tenant_hash", "addresses_import_runs", ["tenant_id", "input_hash"])
def downgrade() -> None:
op.drop_table("addresses_import_runs")
op.drop_table("addresses_import_profiles")
@@ -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")
@@ -0,0 +1,58 @@
"""Add effective-dated contact channel governance.
Revision ID: f2a4b5c6d7e
Revises: e1f2a4b5c6d
"""
from alembic import op
import sqlalchemy as sa
revision = "f2a4b5c6d7e"
down_revision = "e1f2a4b5c6d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"addresses_contact_channel_rules",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("contact_id", sa.String(length=36), nullable=False),
sa.Column("channel", sa.String(length=30), nullable=False),
sa.Column("purpose", sa.String(length=120), nullable=True),
sa.Column("contact_point_id", sa.String(length=36), nullable=True),
sa.Column("decision", sa.String(length=40), nullable=False),
sa.Column("legal_basis", sa.String(length=255), nullable=True),
sa.Column("evidence_ref", sa.String(length=1000), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("preference_rank", sa.Integer(), nullable=True),
sa.Column("locale", sa.String(length=20), nullable=True),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("effective_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
sa.Column("metadata", sa.JSON(), 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"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_addresses_contact_channel_rules_tenant_id", ["tenant_id"]),
("ix_addresses_contact_channel_rules_contact_id", ["contact_id"]),
("ix_addresses_contact_channel_rules_channel", ["channel"]),
("ix_addresses_contact_channel_rules_purpose", ["purpose"]),
("ix_addresses_contact_channel_rules_contact_point_id", ["contact_point_id"]),
("ix_addresses_contact_channel_rules_decision", ["decision"]),
("ix_addresses_contact_channel_rules_effective_from", ["effective_from"]),
("ix_addresses_contact_channel_rules_effective_until", ["effective_until"]),
("ix_addresses_contact_channel_rules_created_by_account_id", ["created_by_account_id"]),
("ix_addresses_channel_rules_resolution", ["tenant_id", "contact_id", "channel", "purpose"]),
("ix_addresses_channel_rules_effective", ["effective_from", "effective_until"]),
):
op.create_index(name, "addresses_contact_channel_rules", columns, unique=False)
def downgrade() -> None:
op.drop_table("addresses_contact_channel_rules")
@@ -0,0 +1,206 @@
from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_addresses.backend.db.models import (
AddressSyncConflict,
AddressSyncDiagnostic,
AddressSyncSource,
)
from govoplan_core.core.provider_governance import (
ExternalProviderRuntimeState,
ExternalProviderStateContext,
)
CARDDAV_PROVIDER_ID = "addresses.carddav_sync"
LDAP_PROVIDER_ID = "addresses.ldap_directory"
_CURRENT_WINDOW = timedelta(hours=24)
def carddav_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
if not isinstance(context.session, Session):
raise RuntimeError("Addresses provider state requires a database session.")
return _provider_states(
context,
connector_types=("carddav",),
provider_id=CARDDAV_PROVIDER_ID,
label="CardDAV",
)
def ldap_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _provider_states(
context,
connector_types=("ldap", "active_directory"),
provider_id=LDAP_PROVIDER_ID,
label="LDAP/Active Directory",
)
def _provider_states(
context: ExternalProviderStateContext,
*,
connector_types: tuple[str, ...],
provider_id: str,
label: str,
) -> tuple[ExternalProviderRuntimeState, ...]:
if not isinstance(context.session, Session):
raise RuntimeError("Addresses provider state requires a database session.")
statement = select(AddressSyncSource).where(
AddressSyncSource.connector_type.in_(connector_types)
)
if context.tenant_id is not None:
statement = statement.where(AddressSyncSource.tenant_id == context.tenant_id)
sources = tuple(
context.session.scalars(
statement.order_by(AddressSyncSource.tenant_id, AddressSyncSource.id).limit(
context.max_items + 1
)
)
)
if not sources:
return ()
source_ids = tuple(item.id for item in sources)
conflict_counts = _grouped_counts(
context.session,
AddressSyncConflict.sync_source_id,
AddressSyncConflict.status == "open",
source_ids,
)
error_counts = _grouped_counts(
context.session,
AddressSyncDiagnostic.sync_source_id,
AddressSyncDiagnostic.severity == "error",
source_ids,
)
observed_at = datetime.now(UTC)
return tuple(
_source_state(
source,
provider_id=provider_id,
label=label,
observed_at=observed_at,
conflict_count=conflict_counts.get(source.id, 0),
error_count=error_counts.get(source.id, 0),
)
for source in sources
)
def _grouped_counts(
session: Session,
source_column: object,
predicate: object,
source_ids: tuple[str, ...],
) -> dict[str, int]:
counts: dict[str, int] = defaultdict(int)
rows = session.execute(
select(source_column, func.count()).where(
source_column.in_(source_ids), predicate
).group_by(source_column)
)
for source_id, count in rows:
counts[str(source_id)] = int(count)
return counts
def _source_state(
source: AddressSyncSource,
*,
provider_id: str,
label: str,
observed_at: datetime,
conflict_count: int,
error_count: int,
) -> ExternalProviderRuntimeState:
active = bool(source.enabled)
status = str(source.status or "idle")
health = (
"inactive"
if not active
else "error"
if status == "failed" or bool(source.last_error)
else "warning"
if status in {"conflict", "running"} or conflict_count or error_count
else "healthy"
if status == "succeeded"
else "unknown"
)
freshness = _freshness(source, observed_at=observed_at)
conflict = "pending" if conflict_count or status == "conflict" else "clear"
recovery = (
"not_applicable"
if not active
else "attention"
if health in {"error", "warning"} or conflict == "pending"
else "ready"
)
return ExternalProviderRuntimeState(
provider_id=provider_id,
binding_ref=f"addresses:sync-source:{source.id}",
authority_mode=(
"external_authoritative"
if provider_id == LDAP_PROVIDER_ID
else "external_mirror"
if source.read_only
else "governed_sync"
),
observed_at=observed_at,
configured=True,
active=active,
health=health,
freshness=freshness,
conflict=conflict,
recovery=recovery,
last_success_at=_aware(source.last_success_at),
detail=(
f"{label} source is disabled."
if not active
else f"{label} source requires reconciliation."
if conflict == "pending"
else f"{label} source health has not been observed yet."
if health == "unknown"
else f"{label} source state is available."
),
metrics={
"open_conflicts": conflict_count,
"error_diagnostics": error_count,
"read_only": bool(source.read_only),
"status": status,
},
)
def _freshness(source: AddressSyncSource, *, observed_at: datetime) -> str:
if not source.enabled:
return "not_applicable"
last_success = _aware(source.last_success_at)
if last_success is None:
return "unknown"
return "current" if observed_at - last_success <= _CURRENT_WINDOW else "stale"
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
__all__ = [
"CARDDAV_PROVIDER_ID",
"LDAP_PROVIDER_ID",
"carddav_provider_states",
"ldap_provider_states",
]
File diff suppressed because it is too large Load Diff
+906
View File
@@ -0,0 +1,906 @@
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"]
AddressDistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
AddressContactPointChannel = Literal[
"email",
"phone",
"postal",
"internal_mail",
"portal",
]
AddressChannelDecision = Literal[
"allowed",
"opted_in",
"preferred",
"opted_out",
"suppressed",
"invalid",
"returned",
"temporarily_unavailable",
]
AddressDistributionOutcome = Literal[
"usable",
"unresolved",
"invalid",
"suppressed",
"ambiguous",
"duplicate",
"policy_blocked",
"provider_unavailable",
"stale",
]
AddressContactPointFallbackRule = Literal["none", "primary", "any"]
AddressPostalFormat = Literal["domestic", "international"]
ContactPointQualityState = Literal[
"valid",
"invalid",
"returned",
"stale",
"undeliverable",
]
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 ContactFieldProvenanceResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
contact_id: str
field_path: str
value: Any = None
source_kind: str
source_ref: str | None = None
source_revision: str | None = None
precedence: int
selected: bool
reason_code: str
explanation: str | None = None
visibility: str
merge_record_id: str | None = None
created_by_account_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
created_at: datetime
class ContactPointQualityDecisionCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
channel: AddressContactPointChannel
contact_point_id: str | None = Field(default=None, max_length=36)
state: ContactPointQualityState
reason_code: str | None = Field(default=None, max_length=120)
reason: str | None = None
evidence_ref: str | None = Field(default=None, max_length=1000)
effective_from: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ContactPointQualityDecisionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
contact_id: str
channel: AddressContactPointChannel
contact_point_id: str | None = None
state: ContactPointQualityState
reason_code: str
reason: str | None = None
evidence_ref: str | None = None
effective_from: datetime
effective_until: datetime | None = None
created_by_account_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
created_at: datetime
updated_at: datetime
class ContactPointQualityDecisionListResponse(BaseModel):
decisions: list[ContactPointQualityDecisionResponse] = Field(default_factory=list)
class ContactEmailResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
label: str | None = None
email: str
original_email: str = ""
normalized_email: str = ""
provenance: dict[str, Any] = Field(default_factory=dict)
quality_state: ContactPointQualityState = "valid"
quality_reason_code: str | None = None
is_primary: bool
class ContactPhoneResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
label: str | None = None
phone: str
original_phone: str = ""
normalized_phone: str = ""
provenance: dict[str, Any] = Field(default_factory=dict)
quality_state: ContactPointQualityState = "valid"
quality_reason_code: str | None = None
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
original_value: dict[str, Any] = Field(default_factory=dict)
normalized_value: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
quality_state: ContactPointQualityState = "valid"
quality_reason_code: 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]
field_provenance: list[ContactFieldProvenanceResponse] = Field(default_factory=list)
deleted_at: datetime | None = None
created_at: datetime
updated_at: datetime
class ContactListResponse(BaseModel):
contacts: list[ContactResponse]
total: int
offset: int
limit: int
has_more: bool
class ContactDuplicateFeatureResponse(BaseModel):
code: str
label: str
weight: int
value: str
class ContactDuplicateSuggestionResponse(BaseModel):
left: ContactResponse
right: ContactResponse
score: int
confidence: Literal["possible", "likely", "strong"]
features: list[ContactDuplicateFeatureResponse]
class ContactDuplicateSuggestionListResponse(BaseModel):
suggestions: list[ContactDuplicateSuggestionResponse] = Field(default_factory=list)
scanned_contacts: int
candidate_pairs: int
truncated: bool
class ContactMergeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
winner_contact_id: str = Field(max_length=36)
duplicate_contact_ids: list[str] = Field(min_length=1, max_length=20)
reason: str = Field(min_length=3)
field_sources: dict[str, str] = Field(default_factory=dict)
contact_point_strategy: Literal["union", "winner_only"] = "union"
source_precedence: list[str] = Field(default_factory=list, max_length=20)
class ContactMergeRecoveryRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
reason: str = Field(min_length=3)
expected_after_hash: str = Field(min_length=64, max_length=64)
class ContactMergeRecordResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
address_book_id: str
winner_contact_id: str
loser_contact_ids: list[str]
status: str
reason: str
survivorship: dict[str, Any]
decisions: list[dict[str, Any]]
before_hash: str
after_hash: str
created_by_account_id: str | None = None
recovered_at: datetime | None = None
recovered_by_account_id: str | None = None
recovery_action: str | None = None
recovery_reason: str | None = None
provenance: dict[str, Any]
created_at: datetime
updated_at: datetime
class ContactMergeRecordListResponse(BaseModel):
merges: list[ContactMergeRecordResponse] = Field(default_factory=list)
class ContactRedirectResponse(BaseModel):
requested_contact_id: str
resolved_contact_id: str
redirected: bool
redirect_chain: list[str] = Field(default_factory=list)
merge_record_ids: list[str] = Field(default_factory=list)
class AddressQualityCorrectionResponse(BaseModel):
contact_id: str
display_name: str
channel: AddressContactPointChannel
contact_point_id: str | None = None
state: ContactPointQualityState
reason_code: str
reason: str | None = None
effective_from: datetime
class AddressQualitySummaryResponse(BaseModel):
contact_count: int
contact_point_count: int
quality_counts: dict[str, int] = Field(default_factory=dict)
duplicate_suggestion_count: int
correction_count: int
corrections: list[AddressQualityCorrectionResponse] = Field(default_factory=list)
truncated: bool = False
class ContactChannelRuleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
channel: AddressDistributionChannel
purpose: str | None = Field(default=None, max_length=120)
contact_point_id: str | None = Field(default=None, max_length=36)
decision: AddressChannelDecision
legal_basis: str | None = Field(default=None, max_length=255)
evidence_ref: str | None = Field(default=None, max_length=1000)
reason: str | None = None
preference_rank: int | None = Field(default=None, ge=0, le=10000)
locale: str | None = Field(default=None, max_length=20)
effective_from: datetime | None = None
effective_until: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ContactChannelRuleResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
contact_id: str
channel: AddressDistributionChannel
purpose: str | None = None
contact_point_id: str | None = None
decision: AddressChannelDecision
legal_basis: str | None = None
evidence_ref: str | None = None
reason: str | None = None
preference_rank: int | None = None
locale: str | None = None
effective_from: datetime | None = None
effective_until: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
created_by_account_id: str | None = None
created_at: datetime
updated_at: datetime
class ContactChannelRuleListResponse(BaseModel):
rules: list[ContactChannelRuleResponse] = Field(default_factory=list)
class AddressSourceReferencePayload(BaseModel):
provider: str = Field(min_length=1, max_length=120)
resource_type: str = Field(min_length=1, max_length=120)
resource_id: str = Field(min_length=1, max_length=1000)
revision: str | None = Field(default=None, max_length=1000)
fingerprint: str | None = Field(default=None, max_length=255)
label: str | None = Field(default=None, max_length=500)
metadata: dict[str, Any] = Field(default_factory=dict)
class ContactPointResolveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
subject: AddressSourceReferencePayload
effective_at: datetime
purpose: str | None = Field(default=None, max_length=120)
requested_channels: list[AddressDistributionChannel] = Field(default_factory=list)
address_purpose: str | None = Field(default=None, max_length=80)
fallback_rule: AddressContactPointFallbackRule = "primary"
locale: str | None = Field(default=None, max_length=20)
postal_format: AddressPostalFormat = "domestic"
context: dict[str, Any] = Field(default_factory=dict)
class ContactPointSourceRequestPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
source_id: str = Field(min_length=1, max_length=1000)
effective_at: datetime
purpose: str | None = Field(default=None, max_length=120)
requested_channels: list[AddressDistributionChannel] = Field(default_factory=list)
address_purpose: str | None = Field(default=None, max_length=80)
fallback_rule: AddressContactPointFallbackRule = "primary"
locale: str | None = Field(default=None, max_length=20)
postal_format: AddressPostalFormat = "domestic"
max_items: int = Field(default=5000, ge=1, le=20000)
context: dict[str, Any] = Field(default_factory=dict)
class ContactPointSourceRequestResponse(ContactPointSourceRequestPayload):
tenant_id: str
class ContactPointCandidateResponse(BaseModel):
channel: AddressDistributionChannel
target: str
target_key: str
status: AddressDistributionOutcome
contact_point_id: str | None = None
address_purpose: str | None = None
locale: str | None = None
preferred: bool = False
preference_rank: int | None = None
reason_code: str | None = None
explanation: str | None = None
source: AddressSourceReferencePayload | None = None
source_revision: str | None = None
preference_revision: str | None = None
consent_revision: str | None = None
value: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
class DistributionExplanationResponse(BaseModel):
code: str
message: str
severity: Literal["info", "warning", "error"]
provider: str | None = None
source: AddressSourceReferencePayload | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class ContactPointResolutionResponse(BaseModel):
contract_version: str
subject: AddressSourceReferencePayload
status: AddressDistributionOutcome
contact_id: str | None = None
display_name: str | None = None
candidates: list[ContactPointCandidateResponse] = Field(default_factory=list)
excluded: list[ContactPointCandidateResponse] = Field(default_factory=list)
explanations: list[DistributionExplanationResponse] = Field(default_factory=list)
source_revision: str | None = None
source_fingerprint: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class ContactPointSourcePreviewResponse(BaseModel):
contract_version: str
source: AddressSourceReferencePayload
request: ContactPointSourceRequestResponse
resolutions: list[ContactPointResolutionResponse]
total_count: int
usable_count: int
excluded_count: int
offset: int
limit: int
has_more: bool
source_revision: str
source_fingerprint: str
generated_at: datetime
provenance: dict[str, Any] = Field(default_factory=dict)
class ContactPointSnapshotResponse(BaseModel):
id: str
tenant_id: str
contract_version: str
source: AddressSourceReferencePayload
request: ContactPointSourceRequestResponse
resolutions: list[ContactPointResolutionResponse]
recipient_count: int
excluded_count: int
source_revision: str
source_fingerprint: str
snapshot_hash: str
generated_at: datetime
provenance: dict[str, Any] = Field(default_factory=dict)
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 AddressCredentialEnvelopeResponse(BaseModel):
id: str
scope_type: str
scope_id: str | None = None
name: str
description: str | None = None
credential_kind: str
public_data: dict[str, Any] = Field(default_factory=dict)
secret_keys: list[str] = Field(default_factory=list)
secret_configured: bool = False
allowed_modules: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
revision: str
class AddressCredentialEnvelopeListResponse(BaseModel):
credentials: list[AddressCredentialEnvelopeResponse] = Field(default_factory=list)
class AddressCardDavSourceCreateRequest(BaseModel):
collection_url: str = Field(min_length=1, max_length=2000)
display_name: str | None = Field(default=None, max_length=255)
auth_type: AddressCardDavAuthType = "none"
username: str | None = Field(default=None, max_length=320)
password: SecretStr | None = None
bearer_token: SecretStr | None = None
credential_ref: str | None = Field(default=None, max_length=1000)
sync_direction: AddressSyncDirection = "read_only"
read_only: bool | None = None
sync_token: str | None = None
etag: str | None = Field(default=None, max_length=1000)
remote_revision: str | None = Field(default=None, max_length=1000)
class AddressSyncRunRequest(BaseModel):
force_full: bool = False
password: SecretStr | None = None
bearer_token: SecretStr | None = None
class AddressSyncPlanStats(BaseModel):
created: int = 0
updated: int = 0
deleted: int = 0
conflicts: int = 0
unchanged: int = 0
errors: int = 0
fetched: int = 0
full_sync: bool = False
used_sync_token: bool = False
sync_token: str | None = None
etag: str | None = None
remote_revision: str | None = None
class AddressSyncPlanItemResponse(BaseModel):
action: AddressSyncPlanAction
href: str | None = None
remote_uid: str | None = None
contact_id: str | None = None
display_name: str | None = None
etag: str | None = None
message: str | None = None
class AddressSyncPlanResponse(BaseModel):
sync_source: AddressSyncSourceResponse
stats: AddressSyncPlanStats
items: list[AddressSyncPlanItemResponse]
class VCardImportRequest(BaseModel):
content: str = Field(min_length=1)
class VCardImportIssue(BaseModel):
index: int
message: str
severity: Literal["warning", "error"] = "error"
field: str | None = None
line: int | None = None
class VCardImportResponse(BaseModel):
imported: int
skipped: int
contacts: list[ContactResponse]
issues: list[VCardImportIssue] = Field(default_factory=list)
File diff suppressed because it is too large Load Diff
+500
View File
@@ -0,0 +1,500 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
from govoplan_addresses.backend.db.models import Contact
from govoplan_addresses.backend.schemas import (
ContactCreateRequest,
ContactEmailPayload,
ContactPhonePayload,
ContactPostalAddressPayload,
)
class VCardError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ParsedVCard:
payload: ContactCreateRequest
raw: str
source_ref: str | None = None
source_revision: str | None = None
@dataclass(frozen=True, slots=True)
class ParsedVCardIssue:
index: int
message: str
severity: Literal["warning", "error"] = "error"
field: str | None = None
line: int | None = None
@dataclass(frozen=True, slots=True)
class VCardParseResult:
cards: list[ParsedVCard]
issues: list[ParsedVCardIssue]
skipped: int
@dataclass(slots=True)
class _VCardDraft:
fn: str | None = None
given_name: str | None = None
family_name: str | None = None
organization: str | None = None
role_title: str | None = None
note: str | None = None
version: str | None = None
uid: str | None = None
revision: str | None = None
tags: list[str] = field(default_factory=list)
emails: list[ContactEmailPayload] = field(default_factory=list)
phones: list[ContactPhonePayload] = field(default_factory=list)
addresses: list[ContactPostalAddressPayload] = field(default_factory=list)
urls: list[str] = field(default_factory=list)
def _normalize_lines(content: str) -> list[str]:
raw_lines = content.replace("\r\n", "\n").replace("\r", "\n").split("\n")
lines: list[str] = []
for line in raw_lines:
if line.startswith((" ", "\t")) and lines:
lines[-1] += line[1:]
elif line:
lines.append(line)
return lines
def _split_unescaped(value: str, separator: str) -> list[str]:
parts: list[str] = []
current: list[str] = []
escaped = False
for char in value:
if escaped:
current.append(char)
escaped = False
elif char == "\\":
current.append(char)
escaped = True
elif char == separator:
parts.append("".join(current))
current = []
else:
current.append(char)
parts.append("".join(current))
return parts
def _unescape_text(value: str) -> str:
return (
value.replace("\\n", "\n")
.replace("\\N", "\n")
.replace("\\,", ",")
.replace("\\;", ";")
.replace("\\\\", "\\")
.strip()
)
def _escape_text(value: str | None) -> str:
if value is None:
return ""
return (
value.replace("\\", "\\\\")
.replace("\r\n", "\n")
.replace("\r", "\n")
.replace("\n", "\\n")
.replace(";", "\\;")
.replace(",", "\\,")
)
def _parse_head(head: str) -> tuple[str, dict[str, list[str]]]:
parts = head.split(";")
name = parts[0].split(".")[-1].upper()
params: dict[str, list[str]] = {}
for part in parts[1:]:
if not part:
continue
if "=" in part:
key, raw_value = part.split("=", 1)
values = [item.strip().strip('"') for item in raw_value.split(",") if item.strip()]
else:
key = "TYPE"
values = [part.strip().strip('"')]
params.setdefault(key.upper(), []).extend(values)
return name, params
def _label_from_params(params: dict[str, list[str]]) -> str | None:
ignored = {"INTERNET", "VOICE", "PREF"}
for value in params.get("TYPE", []):
normalized = value.strip().lower()
if normalized and normalized.upper() not in ignored:
return normalized
return None
def _line_value(line: str, *, card_index: int) -> tuple[str, dict[str, list[str]], str]:
if ":" not in line:
raise VCardError(f"vCard {card_index}: line is missing ':' separator.")
head, value = line.split(":", 1)
name, params = _parse_head(head)
return name, params, value
def _is_pref(params: dict[str, list[str]]) -> bool:
values = [value.strip().upper() for value in params.get("TYPE", [])]
values.extend(value.strip().upper() for value in params.get("PREF", []))
return "PREF" in values or "1" in values
def _card_blocks(content: str) -> list[list[str]]:
result = _card_blocks_with_issues(content)
if result.issues:
raise VCardError(result.issues[0].message)
return result.cards
@dataclass(frozen=True, slots=True)
class _CardBlockResult:
cards: list[list[str]]
issues: list[ParsedVCardIssue]
def _card_blocks_with_issues(content: str) -> _CardBlockResult:
lines = _normalize_lines(content)
blocks: list[list[str]] = []
issues: list[ParsedVCardIssue] = []
current: list[str] | None = None
for line in lines:
card_index = len(blocks) + 1
try:
name, _params, value = _line_value(line, card_index=card_index)
except VCardError as exc:
issues.append(ParsedVCardIssue(index=card_index, message=str(exc), field="line"))
continue
if name == "BEGIN" and line.split(":", 1)[1].upper() == "VCARD":
if current is not None:
issues.append(ParsedVCardIssue(index=card_index, message="Nested vCard BEGIN is not supported."))
current = None
continue
current = [line]
elif name == "END" and value.upper() == "VCARD":
if current is None:
issues.append(ParsedVCardIssue(index=card_index, message="vCard END appears before BEGIN."))
continue
current.append(line)
blocks.append(current)
current = None
elif current is not None:
current.append(line)
if current is not None:
issues.append(ParsedVCardIssue(index=len(blocks) + 1, message="vCard BEGIN has no matching END."))
if not blocks:
issues.append(ParsedVCardIssue(index=0, message="No vCard entries found."))
return _CardBlockResult(cards=blocks, issues=issues)
def _parse_card(index: int, block: list[str]) -> tuple[ParsedVCard | None, list[ParsedVCardIssue]]:
draft = _VCardDraft()
issues: list[ParsedVCardIssue] = []
for line in block:
parsed = _parse_card_line(index, line, issues)
if parsed is not None:
name, params, value = parsed
_apply_card_property(index, draft, name, params, value, issues)
if not _draft_has_identity(draft):
issues.append(ParsedVCardIssue(index=index, message=f"vCard {index}: contact has no name or email."))
return None, issues
raw = "\n".join(block)
payload = _draft_contact_payload(draft)
return ParsedVCard(payload=payload, raw=raw, source_ref=draft.uid, source_revision=draft.revision), issues
def _parse_card_line(
index: int,
line: str,
issues: list[ParsedVCardIssue],
) -> tuple[str, dict[str, list[str]], str] | None:
try:
return _line_value(line, card_index=index)
except VCardError as exc:
issues.append(ParsedVCardIssue(index=index, message=str(exc), field="line"))
return None
def _apply_card_property(
index: int,
draft: _VCardDraft,
name: str,
params: dict[str, list[str]],
value: str,
issues: list[ParsedVCardIssue],
) -> None:
if name in {"BEGIN", "END"}:
return
if _apply_card_metadata(index, draft, name, value, issues):
return
if _apply_card_identity(draft, name, value):
return
_apply_card_contact_detail(index, draft, name, params, value, issues)
def _apply_card_metadata(
index: int,
draft: _VCardDraft,
name: str,
value: str,
issues: list[ParsedVCardIssue],
) -> bool:
if name == "VERSION":
draft.version = value.strip()
if draft.version and draft.version not in {"3.0", "4.0"}:
issues.append(ParsedVCardIssue(index=index, severity="warning", field="VERSION", message=f"vCard version {draft.version} is not fully supported."))
return True
if name == "UID":
draft.uid = _unescape_text(value) or draft.uid
return True
if name == "REV":
draft.revision = _unescape_text(value) or draft.revision
return True
return False
def _apply_card_identity(draft: _VCardDraft, name: str, value: str) -> bool:
if name == "FN":
draft.fn = _unescape_text(value)
return True
if name == "N":
parts = [_unescape_text(item) for item in _split_unescaped(value, ";")]
draft.family_name = parts[0] if len(parts) > 0 and parts[0] else draft.family_name
draft.given_name = parts[1] if len(parts) > 1 and parts[1] else draft.given_name
return True
if name == "ORG":
organization_parts = _unescaped_nonempty_values(value, ";")
draft.organization = " / ".join(organization_parts) or draft.organization
return True
if name in {"TITLE", "ROLE"}:
draft.role_title = _unescape_text(value) or draft.role_title
return True
if name == "NOTE":
draft.note = _unescape_text(value) or draft.note
return True
if name == "CATEGORIES":
draft.tags.extend(_unescaped_nonempty_values(value, ","))
return True
return False
def _apply_card_contact_detail(
index: int,
draft: _VCardDraft,
name: str,
params: dict[str, list[str]],
value: str,
issues: list[ParsedVCardIssue],
) -> None:
if name == "EMAIL":
_append_card_email(index, draft, params, value, issues)
elif name == "TEL":
_append_card_phone(draft, params, value)
elif name == "ADR":
_append_card_address(draft, params, value)
elif name == "URL":
url = _unescape_text(value)
if url:
draft.urls.append(url)
def _append_card_email(
index: int,
draft: _VCardDraft,
params: dict[str, list[str]],
value: str,
issues: list[ParsedVCardIssue],
) -> None:
email = _unescape_text(value)
if not email:
return
if "@" not in email:
issues.append(ParsedVCardIssue(index=index, severity="warning", field="EMAIL", message=f"Skipped invalid email address: {email}"))
return
draft.emails.append(ContactEmailPayload(label=_label_from_params(params), email=email, is_primary=_is_pref(params) or not draft.emails))
def _append_card_phone(draft: _VCardDraft, params: dict[str, list[str]], value: str) -> None:
phone = _unescape_text(value)
if phone:
draft.phones.append(ContactPhonePayload(label=_label_from_params(params), phone=phone, is_primary=_is_pref(params) or not draft.phones))
def _append_card_address(draft: _VCardDraft, params: dict[str, list[str]], value: str) -> None:
parts = [_unescape_text(item) for item in _split_unescaped(value, ";")]
while len(parts) < 7:
parts.append("")
if not any(parts):
return
street = "\n".join(part for part in (parts[1], parts[2]) if part)
draft.addresses.append(
ContactPostalAddressPayload(
label=_label_from_params(params),
street=street or None,
locality=parts[3] or None,
region=parts[4] or None,
postal_code=parts[5] or None,
country=parts[6] or None,
is_primary=_is_pref(params) or not draft.addresses,
)
)
def _unescaped_nonempty_values(value: str, separator: str) -> list[str]:
return [text for text in (_unescape_text(item) for item in _split_unescaped(value, separator)) if text]
def _draft_has_identity(draft: _VCardDraft) -> bool:
return bool(draft.fn or draft.given_name or draft.family_name or draft.emails)
def _draft_contact_payload(draft: _VCardDraft) -> ContactCreateRequest:
payload = ContactCreateRequest(
display_name=draft.fn,
given_name=draft.given_name,
family_name=draft.family_name,
organization=draft.organization,
role_title=draft.role_title,
note=draft.note,
tags=draft.tags,
emails=draft.emails,
phones=draft.phones,
postal_addresses=draft.addresses,
provenance={
"vcard": {
"version": draft.version,
"uid": draft.uid,
"revision": draft.revision,
"urls": draft.urls,
}
},
)
return payload
def parse_vcards_with_issues(content: str) -> VCardParseResult:
blocks = _card_blocks_with_issues(content)
parsed: list[ParsedVCard] = []
issues = list(blocks.issues)
skipped = 0
for index, block in enumerate(blocks.cards, start=1):
card, card_issues = _parse_card(index, block)
issues.extend(card_issues)
if card is None:
skipped += 1
else:
parsed.append(card)
return VCardParseResult(cards=parsed, issues=issues, skipped=skipped)
def parse_vcards(content: str) -> list[ParsedVCard]:
result = parse_vcards_with_issues(content)
errors = [issue for issue in result.issues if issue.severity == "error"]
if errors:
raise VCardError(errors[0].message)
return result.cards
def contact_to_vcard(contact: Contact) -> str:
lines = _contact_identity_lines(contact)
lines.extend(_contact_email_lines(contact))
lines.extend(_contact_phone_lines(contact))
lines.extend(_contact_address_lines(contact))
lines.extend(_contact_note_and_tag_lines(contact))
lines.extend(_contact_url_lines(contact))
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
def _contact_identity_lines(contact: Contact) -> list[str]:
lines = [
"BEGIN:VCARD",
"VERSION:4.0",
f"FN:{_escape_text(contact.display_name)}",
f"N:{_escape_text(contact.family_name)};{_escape_text(contact.given_name)};;;",
]
if contact.source_ref:
lines.append(f"UID:{_escape_text(contact.source_ref)}")
if contact.source_revision:
lines.append(f"REV:{_escape_text(contact.source_revision)}")
if contact.organization:
lines.append(f"ORG:{_escape_text(contact.organization)}")
if contact.role_title:
lines.append(f"TITLE:{_escape_text(contact.role_title)}")
return lines
def _contact_email_lines(contact: Contact) -> list[str]:
lines: list[str] = []
for email in contact.emails:
label = f";TYPE={_escape_text(email.label)}" if email.label else ""
lines.append(f"EMAIL{label}:{_escape_text(email.email)}")
return lines
def _contact_phone_lines(contact: Contact) -> list[str]:
lines: list[str] = []
for phone in contact.phones:
label = f";TYPE={_escape_text(phone.label)}" if phone.label else ""
lines.append(f"TEL{label}:{_escape_text(phone.phone)}")
return lines
def _contact_address_lines(contact: Contact) -> list[str]:
lines: list[str] = []
for address in contact.postal_addresses:
label = f";TYPE={_escape_text(address.label)}" if address.label else ""
lines.append(
"ADR"
f"{label}:;;{_escape_text(address.street)};{_escape_text(address.locality)};"
f"{_escape_text(address.region)};{_escape_text(address.postal_code)};{_escape_text(address.country)}"
)
return lines
def _contact_note_and_tag_lines(contact: Contact) -> list[str]:
lines: list[str] = []
if contact.note:
lines.append(f"NOTE:{_escape_text(contact.note)}")
if contact.tags:
lines.append(f"CATEGORIES:{','.join(_escape_text(tag) for tag in contact.tags)}")
return lines
def _contact_url_lines(contact: Contact) -> list[str]:
lines: list[str] = []
urls = _contact_vcard_urls(contact)
if isinstance(urls, list):
for url in urls:
if isinstance(url, str) and url.strip():
lines.append(f"URL:{_escape_text(url.strip())}")
return lines
def _contact_vcard_urls(contact: Contact) -> object:
if not isinstance(contact.provenance, dict):
return None
vcard = contact.provenance.get("vcard")
if not isinstance(vcard, dict):
return None
return vcard.get("urls")
def contacts_to_vcard(contacts: list[Contact]) -> str:
return "".join(contact_to_vcard(contact) for contact in contacts)
+1
View File
@@ -0,0 +1 @@
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
import contextlib
import threading
import unittest
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from fastapi import HTTPException
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_addresses.backend.carddav import (
AddressCardDAVClient,
AddressCardDAVError,
absolute_dav_url,
urllib_transport,
)
from govoplan_addresses.backend.router import api_discover_carddav_address_books
from govoplan_addresses.backend.schemas import AddressCardDavDiscoveryRequest
@contextlib.contextmanager
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address
yield f"http://{host}:{port}"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
class CardDAVUrlSecurityTests(unittest.TestCase):
def test_transport_revalidates_dns_at_connection_time(self) -> None:
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
AddressCardDAVError,
"non-public network",
):
urllib_transport("GET", "https://dav.example.test/contact.vcf", {}, None, 2)
socket_factory.assert_not_called()
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
base_url = "https://dav.example.test/addressbooks/ada/"
self.assertEqual(
absolute_dav_url(base_url, "/principals/users/ada/"),
"https://dav.example.test/principals/users/ada/",
)
with self.assertRaisesRegex(AddressCardDAVError, "configured collection origin"):
absolute_dav_url(base_url, "https://evil.example.test/steal/")
with self.assertRaisesRegex(AddressCardDAVError, "query or fragment"):
absolute_dav_url(base_url, "/principals/users/ada/?token=secret")
def test_object_href_must_remain_inside_configured_collection(self) -> None:
client = AddressCardDAVClient(collection_url="https://dav.example.test/addressbooks/ada")
with self.assertRaisesRegex(AddressCardDAVError, "collection origin"):
client.object_url("https://evil.example.test/steal.vcf")
with self.assertRaisesRegex(AddressCardDAVError, "collection path"):
client.object_url("https://dav.example.test/addressbooks/other/steal.vcf")
with self.assertRaisesRegex(AddressCardDAVError, "collection path"):
client.object_url("/addressbooks/ada/%2e%2e/other/steal.vcf")
with self.assertRaisesRegex(AddressCardDAVError, "query or fragment"):
client.object_url("/addressbooks/ada/contact.vcf?download=1")
self.assertEqual(
client.object_url("/addressbooks/ada/contact.vcf"),
"https://dav.example.test/addressbooks/ada/contact.vcf",
)
def test_transport_refuses_redirect_before_forwarding_authorization(self) -> None:
forwarded_authorization: list[str | None] = []
class TargetHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(TargetHandler) as target_url:
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
self.send_response(302)
self.send_header("Location", f"{target_url}/stolen.vcf")
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as redirect_url:
status, _headers, _body = urllib_transport(
"GET",
f"{redirect_url}/contact.vcf",
{"Authorization": "Bearer top-secret"},
None,
2,
)
self.assertEqual(status, 302)
self.assertEqual(forwarded_authorization, [])
def test_transport_preserves_same_origin_redirects(self) -> None:
forwarded_authorization: list[str | None] = []
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path == "/contact.vcf":
self.send_response(302)
self.send_header("Location", "/redirected.vcf")
self.end_headers()
return
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
self.wfile.write(b"contact")
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
status, _headers, body = urllib_transport(
"GET",
f"{source_url}/contact.vcf",
{"Authorization": "Bearer expected"},
None,
2,
)
self.assertEqual(status, 200)
self.assertEqual(body, b"contact")
self.assertEqual(forwarded_authorization, ["Bearer expected"])
class CardDAVDiscoveryAuthorizationTests(unittest.TestCase):
def test_sync_read_alone_cannot_start_authenticated_discovery(self) -> None:
principal = ApiPrincipal(
principal=PrincipalRef(
account_id="account-read-only",
membership_id="membership-read-only",
tenant_id="tenant-1",
scopes=frozenset({"addresses:sync:read"}),
),
account=object(),
user=object(),
)
with patch("govoplan_addresses.backend.router.discover_carddav_address_books") as discover:
with self.assertRaises(HTTPException) as raised:
api_discover_carddav_address_books(
AddressCardDavDiscoveryRequest(
url="https://dav.example.test/",
auth_type="basic",
username="reader",
password="secret",
),
principal,
object(), # type: ignore[arg-type] - scope rejection precedes session use
)
self.assertEqual(raised.exception.status_code, 403)
self.assertEqual(raised.exception.detail, "Missing scope: addresses:sync:write")
discover.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,39 @@
from __future__ import annotations
import unittest
from govoplan_addresses.backend.manifest import manifest
class AddressesInterfaceDocumentationContractTests(unittest.TestCase):
def test_route_and_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
self.assertEqual({"/address-book"}, {item.path for item in frontend.routes}) # type: ignore[union-attr]
self.assertEqual(
{
"addresses.page",
"addresses.sources",
"addresses.contacts",
"addresses.detail",
"addresses.governance",
"addresses.sync",
},
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
)
def test_help_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
boundary = topics["addresses.boundary"]
governance = topics["addresses.contact-point-resolution"]
reference = topics["addresses.reference.fields-and-consequences"]
self.assertIn("addresses.state.read-only", boundary.metadata["help_contexts"])
self.assertIn("addresses.field.communication-purpose", governance.metadata["help_contexts"])
self.assertIn("addresses.action.sync", reference.metadata["help_contexts"])
self.assertIn("merge", reference.metadata["consequence_classes"])
self.assertIn("governance_fact", reference.metadata["consequence_classes"])
if __name__ == "__main__":
unittest.main()
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_addresses.backend.db.models import AddressBook, AddressSyncSource, Contact
from govoplan_addresses.backend.ldap import (
AddressLdapClient,
AddressLdapEntry,
AddressLdapError,
AddressLdapSearchResult,
)
from govoplan_addresses.backend.ldap_schemas import AddressLdapSourceCreateRequest
from govoplan_addresses.backend.service import (
create_ldap_sync_source,
preview_sync_source,
run_sync_source,
)
from govoplan_core.db.base import Base
class Principal:
account_id = "account-1"
group_ids = frozenset()
@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:contact:read",
"addresses:contact:write",
"addresses:contact:delete",
"addresses:sync:read",
"addresses:sync:write",
}
class FakeLdapClient:
def __init__(self, entries: list[AddressLdapEntry], *, complete: bool = True) -> None:
self.entries = entries
self.complete = complete
def search(self, *, base_dn: str, search_filter: str, attributes: tuple[str, ...], page_size: int, max_entries: int) -> AddressLdapSearchResult:
del search_filter, attributes, max_entries
return AddressLdapSearchResult(
base_dn=base_dn,
entries=tuple(self.entries),
complete=self.complete,
page_size=page_size,
)
def ldap_entry(
key: str,
*,
revision: str = "20260802090000Z",
organization: str = "Analysis Office",
) -> AddressLdapEntry:
return AddressLdapEntry(
dn=f"uid={key},ou=people,dc=example,dc=test",
attributes={
"entryUUID": key,
"modifyTimestamp": revision,
"displayName": "Ada Lovelace",
"givenName": "Ada",
"sn": "Lovelace",
"mail": "ada@example.test",
"o": organization,
"memberOf": ["cn=analysts,ou=groups,dc=example,dc=test"],
},
)
class AddressLdapSyncTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
self.session = sessionmaker(bind=engine, expire_on_commit=False)()
self.principal = Principal()
self.book = AddressBook(
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Directory",
source_kind="local",
read_only=False,
)
self.session.add(self.book)
self.session.flush()
self.source = create_ldap_sync_source(
self.session,
self.principal,
self.book.id,
AddressLdapSourceCreateRequest(
url="ldaps://directory.example.test",
display_name="Corporate directory",
base_dn="ou=people,dc=example,dc=test",
),
)
self.session.flush()
def test_preview_is_nonmutating_and_full_sync_is_idempotent(self) -> None:
client = FakeLdapClient([ldap_entry("person-1")])
preview = preview_sync_source(self.session, self.principal, self.source.id, client=client)
self.assertEqual(1, preview.stats.created)
self.assertEqual(0, self.session.query(Contact).count())
first = run_sync_source(self.session, self.principal, self.source.id, client=client)
self.assertEqual(1, first.stats.created)
contact = self.session.query(Contact).one()
self.assertEqual("ldap", contact.source_kind)
self.assertEqual("person-1", contact.provenance["ldap"]["source_key"])
self.assertEqual("succeeded", self.source.status)
repeated = run_sync_source(self.session, self.principal, self.source.id, client=client)
self.assertEqual(1, repeated.stats.unchanged)
self.assertEqual(1, self.session.query(Contact).count())
changed_client = FakeLdapClient(
[ldap_entry("person-1", revision="20260802100000Z", organization="Computing Office")]
)
changed = run_sync_source(self.session, self.principal, self.source.id, client=changed_client)
self.assertEqual(1, changed.stats.updated)
self.assertEqual("Computing Office", self.session.query(Contact).one().organization)
def test_only_complete_scans_plan_authoritative_deletes(self) -> None:
run_sync_source(
self.session,
self.principal,
self.source.id,
client=FakeLdapClient([ldap_entry("person-1")]),
)
incomplete = preview_sync_source(
self.session,
self.principal,
self.source.id,
client=FakeLdapClient([], complete=False),
)
self.assertEqual(0, incomplete.stats.deleted)
self.assertEqual(1, incomplete.stats.errors)
self.assertIsNone(self.session.query(Contact).one().deleted_at)
complete = run_sync_source(
self.session,
self.principal,
self.source.id,
client=FakeLdapClient([], complete=True),
)
self.assertEqual(1, complete.stats.deleted)
self.assertIsNotNone(self.session.query(Contact).one().deleted_at)
def test_connector_requires_encrypted_transport(self) -> None:
with self.assertRaisesRegex(AddressLdapError, "require StartTLS"):
AddressLdapClient(url="ldap://directory.example.test", start_tls=False)
with self.assertRaisesRegex(AddressLdapError, "must not contain credentials"):
AddressLdapClient(url="ldaps://user:secret@directory.example.test")
def test_source_is_always_read_only(self) -> None:
source = self.session.get(AddressSyncSource, self.source.id)
self.assertTrue(source.read_only)
self.assertEqual("read_only", source.sync_direction)
self.assertTrue(source.address_book.read_only)
if __name__ == "__main__":
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_addresses.backend.db.models import Base
from govoplan_addresses.backend.manifest import get_manifest
from govoplan_core.db.migrations import migrate_database
class AddressesMigrationTests(unittest.TestCase):
def test_schema_identifiers_fit_postgresql_limit(self) -> None:
overlong_indexes = sorted(
index.name
for table in Base.metadata.tables.values()
if table.name.startswith("addresses_")
for index in table.indexes
if index.name and len(index.name) > 63
)
self.assertEqual([], overlong_indexes)
def test_fresh_database_reaches_import_profile_head(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-addresses-migration-") as directory:
url = f"sqlite:///{Path(directory) / 'addresses.db'}"
migrate_database(
database_url=url,
enabled_modules=("addresses",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
with engine.connect() as connection:
self.assertIn(
"c5d7e8f9a0b1",
set(MigrationContext.configure(connection).get_current_heads()),
)
tables = set(inspect(connection).get_table_names())
self.assertIn("addresses_import_profiles", tables)
self.assertIn("addresses_import_runs", tables)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from datetime import UTC, datetime
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressSyncConflict,
AddressSyncDiagnostic,
AddressSyncSource,
)
from govoplan_addresses.backend.manifest import manifest
from govoplan_addresses.backend.provider_state import (
CARDDAV_PROVIDER_ID,
LDAP_PROVIDER_ID,
carddav_provider_states,
ldap_provider_states,
)
from govoplan_core.core.provider_governance import ExternalProviderStateContext
from govoplan_core.db.base import Base
class AddressesProviderStateTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(
self.engine,
tables=(
AddressBook.__table__,
AddressSyncSource.__table__,
AddressSyncConflict.__table__,
AddressSyncDiagnostic.__table__,
),
)
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
book = AddressBook(
id="book-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Remote",
)
self.source = AddressSyncSource(
id="source-1",
tenant_id="tenant-1",
address_book_id=book.id,
connector_type="carddav",
display_name="CardDAV",
external_address_book_ref="https://dav.example.test/addressbook/",
sync_direction="two_way",
read_only=False,
enabled=True,
status="succeeded",
last_success_at=datetime.now(UTC),
)
self.session.add_all((book, self.source))
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_state_is_tenant_bounded_secret_free_and_reports_conflict(self) -> None:
healthy = carddav_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual("healthy", healthy.health)
self.assertEqual("current", healthy.freshness)
self.assertEqual("governed_sync", healthy.authority_mode)
self.assertNotIn("dav.example.test", str(healthy.to_dict()))
self.assertEqual(
(),
carddav_provider_states(
ExternalProviderStateContext(
session=self.session,
tenant_id="tenant-2",
)
),
)
self.session.add(
AddressSyncConflict(
tenant_id="tenant-1",
sync_source_id=self.source.id,
address_book_id="book-1",
field_path="email",
status="open",
)
)
self.session.flush()
conflicted = carddav_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual("pending", conflicted.conflict)
self.assertEqual("attention", conflicted.recovery)
def test_manifest_registers_carddav_declaration_and_state(self) -> None:
self.assertEqual(CARDDAV_PROVIDER_ID, manifest.external_providers[0].id)
self.assertEqual(
CARDDAV_PROVIDER_ID,
manifest.external_provider_state_providers[0].provider_id,
)
self.assertEqual(LDAP_PROVIDER_ID, manifest.external_providers[1].id)
self.assertEqual(
LDAP_PROVIDER_ID,
manifest.external_provider_state_providers[1].provider_id,
)
def test_failed_ldap_source_is_stale_without_exposing_endpoint(self) -> None:
self.source.connector_type = "ldap"
self.source.display_name = "Directory"
self.source.status = "failed"
self.source.last_error = "connection failed"
self.session.flush()
state = ldap_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual(LDAP_PROVIDER_ID, state.provider_id)
self.assertEqual("error", state.health)
self.assertEqual("current", state.freshness)
self.assertEqual("external_authoritative", state.authority_mode)
self.assertNotIn("dav.example.test", str(state.to_dict()))
if __name__ == "__main__":
unittest.main()
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
import base64
from io import BytesIO
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from openpyxl import Workbook
from govoplan_addresses.backend.db.models import AddressBook, Contact
from govoplan_addresses.backend.import_schemas import (
AddressImportConfiguration,
AddressImportPreviewRequest,
AddressImportProfileCreateRequest,
AddressImportProfileUpdateRequest,
AddressImportRollbackRequest,
)
from govoplan_addresses.backend.imports import (
apply_address_import,
create_import_profile,
import_run_payload,
preview_address_import,
rollback_address_import,
update_import_profile,
)
from govoplan_core.db.base import Base
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:contact:read",
"addresses:contact:write",
}
def encoded(value: str) -> str:
return base64.b64encode(value.encode()).decode()
class AddressTabularImportTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
self.session = sessionmaker(bind=engine, expire_on_commit=False)()
self.principal = Principal()
self.book = AddressBook(
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Imported contacts",
source_kind="local",
read_only=False,
)
self.session.add(self.book)
self.profile = create_import_profile(
self.session,
self.principal,
AddressImportProfileCreateRequest(
scope_type="tenant",
name="Monthly contacts",
source_format="csv",
configuration=AddressImportConfiguration(
delimiter=";",
field_mappings={
"source_key": "id",
"given_name": "first",
"family_name": "last",
"email": "email",
"organization": "organization",
},
),
),
)
self.session.flush()
def test_preview_apply_repeat_and_guarded_rollback(self) -> None:
payload = AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="contacts.csv",
content_base64=encoded(
"id;first;last;email;organization\n"
"1;Ada;Lovelace;ada@example.test;Analysis Office\n"
"2;Grace;Hopper;grace@example.test;Computing Office\n"
),
)
run = preview_address_import(self.session, self.principal, self.book.id, payload)
self.assertEqual(2, run.statistics["create"])
self.assertFalse(run.diagnostics)
applied = apply_address_import(
self.session,
self.principal,
run.id,
expected_plan_hash=run.plan_hash,
)
self.assertEqual("applied", applied.status)
self.assertEqual(2, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
self.assertIs(applied, apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash))
response_evidence = import_run_payload(applied)["result_evidence"]
self.assertEqual(2, response_evidence["created_contact_count"])
self.assertNotIn("created_contact_ids", response_evidence)
self.assertNotIn("updated_contacts", response_evidence)
repeated = preview_address_import(self.session, self.principal, self.book.id, payload)
self.assertEqual(2, repeated.statistics["unchanged"])
apply_address_import(self.session, self.principal, repeated.id, expected_plan_hash=repeated.plan_hash)
self.assertEqual(2, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
rolled_back = rollback_address_import(
self.session,
self.principal,
run.id,
AddressImportRollbackRequest(reason="The operator selected the wrong monthly file."),
)
self.assertEqual("rolled_back", rolled_back.status)
self.assertEqual(0, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
def test_duplicate_keys_and_changed_targets_block_apply(self) -> None:
duplicate = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="duplicates.csv",
content_base64=encoded(
"id;first;last;email;organization\n"
"1;Ada;Lovelace;ada@example.test;One\n"
"1;Ada;Lovelace;ada@example.test;Two\n"
),
),
)
self.assertEqual(2, duplicate.statistics["conflict"])
with self.assertRaisesRegex(ValueError, "error diagnostics"):
apply_address_import(self.session, self.principal, duplicate.id, expected_plan_hash=duplicate.plan_hash)
initial = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="one.csv",
content_base64=encoded("id;first;last;email;organization\n1;Ada;Lovelace;ada@example.test;One\n"),
),
)
apply_address_import(self.session, self.principal, initial.id, expected_plan_hash=initial.plan_hash)
changed = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="one.csv",
content_base64=encoded("id;first;last;email;organization\n1;Ada;Lovelace;ada@example.test;Two\n"),
),
)
contact = self.session.query(Contact).filter(Contact.deleted_at.is_(None)).one()
contact.organization = "Concurrent edit"
self.session.flush()
with self.assertRaisesRegex(ValueError, "changed after preview"):
apply_address_import(self.session, self.principal, changed.id, expected_plan_hash=changed.plan_hash)
def test_profile_updates_create_immutable_versions(self) -> None:
next_profile = update_import_profile(
self.session,
self.principal,
self.profile.id,
payload=AddressImportProfileUpdateRequest(name="Monthly contacts v2"),
)
self.assertFalse(self.profile.is_current)
self.assertTrue(next_profile.is_current)
self.assertEqual(self.profile.profile_key, next_profile.profile_key)
self.assertEqual(2, next_profile.version)
def test_xlsx_sheet_selection_and_formula_rejection(self) -> None:
workbook = Workbook()
workbook.active.title = "Ignore"
sheet = workbook.create_sheet("Contacts")
sheet.append(["id", "first", "last", "email", "organization"])
sheet.append(["1", "Ada", "Lovelace", "ada@example.test", "Analysis Office"])
content = BytesIO()
workbook.save(content)
xlsx_profile = create_import_profile(
self.session,
self.principal,
AddressImportProfileCreateRequest(
scope_type="tenant",
name="Workbook contacts",
source_format="xlsx",
configuration=AddressImportConfiguration(
sheet_name="Contacts",
field_mappings={
"source_key": "id",
"given_name": "first",
"family_name": "last",
"email": "email",
"organization": "organization",
},
),
),
)
self.session.flush()
run = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=xlsx_profile.id,
filename="contacts.xlsx",
content_base64=base64.b64encode(content.getvalue()).decode(),
),
)
self.assertEqual(1, run.statistics["create"])
sheet["E2"] = "=CONCAT(\"Analysis\", \" Office\")"
content = BytesIO()
workbook.save(content)
with self.assertRaisesRegex(ValueError, "formulas are never evaluated"):
preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=xlsx_profile.id,
filename="contacts.xlsx",
content_base64=base64.b64encode(content.getvalue()).decode(),
),
)
if __name__ == "__main__":
unittest.main()
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
import unittest
from govoplan_addresses.backend.db.models import Contact, ContactEmail, ContactPhone, ContactPostalAddress
from govoplan_addresses.backend.vcard import contact_to_vcard, parse_vcards_with_issues
class VCardTests(unittest.TestCase):
def test_parse_vcard_preserves_folded_and_escaped_text(self) -> None:
result = parse_vcards_with_issues(
"BEGIN:VCARD\r\n"
"VERSION:4.0\r\n"
"UID:contact-1\r\n"
"FN:Ada \r\n"
" Lovelace\r\n"
"N:Lovelace;Ada;;;\r\n"
"NOTE:Line one\\nLine two\r\n"
"CATEGORIES:science\\,history,engineering\r\n"
"EMAIL;TYPE=work;PREF=1:ada@example.local\r\n"
"ADR;TYPE=work:;;Main Street 1;Berlin;BE;10115;Germany\r\n"
"URL:https://example.local/ada\r\n"
"END:VCARD\r\n"
)
self.assertEqual([], result.issues)
self.assertEqual(1, len(result.cards))
payload = result.cards[0].payload
self.assertEqual("Ada Lovelace", payload.display_name)
self.assertEqual("Line one\nLine two", payload.note)
self.assertEqual(["science,history", "engineering"], payload.tags)
self.assertEqual("work", payload.emails[0].label)
self.assertTrue(payload.emails[0].is_primary)
self.assertEqual("Berlin", payload.postal_addresses[0].locality)
self.assertEqual(["https://example.local/ada"], payload.provenance["vcard"]["urls"])
def test_contact_to_vcard_escapes_text_and_filters_urls(self) -> None:
contact = Contact(
address_book_id="book-1",
display_name="Ada Lovelace",
given_name="Ada",
family_name="Lovelace",
organization="Analytical Engine Office",
role_title="Mathematician",
note="Line one\nLine two",
tags=["science,history", "engineering"],
source_ref="contact-1",
source_revision="rev-1",
provenance={"vcard": {"urls": [" https://example.local/ada ", "", 42]}},
)
contact.emails = [ContactEmail(label="work", email="ada@example.local", is_primary=True, order_index=0)]
contact.phones = [ContactPhone(label="work", phone="+49 30 123", is_primary=True, order_index=0)]
contact.postal_addresses = [
ContactPostalAddress(
label="work",
street="Main Street 1",
locality="Berlin",
region="BE",
postal_code="10115",
country="Germany",
is_primary=True,
order_index=0,
)
]
content = contact_to_vcard(contact)
self.assertIn("UID:contact-1\r\n", content)
self.assertIn("REV:rev-1\r\n", content)
self.assertIn("NOTE:Line one\\nLine two\r\n", content)
self.assertIn("CATEGORIES:science\\,history,engineering\r\n", content)
self.assertIn("EMAIL;TYPE=work:ada@example.local\r\n", content)
self.assertIn("URL:https://example.local/ada\r\n", content)
self.assertNotIn("URL:42", content)
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.18",
"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"
},
"scripts": {
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const pagePath = fileURLToPath(new URL("../src/features/addressbook/AddressBookPage.tsx", import.meta.url));
const stylesPath = fileURLToPath(new URL("../src/styles/addresses.css", import.meta.url));
const page = readFileSync(pagePath, "utf8");
const styles = readFileSync(stylesPath, "utf8");
assert.match(page, /SegmentedControl,[\s\S]*SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(page, /<SelectionList label="Contacts" className="address-contact-selection-list">/);
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selected\}[\s\S]*className=\{`address-contact-row/);
assert.match(page, /draggable=\{!contact\.deleted_at && !saving\}/);
assert.match(page, /<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">/);
assert.match(page, /selected=\{cardDavForm\.collection_url === item\.collection_url\}/);
assert.match(page, /<SegmentedControl<ConflictMergeChoice>[\s\S]*role="group"[\s\S]*value=\{conflictMergeChoices\[row\.field\] \?\? "local"\}/);
assert.doesNotMatch(page, /<button[\s\S]{0,160}(?:address-contact-row|address-sync-result-row)/);
assert.doesNotMatch(styles, /\.address-conflict-choice button/);
assert.doesNotMatch(styles, /\.address-contact-row:(?:hover|focus-visible)/);
console.log("Address-book flat selections use central components.");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const ADDRESSES_DOCUMENTATION = {
topicId: "addresses.boundary",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const ADDRESS_FIELDS_DOCUMENTATION = {
topicId: "addresses.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const ADDRESS_GOVERNANCE_DOCUMENTATION = {
topicId: "addresses.contact-point-resolution",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const ADDRESSES_I18N = {
requiredAction: "i18n:govoplan-addresses.required_action",
actor: "i18n:govoplan-addresses.actor",
destination: "i18n:govoplan-addresses.destination",
permissionDetails: "i18n:govoplan-addresses.permission_details",
permissionAction: "i18n:govoplan-addresses.permission_action",
permissionActor: "i18n:govoplan-addresses.permission_actor",
permissionDestination: "i18n:govoplan-addresses.permission_destination"
} as const;
+176
View File
@@ -0,0 +1,176 @@
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",
"i18n:govoplan-addresses.sources": "Address sources",
"i18n:govoplan-addresses.contact_detail": "Contact detail",
"i18n:govoplan-addresses.communication_governance": "Communication governance",
"i18n:govoplan-addresses.required_action": "Required action",
"i18n:govoplan-addresses.actor": "Responsible actor",
"i18n:govoplan-addresses.destination": "Where to continue",
"i18n:govoplan-addresses.permission_details": "Your account can inspect Addresses but cannot create or change address books, lists, or contacts.",
"i18n:govoplan-addresses.permission_action": "Ask for the address-book, list, or contact permission needed for the intended task.",
"i18n:govoplan-addresses.permission_actor": "A tenant administrator or owner of the address-book scope",
"i18n:govoplan-addresses.permission_destination": "Access administration for the current tenant or group",
"i18n:govoplan-addresses.unsaved_book_title": "Unsaved address book",
"i18n:govoplan-addresses.unsaved_list_title": "Unsaved address list",
"i18n:govoplan-addresses.unsaved_contact_title": "Unsaved contact",
"i18n:govoplan-addresses.unsaved_message": "Save or discard this draft before leaving the editor.",
"Addresses are read-only": "Addresses are read-only",
"Address books": "Address books",
"Address sources": "Address sources",
"Contact detail": "Contact detail",
"Show archived": "Show archived",
"Search contacts": "Search contacts",
"No address books found.": "No address books found.",
"No contact selected": "No contact selected",
"Add address book": "Add address book",
"Edit address book": "Edit address book",
"Add address list": "Add address list",
"Edit address list": "Edit address list",
"Add contact": "Add contact",
"Edit contact": "Edit contact",
"Communication governance": "Communication governance",
"Display name": "Display name",
"Given name": "Given name",
"Family name": "Family name",
"Organization": "Organization",
"Role title": "Role title",
"Email addresses": "Email addresses",
"Phone numbers": "Phone numbers",
"Postal addresses": "Postal addresses",
"Primary": "Primary",
"Description": "Description",
"Note": "Note"
},
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",
"i18n:govoplan-addresses.sources": "Adressquellen",
"i18n:govoplan-addresses.contact_detail": "Kontaktdetails",
"i18n:govoplan-addresses.communication_governance": "Kommunikationssteuerung",
"i18n:govoplan-addresses.required_action": "Erforderliche Aktion",
"i18n:govoplan-addresses.actor": "Verantwortliche Stelle",
"i18n:govoplan-addresses.destination": "Fortsetzung",
"i18n:govoplan-addresses.permission_details": "Ihr Konto darf Adressen einsehen, aber keine Adressbücher, Listen oder Kontakte erstellen oder ändern.",
"i18n:govoplan-addresses.permission_action": "Fordern Sie die für die Aufgabe erforderliche Adressbuch-, Listen- oder Kontaktberechtigung an.",
"i18n:govoplan-addresses.permission_actor": "Mandantenadministration oder Eigentümer des Adressbuchbereichs",
"i18n:govoplan-addresses.permission_destination": "Zugriffsverwaltung des aktuellen Mandanten oder der Gruppe",
"i18n:govoplan-addresses.unsaved_book_title": "Ungespeichertes Adressbuch",
"i18n:govoplan-addresses.unsaved_list_title": "Ungespeicherte Adressliste",
"i18n:govoplan-addresses.unsaved_contact_title": "Ungespeicherter Kontakt",
"i18n:govoplan-addresses.unsaved_message": "Speichern oder verwerfen Sie diesen Entwurf, bevor Sie den Editor verlassen.",
"Addresses are read-only": "Adressen sind schreibgeschützt",
"Address books": "Adressbücher",
"Address sources": "Adressquellen",
"Contact detail": "Kontaktdetails",
"Show archived": "Archivierte anzeigen",
"Search contacts": "Kontakte suchen",
"No address books found.": "Keine Adressbücher gefunden.",
"No contact selected": "Kein Kontakt ausgewählt",
"Add address book": "Adressbuch hinzufügen",
"Edit address book": "Adressbuch bearbeiten",
"Add address list": "Adressliste hinzufügen",
"Edit address list": "Adressliste bearbeiten",
"Add contact": "Kontakt hinzufügen",
"Edit contact": "Kontakt bearbeiten",
"Communication governance": "Kommunikationssteuerung",
"Display name": "Anzeigename",
"Given name": "Vorname",
"Family name": "Nachname",
"Organization": "Organisation",
"Role title": "Funktionsbezeichnung",
"Email addresses": "E-Mail-Adressen",
"Phone numbers": "Telefonnummern",
"Postal addresses": "Postanschriften",
"Primary": "Primär",
"Description": "Beschreibung",
"Note": "Notiz"
}
};
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./module";
export * from "./module";
export { default as AddressBookPage } from "./features/addressbook/AddressBookPage";
+32
View File
@@ -0,0 +1,32 @@
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: "0.1.9",
dependencies: [],
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
translations,
viewSurfaces: [
{ id: "addresses.page", moduleId: "addresses", kind: "route", label: "i18n:govoplan-addresses.address_book.f6327f59", order: 80 },
{ id: "addresses.sources", moduleId: "addresses", kind: "section", label: "i18n:govoplan-addresses.sources", parentId: "addresses.page", order: 10 },
{ id: "addresses.contacts", moduleId: "addresses", kind: "section", label: "i18n:govoplan-addresses.contacts.b0dd615c", parentId: "addresses.page", order: 20 },
{ id: "addresses.detail", moduleId: "addresses", kind: "section", label: "i18n:govoplan-addresses.contact_detail", parentId: "addresses.page", order: 30 },
{ id: "addresses.governance", moduleId: "addresses", kind: "action", label: "i18n:govoplan-addresses.communication_governance", parentId: "addresses.detail", order: 40 },
{ id: "addresses.sync", moduleId: "addresses", kind: "action", label: "i18n:govoplan-addresses.sync.905f6309", parentId: "addresses.sources", order: 50 }
],
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, surfaceId: "addresses.page", render: ({ settings, auth, onAuthChange }) => createElement(AddressBookPage, { settings, auth, onAuthChange }) }]
};
export default addressesModule;
+828
View File
@@ -0,0 +1,828 @@
.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-import-dialog {
max-width: min(1080px, calc(100vw - 36px));
width: min(1080px, calc(100vw - 36px));
}
.address-import-workspace,
.address-import-profile-editor,
.address-import-preview {
display: grid;
gap: 14px;
min-height: 0;
}
.address-import-profile-actions {
align-items: end;
justify-content: flex-start;
padding-bottom: 1px;
}
.address-import-mapping-grid {
display: grid;
gap: 10px 14px;
grid-template-columns: repeat(3, minmax(0, 1fr));
max-height: min(42vh, 440px);
overflow: auto;
padding-right: 4px;
}
.address-import-preview > .address-sync-plan-grid {
border: 0;
gap: 8px;
grid-template-columns: repeat(6, minmax(0, 1fr));
max-height: none;
overflow: visible;
}
.address-import-preview > .address-sync-plan-grid > div {
background: var(--panel-soft);
border: var(--border-line);
border-radius: 6px;
display: grid;
gap: 2px;
padding: 9px 10px;
}
.address-import-preview > .address-sync-plan-grid strong {
font-size: 1.05rem;
}
.address-import-preview > .address-sync-plan-grid small {
color: var(--muted);
text-transform: capitalize;
}
.address-sync-record-list,
.address-sync-plan-grid {
display: grid;
}
.address-sync-result-list,
.address-sync-record-list,
.address-sync-plan-grid {
border: var(--border-line);
max-height: 260px;
overflow: auto;
}
.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 {
align-items: center;
border-bottom: var(--border-line);
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
}
.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-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-pagination {
border-top: var(--border-line);
flex: 0 0 auto;
}
.address-contact-selection-list {
gap: 2px;
}
.address-contact-row {
align-items: center;
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
}
.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-contact-point-value {
align-items: center;
display: grid;
gap: 4px 8px;
grid-template-columns: minmax(0, 1fr) auto auto;
}
.address-contact-point-value > small {
color: var(--muted);
grid-column: 1 / -1;
}
.address-contact-point-value .btn {
min-height: 28px;
padding: 4px 8px;
}
.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;
}
@media (max-width: 760px) {
.address-import-mapping-grid {
grid-template-columns: minmax(0, 1fr);
}
.address-import-preview > .address-sync-plan-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.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-governance-dialog .dialog-panel {
width: min(980px, calc(100vw - 32px));
}
.dialog-panel.address-quality-dialog,
.address-quality-dialog .dialog-panel {
width: min(1120px, calc(100vw - 32px));
}
.address-quality-layout {
display: grid;
gap: 18px;
max-height: min(720px, calc(100vh - 210px));
overflow: auto;
padding-right: 2px;
}
.address-provenance-layout {
display: grid;
gap: 12px;
}
.address-provenance-list {
max-height: min(620px, calc(100vh - 300px));
}
.address-provenance-value {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.address-quality-empty {
padding: 10px 12px;
}
.address-merge-field-sources select {
min-width: 0;
text-overflow: ellipsis;
}
.address-quality-metrics {
margin-top: 0;
}
.address-quality-section,
.address-quality-list,
.address-quality-row-main {
display: grid;
gap: 8px;
}
.address-quality-section {
border-top: var(--border-line);
padding-top: 14px;
}
.address-quality-list {
border: var(--border-line);
border-radius: 6px;
max-height: 300px;
overflow: auto;
}
.address-quality-row {
align-items: center;
background: transparent;
border: 0;
border-bottom: var(--border-line);
color: inherit;
display: grid;
font: inherit;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 10px 12px;
text-align: left;
width: 100%;
}
.address-quality-row:last-child {
border-bottom: 0;
}
.address-quality-row-button {
cursor: pointer;
grid-template-columns: minmax(0, 1fr);
}
.address-quality-row-button:hover {
background: var(--panel-soft);
}
.address-quality-row-heading {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.address-quality-row-main small {
color: var(--muted);
}
.address-governance-layout,
.address-governance-list {
display: grid;
gap: 12px;
}
.address-governance-list {
max-height: 260px;
overflow: auto;
}
.address-governance-rule {
align-items: center;
border-bottom: var(--border-line);
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 8px 0;
}
.address-governance-rule:last-child {
border-bottom: 0;
}
.address-governance-rule-main,
.address-governance-rule-heading {
display: flex;
gap: 6px;
}
.address-governance-rule-main {
align-items: flex-start;
flex-direction: column;
min-width: 0;
}
.address-governance-rule-heading {
align-items: center;
flex-wrap: wrap;
}
.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;
}
.strong-link {
font-weight: 700;
}
.is-selected-row {
background: var(--panel-soft);
}
.address-book-page .is-archived-row {
opacity: 0.72;
}
@media (max-width: 980px) {
.address-book-workspace,
.address-form-row-email,
.address-form-row-phone,
.address-form-row-postal {
grid-template-columns: 1fr;
}
.address-quality-row,
.address-contact-point-value {
grid-template-columns: 1fr;
}
.address-contact-point-value > small {
grid-column: auto;
}
}