33 Commits
Author SHA1 Message Date
zemion 3f1ff79e87 fix(webui): bind consequential address actions to help
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 11:36:31 +02:00
zemion 5cdf0ae9ff docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 01:15:30 +02:00
zemion 3458306e04 docs(addresses): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:11:47 +02:00
zemion 8740fb33f8 feat(addresses): add governed DSAR coverage 2026-08-21 01:19:17 +02:00
zemion 42fe262376 feat: add selective vCard batch workflows 2026-08-20 17:24:42 +02:00
zemion 147f34c5c1 feat: add governed LDIF contact imports 2026-08-20 11:19:23 +02:00
zemion ec32ad2c37 feat(imports): resume persisted address runs 2026-08-19 20:39:21 +02:00
zemion 7e5c1cf10c feat: align addresses with shared UI foundations 2026-08-18 21:32:34 +02:00
zemion 63d59a7eaa Adopt shared WebUI layout primitives 2026-08-18 11:30:39 +02:00
zemion e6165b4d26 Adopt shared WebUI layout primitives 2026-08-18 10:42:50 +02:00
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
51 changed files with 18440 additions and 405 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
+2
View File
@@ -142,11 +142,13 @@ dist
.policy-test-build/ .policy-test-build/
.template-preview-test-build/ .template-preview-test-build/
.import-test-build/ .import-test-build/
.import-run-test-build/
webui/.component-test-build/ webui/.component-test-build/
webui/.module-test-build/ webui/.module-test-build/
webui/.policy-test-build/ webui/.policy-test-build/
webui/.template-preview-test-build/ webui/.template-preview-test-build/
webui/.import-test-build/ webui/.import-test-build/
webui/.import-run-test-build/
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
+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.
+81 -14
View File
@@ -5,9 +5,8 @@
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
`govoplan-addresses` is the reusable address and recipient-source module. It `govoplan-addresses` is the reusable address and recipient-source module. It
owns long-lived address directories and makes them available to campaigns, owns long-lived address directories and contact points and makes them available
mail, forms, reporting, portal, and postbox modules through platform to consumers through platform capabilities.
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.
@@ -21,8 +20,14 @@ tenant summaries, and uninstall guards.
The first UI supports user, group, tenant, and system-scoped address books, The first UI supports user, group, tenant, and system-scoped address books,
multi-value contact methods, soft deletion, restore, read-only lookup/search, multi-value contact methods, soft deletion, restore, read-only lookup/search,
and vCard import/export for common contact fields. Imported vCards preserve and vCard import/export for common contact fields. Multi-file vCard imports now
source payload and revision metadata for later sync/conflict work. create a persisted preview before mutation, expose duplicate suggestions and
per-card create/update/ignore choices, reject stale plans, and make identical
commit retries idempotent. Pending batches can be reloaded or cancelled.
Address-book, address-list, and selected-contact exports explicitly support
vCard 3.0 or 4.0 with deterministic ordering and a recorded content hash.
Imported vCards preserve source payload and revision metadata for later
sync/conflict work, while batch diagnostics expose only bounded metadata.
The backend and WebUI also support classical address lists: reusable groupings The backend and WebUI also support classical address lists: reusable groupings
of contacts or specific contact methods within one address book. Campaigns can of contacts or specific contact methods within one address book. Campaigns can
@@ -43,6 +48,16 @@ inspection UI are implemented. The conflict review UI compares stored local and
remote field payloads, can apply a stored remote vCard payload, and supports remote field payloads, can apply a stored remote vCard payload, and supports
manual per-field local/remote merge choices. 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 API-managed CardDAV credentials are encrypted inside the source record. Source
deletion physically removes that credential material and records a non-secret deletion physically removes that credential material and records a non-secret
audit event in the same database transaction; destructive module retirement audit event in the same database transaction; destructive module retirement
@@ -54,9 +69,9 @@ deletion because Addresses cannot prove that it owns them.
`govoplan-addresses` owns: `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
@@ -69,17 +84,37 @@ 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 - operational distribution lists/`Verteiler` with mixed recipient types
## First Capabilities ## Capabilities
The module exposes three core-mediated capabilities: The module exposes core-mediated capabilities for:
- `addresses.lookup`: read-only contact/recipient lookup for autocomplete. - `addresses.lookup`: read-only contact/recipient lookup for autocomplete.
- `addresses.recipient_source`: immutable recipient snapshots for campaign, - `addresses.recipient_source`: immutable recipient snapshots for campaign,
reporting, mail-build, forms, portal, and postbox workflows. reporting, mail-build, forms, portal, and postbox workflows.
- `addresses.contact_writer`: address-book-scoped write decisions and contact - `addresses.contact_writer`: address-book-scoped write decisions and contact
creation for local or otherwise writable sources. 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.
- `privacy.dsar.addresses`: tenant-bounded, minimized data-subject discovery
across contacts, contact points, list use, governance, provenance,
synchronization evidence, and operator attribution.
The DSAR provider accepts corroborated email/account selectors and namespaced
Addresses references. It does not export connector state, raw import or sync
payloads, opaque metadata, snapshot payloads, or merge before/after payloads.
Reusable contacts are never deleted automatically: shared/synchronized contact
changes require an authorized dependency review through the ordinary Addresses
workflows, while governance, quality, merge, sync, import, and attribution
evidence is retained with an explicit reason.
`addresses.recipient_source` returns: `addresses.recipient_source` returns:
@@ -97,10 +132,13 @@ address. Email-oriented consumers snapshot email targets and whole-contact
entries with a usable email address; postal-only entries remain valid list entries with a usable email address; postal-only entries remain valid list
members for later postal/document workflows. members for later postal/document workflows.
Consumers must store their own immutable snapshot when they need historical Legacy `addresses.recipient_source` consumers must store their own immutable
evidence. The addresses module remains the owner of the reusable source, not of snapshot when they need historical evidence. Channel-neutral consumers may use
the consumer's historical records. Consumers must resolve these capabilities the dedicated freeze operation described below. The addresses module remains
through the platform registry and must not import address ORM/service internals. 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 `addresses.contact_writer` returns an explicit decision before a consumer shows
or executes write actions: allowed/blocked, reason, user-facing message, or executes write actions: allowed/blocked, reason, user-facing message,
@@ -108,7 +146,36 @@ required scopes, source kind, read-only state, and provenance. The decision is
address-book specific; broader policy modules may later contribute to the same address-book specific; broader policy modules may later contribute to the same
decision path, but consumers should not import or duplicate policy logic. 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 ## Design Documents
- [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md) - [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md)
- [Implementation plan](docs/IMPLEMENTATION_PLAN.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)
+135 -20
View File
@@ -17,7 +17,7 @@ targets layered on top of the same local model and sync contracts.
`govoplan-addresses` owns: `govoplan-addresses` owns:
- scoped address books - scoped address books
- contacts, organizations, households, and postal/email/phone address data - vCard-compatible contacts and postal/email/phone contact-point data
- vCard import/export and vCard-compatible field mapping - vCard import/export and vCard-compatible field mapping
- reusable recipient sources and classical address lists - reusable recipient sources and classical address lists
- contact tags, categories, communication preferences, consent, and legal basis - contact tags, categories, communication preferences, consent, and legal basis
@@ -31,9 +31,12 @@ It does not own:
- mail transport, mailbox access, or delivery queues - mail transport, mailbox access, or delivery queues
- calendar events or iCalendar event storage - calendar events or iCalendar event storage
- global identity authentication or authorization decisions - global identity authentication or authorization decisions
- organization structure or internal function assignments - IDM identities, typed groups, effective-dated relationships, or identity
- operational distribution lists/`Verteiler` with mixed users, identities, lifecycle state
groups, functions, roles, raw recipients, and nested lists - 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 ## Scopes
@@ -55,7 +58,8 @@ fields:
- name components and formatted names - name components and formatted names
- nicknames and display names - nicknames and display names
- email addresses, phone numbers, postal addresses, URLs, notes, categories - email addresses, phone numbers, postal addresses, URLs, notes, categories
- organizations, titles, roles, departments, and relationships - organization, title, role, department, `KIND`, and `RELATED` values needed for
vCard round-trip compatibility
- birthday/anniversary where allowed by policy - birthday/anniversary where allowed by policy
- photos/avatars where storage and privacy policy allow them - photos/avatars where storage and privacy policy allow them
- calendar or scheduling addresses where present - calendar or scheduling addresses where present
@@ -66,7 +70,8 @@ representation for import/export and conflict handling.
The local baseline implements scoped address books, contacts, normalized The local baseline implements scoped address books, contacts, normalized
email/phone/postal-address tables, tags, source kind/reference fields, email/phone/postal-address tables, tags, source kind/reference fields,
first-class source payload/revision fields, and provenance JSON. Imported 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. vCards preserve raw source payload and revision metadata for audit/debugging.
Sync sources, attempt state, tombstones, conflicts, and diagnostics are now Sync sources, attempt state, tombstones, conflicts, and diagnostics are now
first-class backend tables and API resources. Connector-specific diffing, first-class backend tables and API resources. Connector-specific diffing,
@@ -83,6 +88,8 @@ The first stable capabilities are:
campaign, scheduling, postbox, portal, and case workflows. campaign, scheduling, postbox, portal, and case workflows.
- `addresses.contact_writer`: provide address-book-scoped write target decisions - `addresses.contact_writer`: provide address-book-scoped write target decisions
and contact creation for local or otherwise writable sources. 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 Capabilities use DTOs and source IDs. Consumers must not receive ORM objects or
write address tables directly. Consumers that need historical evidence must write address tables directly. Consumers that need historical evidence must
@@ -92,10 +99,24 @@ provenance; they must not treat live address records as historical evidence.
`addresses.recipient_source` exposes both complete address books and classical `addresses.recipient_source` exposes both complete address books and classical
address lists. Address-book sources use `addresses:address_book:<id>`. address lists. Address-book sources use `addresses:address_book:<id>`.
Address-list sources use `addresses:address_list:<id>` and include the Address-list sources use `addresses:address_list:<id>` and include the
address-list entry ID in each recipient's provenance. The current snapshot DTO address-list entry ID in each recipient's provenance. The legacy snapshot DTO
is email-recipient oriented; postal-only list entries are valid address-list remains email-oriented for compatible campaign consumers.
members but are skipped by the email recipient-source path until postal
recipient DTOs are added. 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 The writer capability is intentionally address-book specific. It answers
whether the current principal may perform an operation such as `create_contact`, whether the current principal may perform an operation such as `create_contact`,
@@ -155,19 +176,106 @@ module retirement audits all remaining owned credential material before table
removal. An unowned legacy reference is detached rather than passed to an removal. An unowned legacy reference is detached rather than passed to an
external secret provider. 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 Mapped Imports
CSV, XLSX, and LDIF 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.
LDIF is unfolded and parsed as a bounded stream of entries. Attribute names are
case-insensitive; UTF-8 and base64-encoded text and repeated values are retained
for mapping, while binary and URL values produce diagnostics and are never
projected or fetched. The default change-record policy rejects change records.
A profile can instead ignore them, or treat `changetype: add` as a static entry;
modify and delete records are never translated into contact mutations. Entry
hashes, input/plan hashes, mapping-version provenance, and the same correction
and guarded rollback lifecycle apply as for CSV and XLSX.
Persisted import runs can be resumed through `/address-book?import_run=<id>`.
The WebUI reloads the bounded run projection, selects its address book and
mapping version, and restores statistics, diagnostics, effects, and lifecycle
state. Apply and rollback both carry the reviewed plan hash. The normal read
projection never includes uploaded bytes or private before-images, and an
unknown, expired, hidden, or cross-tenant id is presented as one unavailable
state so the deep link cannot enumerate another tenant's imports.
## 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 ## Connector Direction
Implement connectors in this order: Implement connectors in this order:
1. vCard import/export and batch import. 1. vCard import/export and batch import.
2. CardDAV address books. 2. CardDAV address books.
3. LDAP/Active Directory read-only directories. 3. LDAP/Active Directory read-only directories and reusable CSV/XLSX mapping
4. Exchange/Microsoft 365 and Google Contacts. profiles (implemented).
5. CSV/XLSX/LDIF import mapping profiles. 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: Connector runtime behavior should reuse shared connector concepts where useful:
configured endpoints, credentials, dry-run, diagnostics, rate limits, and audit configured endpoints, credentials, dry-run, diagnostics, rate limits, and audit
events. 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 ## Cross-Module Integration
@@ -180,19 +288,26 @@ 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 write actions when no writable target exists, or show the writer decision
message when a disabled action remains visible for context. message when a disabled action remains visible for context.
Operational distribution lists belong in `govoplan-dist-lists`. They may later Operational distribution lists and reusable dynamic segments belong in
consume address lists as one entry type, but they own mixed recipient expansion `govoplan-dist-lists`. They may consume address lists as one entry type, but
for users, identities, organization units, groups, functions, roles, raw they own mixed recipient expansion for address contacts, IDM identities and
recipients, and nested lists. Workflow and Tasks own `Umlauf` execution state; 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. 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 ## Deferred Work
The following are valuable but not required for the first functional milestone: The following are valuable but not required for the first functional milestone:
- automatic deduplication and merge suggestions
- two-way sync conflict UI - two-way sync conflict UI
- Microsoft/Google connectors - Microsoft/Google connectors
- household and relationship editing - richer vCard `KIND`/`RELATED` round-trip and provider-reference linking
- advanced consent-policy automation - advanced consent-policy automation
- contact activity timeline across all modules - contact activity timeline across all modules
+28 -10
View File
@@ -73,6 +73,11 @@ Tasks:
- [x] define immutable recipient snapshot DTOs - [x] define immutable recipient snapshot DTOs
- [x] expose source provenance in capability responses - [x] expose source provenance in capability responses
- [x] expose classical address lists as `addresses.recipient_source` sources - [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] add module presence/capability tests
- [x] document consumer rules for campaign, mail, scheduling, portal, postbox, and - [x] document consumer rules for campaign, mail, scheduling, portal, postbox, and
reporting reporting
@@ -82,6 +87,8 @@ Exit criteria:
- [x] campaign can request a recipient source via core-mediated capability - [x] campaign can request a recipient source via core-mediated capability
- [x] mail/scheduling can request autocomplete candidates via core-mediated lookup - [x] mail/scheduling can request autocomplete candidates via core-mediated lookup
- [x] consumers do not import `govoplan_addresses` - [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 ## Milestone 4: Campaign Integration
@@ -212,16 +219,22 @@ Primary issues: `govoplan-addresses#8`, `govoplan-addresses#9`,
Tasks: Tasks:
- LDAP/Active Directory read-only directory connector - [ ] [LDAP/Active Directory read-only directory connector](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/15)
- Exchange/Microsoft 365 contacts connector - [ ] [Microsoft Graph contacts connector for Microsoft 365](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/16)
- Google Contacts connector - [ ] [On-premises Exchange connector profile](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/17)
- CSV/XLSX/LDIF import mapping profiles - [ ] [Google People contacts connector](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/18)
- classical address-list UI and static/dynamic address-domain segments - [ ] [Reusable CSV/XLSX import mapping profiles](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/19)
- operational distribution lists move to `govoplan-dist-lists` - [x] [Bounded LDIF import profile](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/20)
- consent, legal-basis, suppression, and communication preferences - [ ] [Selective and large-batch vCard workflows](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/21)
- deduplication and merge workflow - [x] classical address-list UI; reusable static/dynamic operational segments move
- address quality checks and normalization to `govoplan-dist-lists`
- relationship/household/organization editing - [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: Exit criteria:
@@ -229,6 +242,11 @@ Exit criteria:
- users can understand where data came from and whether they may edit it - users can understand where data came from and whether they may edit it
- downstream modules can safely use contacts without owning them - 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 ## First Implementation Recommendation
Start with Milestone 1 and enough of Milestone 2 to define the data model Start with Milestone 1 and enough of Milestone 2 to define the data model
+43
View File
@@ -0,0 +1,43 @@
# 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.
- vCard batch upload accepts multiple files, persists a non-mutating preview,
and requires an explicit create/update/ignore choice for every reviewed card.
Reload and cancellation preserve the pending plan; only applying the matching
plan hash mutates contacts. Scoped exports name the selected vCard version and
use deterministic ordering.
- 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.
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/addresses-webui", "name": "@govoplan/addresses-webui",
"version": "0.1.9", "version": "0.1.21",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,11 +18,11 @@
"README.md" "README.md"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.11", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1" "react-router": ">=8.3.0 <9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
+4 -2
View File
@@ -4,14 +4,16 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-addresses" name = "govoplan-addresses"
version = "0.1.9" version = "0.1.21"
description = "GovOPlaN reusable address and recipient-source module." description = "GovOPlaN reusable address and recipient-source module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"defusedxml>=0.7.1", "defusedxml>=0.7.1",
"govoplan-core>=0.1.11", "govoplan-core>=0.1.18",
"ldap3>=2.9.1,<3",
"openpyxl>=3.1.5,<4",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
File diff suppressed because it is too large Load Diff
+304 -1
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, text from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin from govoplan_core.db.base import Base, TimestampMixin
@@ -56,6 +56,11 @@ class Contact(Base, TimestampMixin):
__table_args__ = ( __table_args__ = (
Index("ix_addresses_contacts_book_name", "address_book_id", "display_name"), 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_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) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
@@ -92,6 +97,21 @@ class Contact(Base, TimestampMixin):
order_by="ContactPostalAddress.order_index", order_by="ContactPostalAddress.order_index",
) )
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact", cascade="all, delete-orphan") 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): class ContactEmail(Base, TimestampMixin):
@@ -105,6 +125,9 @@ class ContactEmail(Base, TimestampMixin):
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True) contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
label: Mapped[str | None] = mapped_column(String(80)) label: Mapped[str | None] = mapped_column(String(80))
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True) 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) is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False) order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
@@ -120,6 +143,9 @@ class ContactPhone(Base, TimestampMixin):
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True) contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
label: Mapped[str | None] = mapped_column(String(80)) label: Mapped[str | None] = mapped_column(String(80))
phone: Mapped[str] = mapped_column(String(100), nullable=False) 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) is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False) order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
@@ -138,6 +164,9 @@ class ContactPostalAddress(Base, TimestampMixin):
locality: Mapped[str | None] = mapped_column(String(255)) locality: Mapped[str | None] = mapped_column(String(255))
region: Mapped[str | None] = mapped_column(String(255)) region: Mapped[str | None] = mapped_column(String(255))
country: 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) is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False) order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
@@ -145,6 +174,213 @@ class ContactPostalAddress(Base, TimestampMixin):
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_postal_address") 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): class AddressList(Base, TimestampMixin):
__tablename__ = "addresses_address_lists" __tablename__ = "addresses_address_lists"
__table_args__ = ( __table_args__ = (
@@ -314,8 +550,70 @@ class AddressSyncDiagnostic(Base, TimestampMixin):
sync_source: Mapped[AddressSyncSource] = relationship(back_populates="diagnostics") 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 | None] = mapped_column(
ForeignKey("addresses_import_profiles.id", ondelete="RESTRICT"),
nullable=True,
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 | None] = relationship()
__all__ = [ __all__ = [
"AddressBook", "AddressBook",
"AddressImportProfile",
"AddressImportRun",
"AddressList", "AddressList",
"AddressListEntry", "AddressListEntry",
"AddressSyncConflict", "AddressSyncConflict",
@@ -325,6 +623,11 @@ __all__ = [
"Contact", "Contact",
"ContactEmail", "ContactEmail",
"ContactPhone", "ContactPhone",
"ContactFieldProvenance",
"ContactMergeRecord",
"ContactPointQualityDecision",
"ContactPointSnapshot",
"ContactPostalAddress", "ContactPostalAddress",
"ContactRedirect",
"new_uuid", "new_uuid",
] ]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'addresses.reference.fields-and-consequences': {'consequence_classes': {'archive': 'Entfernt das '
'Objekt aus '
'der '
'gewöhnlichen '
'Auswahl, '
'während die '
'verwaltete '
'Geschichte '
'und '
'Referenzen '
'beibehalten '
'werden.',
'governance_fact': 'Hinzufügen '
'oder '
'Beenden '
'einer '
'effektiv '
'datierten '
'Kommunikationsentscheidung '
'ohne '
'vorherige '
'Fakten '
'zu '
'löschen.',
'import_or_sync': 'Wendet '
'nur '
'einen '
'überprüften '
'Bounded '
'Plan '
'an und '
'behält '
'die '
'Quellenrevision, '
'Diagnose '
'und '
'Herkunft '
'bei.',
'merge': 'Repoints '
'verwaltet '
'Verweise auf '
'einen '
'Überlebenden '
'und behält '
'reversible '
'Redirect und '
'Provenienz '
'Nachweise.'}}}
@@ -0,0 +1,174 @@
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", "ldif"]
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"
ldif_change_record_policy: Literal["reject", "ignore", "treat_add_as_entry"] = "reject"
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 | None
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):
expected_plan_hash: str = Field(min_length=64, max_length=64)
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",
]
File diff suppressed because it is too large Load Diff
+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",
]
+286
View File
@@ -0,0 +1,286 @@
from __future__ import annotations
import base64
import binascii
import hashlib
from collections.abc import Iterable
from io import BytesIO
from typing import Any, Literal
LdifChangeRecordPolicy = Literal["reject", "ignore", "treat_add_as_entry"]
MAX_LDIF_ATTRIBUTES = 500
MAX_LDIF_VALUES_PER_ATTRIBUTE = 100
MAX_LDIF_LOGICAL_LINE_BYTES = 1_000_000
def parse_ldif_rows(
raw: bytes,
*,
max_entries: int,
change_record_policy: LdifChangeRecordPolicy = "reject",
) -> tuple[list[tuple[int, dict[str, Any]]], list[dict[str, Any]]]:
"""Parse bounded LDIF entries without fetching URL or decoding binary values."""
rows: list[tuple[int, dict[str, Any]]] = []
diagnostics: list[dict[str, Any]] = []
record_lines: list[tuple[int, bytes]] = []
def finish_record() -> None:
if not record_lines:
return
row_number = record_lines[0][0]
row, record_diagnostics = _parse_record(record_lines)
diagnostics.extend(record_diagnostics)
record_lines.clear()
if not row:
return
if set(row) == {"version"} and _first(row.get("version")) == "1":
return
change_type = _first(row.get("changetype")).casefold()
if change_type:
if change_record_policy == "ignore":
diagnostics.append(
_diagnostic(
"warning",
"ldif_change_record_ignored",
f"LDIF change record {change_type!r} was ignored by profile policy.",
row_number=row_number,
field="changetype",
)
)
return
if change_record_policy != "treat_add_as_entry" or change_type != "add":
diagnostics.append(
_diagnostic(
"error",
"ldif_change_record_rejected",
f"LDIF change record {change_type!r} is not permitted by the profile policy.",
row_number=row_number,
field="changetype",
)
)
return
diagnostics.append(
_diagnostic(
"info",
"ldif_add_record_imported",
"LDIF add change record is treated as a static contact entry by profile policy.",
row_number=row_number,
field="changetype",
)
)
row.pop("changetype", None)
row["__ldif_record_hash"] = hashlib.sha256(_canonical_record(row)).hexdigest()
rows.append((row_number, row))
if len(rows) > max_entries:
raise ValueError(f"LDIF exceeds the configured {max_entries}-entry limit.")
for line_number, logical_line in _logical_lines(raw):
if not logical_line:
finish_record()
continue
if logical_line.startswith(b"#"):
continue
record_lines.append((line_number, logical_line))
finish_record()
if not rows and not any(item["severity"] == "error" for item in diagnostics):
diagnostics.append(_diagnostic("warning", "ldif_no_entries", "No importable LDIF entries were found."))
return rows, diagnostics
def _logical_lines(raw: bytes) -> Iterable[tuple[int, bytes]]:
current: bytearray | None = None
start_line = 0
for line_number, physical_with_ending in enumerate(BytesIO(raw), start=1):
physical = physical_with_ending.rstrip(b"\r\n")
if physical.startswith(b" "):
if current is None:
yield line_number, b"!invalid-fold-without-preceding-line"
continue
current.extend(physical[1:])
if len(current) > MAX_LDIF_LOGICAL_LINE_BYTES:
raise ValueError(f"LDIF logical line starting at {start_line} exceeds the size limit.")
continue
if current is not None:
yield start_line, bytes(current)
current = bytearray(physical)
start_line = line_number
if len(current) > MAX_LDIF_LOGICAL_LINE_BYTES:
raise ValueError(f"LDIF logical line {line_number} exceeds the size limit.")
if current is not None:
yield start_line, bytes(current)
def _parse_record(
lines: list[tuple[int, bytes]],
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
row: dict[str, list[str]] = {}
diagnostics: list[dict[str, Any]] = []
for line_number, line in lines:
if line == b"!invalid-fold-without-preceding-line":
diagnostics.append(
_diagnostic(
"error",
"ldif_invalid_fold",
"LDIF continuation line has no preceding attribute.",
row_number=line_number,
)
)
continue
if b":" not in line:
diagnostics.append(
_diagnostic(
"error",
"ldif_invalid_line",
"LDIF line is missing the attribute separator.",
row_number=line_number,
)
)
continue
if line == b"-":
# Attribute-operation separators are meaningful only inside change
# records, whose enclosing policy is evaluated after the record.
continue
raw_name, raw_value = line.split(b":", 1)
try:
name_parts = [part.strip().casefold() for part in raw_name.decode("ascii").split(";")]
name = name_parts[0]
except UnicodeDecodeError:
name_parts = []
name = ""
if not name or any(character.isspace() for character in name):
diagnostics.append(
_diagnostic(
"error",
"ldif_invalid_attribute",
"LDIF attribute name is invalid.",
row_number=line_number,
)
)
continue
if "binary" in name_parts[1:]:
diagnostics.append(
_diagnostic(
"warning",
"ldif_binary_value_ignored",
f"Binary LDIF attribute {name!r} was ignored; binary data is never projected into contacts.",
row_number=line_number,
field=name,
)
)
continue
if name not in row and len(row) >= MAX_LDIF_ATTRIBUTES:
diagnostics.append(
_diagnostic(
"error",
"ldif_too_many_attributes",
f"LDIF entry exceeds the {MAX_LDIF_ATTRIBUTES}-attribute limit.",
row_number=line_number,
)
)
continue
value, value_diagnostic = _decode_value(raw_value, attribute=name, line_number=line_number)
if value_diagnostic is not None:
diagnostics.append(value_diagnostic)
if value is None:
continue
values = row.setdefault(name, [])
if len(values) >= MAX_LDIF_VALUES_PER_ATTRIBUTE:
diagnostics.append(
_diagnostic(
"error",
"ldif_too_many_values",
f"LDIF attribute {name!r} exceeds the value limit.",
row_number=line_number,
field=name,
)
)
continue
values.append(value)
return row, diagnostics
def _decode_value(
raw_value: bytes,
*,
attribute: str,
line_number: int,
) -> tuple[str | None, dict[str, Any] | None]:
if raw_value.startswith(b":"):
encoded = raw_value[1:].lstrip(b" ")
try:
decoded = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
return None, _diagnostic(
"error",
"ldif_invalid_base64",
f"LDIF attribute {attribute!r} contains invalid base64.",
row_number=line_number,
field=attribute,
)
try:
return decoded.decode("utf-8"), None
except UnicodeDecodeError:
return None, _diagnostic(
"warning",
"ldif_binary_value_ignored",
f"Binary LDIF attribute {attribute!r} was ignored; binary data is never projected into contacts.",
row_number=line_number,
field=attribute,
)
if raw_value.startswith(b"<"):
return None, _diagnostic(
"warning",
"ldif_url_value_ignored",
f"External LDIF URL value for {attribute!r} was ignored; imports never fetch referenced content.",
row_number=line_number,
field=attribute,
)
value_bytes = raw_value[1:] if raw_value.startswith(b" ") else raw_value
try:
return value_bytes.decode("utf-8"), None
except UnicodeDecodeError:
return None, _diagnostic(
"error",
"ldif_invalid_utf8",
f"LDIF attribute {attribute!r} is not valid UTF-8.",
row_number=line_number,
field=attribute,
)
def _canonical_record(row: dict[str, Any]) -> bytes:
lines = []
for name in sorted(key for key in row if not key.startswith("__")):
values = row[name] if isinstance(row[name], list) else [row[name]]
lines.extend(f"{name}:{value}" for value in values)
return "\n".join(lines).encode("utf-8")
def _first(value: object) -> str:
if isinstance(value, list):
return str(value[0]) if value else ""
return str(value or "")
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": {},
}
__all__ = ["LdifChangeRecordPolicy", "parse_ldif_rows"]
File diff suppressed because it is too large Load Diff
@@ -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,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,34 @@
"""Allow profile-free persisted vCard batch runs.
Revision ID: d6e8f9a0b1c2
Revises: c5d7e8f9a0b1
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d6e8f9a0b1c2"
down_revision = "c5d7e8f9a0b1"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("addresses_import_runs") as batch:
batch.alter_column(
"profile_id",
existing_type=sa.String(length=36),
nullable=True,
)
def downgrade() -> None:
with op.batch_alter_table("addresses_import_runs") as batch:
batch.alter_column(
"profile_id",
existing_type=sa.String(length=36),
nullable=False,
)
@@ -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
+396
View File
@@ -14,6 +14,44 @@ AddressSyncConflictStatus = Literal["open", "resolved", "ignored"]
AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "manual", "ignored"] AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "manual", "ignored"]
AddressCardDavAuthType = Literal["none", "basic", "bearer"] AddressCardDavAuthType = Literal["none", "basic", "bearer"]
AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"] 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): class ContactEmailPayload(BaseModel):
@@ -162,12 +200,75 @@ class ContactUpdateRequest(BaseModel):
provenance: dict[str, Any] | 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): class ContactEmailResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: str id: str
label: str | None = None label: str | None = None
email: str 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 is_primary: bool
@@ -177,6 +278,11 @@ class ContactPhoneResponse(BaseModel):
id: str id: str
label: str | None = None label: str | None = None
phone: str 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 is_primary: bool
@@ -190,6 +296,11 @@ class ContactPostalAddressResponse(BaseModel):
locality: str | None = None locality: str | None = None
region: str | None = None region: str | None = None
country: 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 is_primary: bool
@@ -214,6 +325,7 @@ class ContactResponse(BaseModel):
emails: list[ContactEmailResponse] emails: list[ContactEmailResponse]
phones: list[ContactPhoneResponse] phones: list[ContactPhoneResponse]
postal_addresses: list[ContactPostalAddressResponse] postal_addresses: list[ContactPostalAddressResponse]
field_provenance: list[ContactFieldProvenanceResponse] = Field(default_factory=list)
deleted_at: datetime | None = None deleted_at: datetime | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -221,6 +333,270 @@ class ContactResponse(BaseModel):
class ContactListResponse(BaseModel): class ContactListResponse(BaseModel):
contacts: list[ContactResponse] 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): class AddressLookupResponse(BaseModel):
@@ -439,6 +815,26 @@ class AddressCardDavDiscoveryResponse(BaseModel):
address_books: list[AddressCardDavAddressBookResponse] 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): class AddressCardDavSourceCreateRequest(BaseModel):
collection_url: str = Field(min_length=1, max_length=2000) collection_url: str = Field(min_length=1, max_length=2000)
display_name: str | None = Field(default=None, max_length=255) display_name: str | None = Field(default=None, max_length=255)
File diff suppressed because it is too large Load Diff
+83 -36
View File
@@ -16,6 +16,12 @@ class VCardError(ValueError):
pass pass
VCARD_PARSER_VERSION = "govoplan-vcard/2"
MAX_VCARD_CARDS = 10_000
MAX_VCARD_LINES = 200_000
MAX_VCARD_UNFOLDED_LINE_CHARS = 16_384
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ParsedVCard: class ParsedVCard:
payload: ContactCreateRequest payload: ContactCreateRequest
@@ -60,12 +66,16 @@ class _VCardDraft:
def _normalize_lines(content: str) -> list[str]: def _normalize_lines(content: str) -> list[str]:
raw_lines = content.replace("\r\n", "\n").replace("\r", "\n").split("\n") raw_lines = content.replace("\r\n", "\n").replace("\r", "\n").split("\n")
if len(raw_lines) > MAX_VCARD_LINES:
raise VCardError(f"vCard input exceeds the {MAX_VCARD_LINES}-line parser limit.")
lines: list[str] = [] lines: list[str] = []
for line in raw_lines: for line in raw_lines:
if line.startswith((" ", "\t")) and lines: if line.startswith((" ", "\t")) and lines:
lines[-1] += line[1:] lines[-1] += line[1:]
elif line: elif line:
lines.append(line) lines.append(line)
if lines and len(lines[-1]) > MAX_VCARD_UNFOLDED_LINE_CHARS:
raise VCardError(f"vCard unfolded lines are limited to {MAX_VCARD_UNFOLDED_LINE_CHARS} characters.")
return lines return lines
@@ -90,27 +100,13 @@ def _split_unescaped(value: str, separator: str) -> list[str]:
def _unescape_text(value: str) -> str: def _unescape_text(value: str) -> str:
return ( return value.replace("\\n", "\n").replace("\\N", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\").strip()
value.replace("\\n", "\n")
.replace("\\N", "\n")
.replace("\\,", ",")
.replace("\\;", ";")
.replace("\\\\", "\\")
.strip()
)
def _escape_text(value: str | None) -> str: def _escape_text(value: str | None) -> str:
if value is None: if value is None:
return "" return ""
return ( return value.replace("\\", "\\\\").replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\\n").replace(";", "\\;").replace(",", "\\,")
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]]]: def _parse_head(head: str) -> tuple[str, dict[str, list[str]]]:
@@ -154,7 +150,7 @@ def _is_pref(params: dict[str, list[str]]) -> bool:
def _card_blocks(content: str) -> list[list[str]]: def _card_blocks(content: str) -> list[list[str]]:
result = _card_blocks_with_issues(content) result = _card_blocks_with_issues(content, max_cards=MAX_VCARD_CARDS)
if result.issues: if result.issues:
raise VCardError(result.issues[0].message) raise VCardError(result.issues[0].message)
return result.cards return result.cards
@@ -166,8 +162,14 @@ class _CardBlockResult:
issues: list[ParsedVCardIssue] issues: list[ParsedVCardIssue]
def _card_blocks_with_issues(content: str) -> _CardBlockResult: def _card_blocks_with_issues(content: str, *, max_cards: int) -> _CardBlockResult:
lines = _normalize_lines(content) try:
lines = _normalize_lines(content)
except VCardError as exc:
return _CardBlockResult(
cards=[],
issues=[ParsedVCardIssue(index=0, message=str(exc))],
)
blocks: list[list[str]] = [] blocks: list[list[str]] = []
issues: list[ParsedVCardIssue] = [] issues: list[ParsedVCardIssue] = []
current: list[str] | None = None current: list[str] | None = None
@@ -189,6 +191,15 @@ def _card_blocks_with_issues(content: str) -> _CardBlockResult:
issues.append(ParsedVCardIssue(index=card_index, message="vCard END appears before BEGIN.")) issues.append(ParsedVCardIssue(index=card_index, message="vCard END appears before BEGIN."))
continue continue
current.append(line) current.append(line)
if len(blocks) >= max_cards:
issues.append(
ParsedVCardIssue(
index=len(blocks) + 1,
message=f"vCard input exceeds the configured {max_cards}-card limit.",
)
)
current = None
break
blocks.append(current) blocks.append(current)
current = None current = None
elif current is not None: elif current is not None:
@@ -258,7 +269,14 @@ def _apply_card_metadata(
if name == "VERSION": if name == "VERSION":
draft.version = value.strip() draft.version = value.strip()
if draft.version and draft.version not in {"3.0", "4.0"}: 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.")) issues.append(
ParsedVCardIssue(
index=index,
severity="warning",
field="VERSION",
message=f"vCard version {draft.version} is not fully supported.",
)
)
return True return True
if name == "UID": if name == "UID":
draft.uid = _unescape_text(value) or draft.uid draft.uid = _unescape_text(value) or draft.uid
@@ -325,15 +343,34 @@ def _append_card_email(
if not email: if not email:
return return
if "@" not in email: if "@" not in email:
issues.append(ParsedVCardIssue(index=index, severity="warning", field="EMAIL", message=f"Skipped invalid email address: {email}")) issues.append(
ParsedVCardIssue(
index=index,
severity="warning",
field="EMAIL",
message=f"Skipped invalid email address: {email}",
)
)
return return
draft.emails.append(ContactEmailPayload(label=_label_from_params(params), email=email, is_primary=_is_pref(params) or not draft.emails)) 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: def _append_card_phone(draft: _VCardDraft, params: dict[str, list[str]], value: str) -> None:
phone = _unescape_text(value) phone = _unescape_text(value)
if phone: if phone:
draft.phones.append(ContactPhonePayload(label=_label_from_params(params), phone=phone, is_primary=_is_pref(params) or not draft.phones)) 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: def _append_card_address(draft: _VCardDraft, params: dict[str, list[str]], value: str) -> None:
@@ -388,8 +425,14 @@ def _draft_contact_payload(draft: _VCardDraft) -> ContactCreateRequest:
return payload return payload
def parse_vcards_with_issues(content: str) -> VCardParseResult: def parse_vcards_with_issues(
blocks = _card_blocks_with_issues(content) content: str,
*,
max_cards: int = MAX_VCARD_CARDS,
) -> VCardParseResult:
if max_cards < 1 or max_cards > MAX_VCARD_CARDS:
raise VCardError(f"max_cards must be between 1 and {MAX_VCARD_CARDS}.")
blocks = _card_blocks_with_issues(content, max_cards=max_cards)
parsed: list[ParsedVCard] = [] parsed: list[ParsedVCard] = []
issues = list(blocks.issues) issues = list(blocks.issues)
skipped = 0 skipped = 0
@@ -411,8 +454,8 @@ def parse_vcards(content: str) -> list[ParsedVCard]:
return result.cards return result.cards
def contact_to_vcard(contact: Contact) -> str: def contact_to_vcard(contact: Contact, *, version: Literal["3.0", "4.0"] = "4.0") -> str:
lines = _contact_identity_lines(contact) lines = _contact_identity_lines(contact, version=version)
lines.extend(_contact_email_lines(contact)) lines.extend(_contact_email_lines(contact))
lines.extend(_contact_phone_lines(contact)) lines.extend(_contact_phone_lines(contact))
lines.extend(_contact_address_lines(contact)) lines.extend(_contact_address_lines(contact))
@@ -422,10 +465,14 @@ def contact_to_vcard(contact: Contact) -> str:
return "\r\n".join(lines) + "\r\n" return "\r\n".join(lines) + "\r\n"
def _contact_identity_lines(contact: Contact) -> list[str]: def _contact_identity_lines(
contact: Contact,
*,
version: Literal["3.0", "4.0"],
) -> list[str]:
lines = [ lines = [
"BEGIN:VCARD", "BEGIN:VCARD",
"VERSION:4.0", f"VERSION:{version}",
f"FN:{_escape_text(contact.display_name)}", f"FN:{_escape_text(contact.display_name)}",
f"N:{_escape_text(contact.family_name)};{_escape_text(contact.given_name)};;;", f"N:{_escape_text(contact.family_name)};{_escape_text(contact.given_name)};;;",
] ]
@@ -460,11 +507,7 @@ def _contact_address_lines(contact: Contact) -> list[str]:
lines: list[str] = [] lines: list[str] = []
for address in contact.postal_addresses: for address in contact.postal_addresses:
label = f";TYPE={_escape_text(address.label)}" if address.label else "" label = f";TYPE={_escape_text(address.label)}" if address.label else ""
lines.append( lines.append(f"ADR{label}:;;{_escape_text(address.street)};{_escape_text(address.locality)};{_escape_text(address.region)};{_escape_text(address.postal_code)};{_escape_text(address.country)}")
"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 return lines
@@ -496,5 +539,9 @@ def _contact_vcard_urls(contact: Contact) -> object:
return vcard.get("urls") return vcard.get("urls")
def contacts_to_vcard(contacts: list[Contact]) -> str: def contacts_to_vcard(
return "".join(contact_to_vcard(contact) for contact in contacts) contacts: list[Contact],
*,
version: Literal["3.0", "4.0"] = "4.0",
) -> str:
return "".join(contact_to_vcard(contact, version=version) for contact in contacts)
@@ -0,0 +1,144 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
VCardPlanAction = Literal["create", "update", "ignore", "unchanged", "conflict"]
VCardCommitAction = Literal["create", "update", "ignore"]
class VCardBatchFilePayload(BaseModel):
filename: str = Field(min_length=1, max_length=500)
content_base64: str = Field(min_length=1, max_length=14_000_000)
class VCardBatchPreviewRequest(BaseModel):
files: list[VCardBatchFilePayload] = Field(min_length=1, max_length=50)
duplicate_card_policy: Literal["reject", "first", "last"] = "reject"
existing_contact_policy: Literal["update", "ignore", "reject"] = "update"
class VCardDuplicateSuggestion(BaseModel):
contact_id: str
display_name: str
reasons: list[str] = Field(default_factory=list)
class VCardBatchPlanItemResponse(BaseModel):
source_key: str
source_filename: str
card_index: int
action: VCardPlanAction
allowed_actions: list[VCardCommitAction] = Field(default_factory=list)
contact_id: str | None = None
display_name: str | None = None
changed_fields: list[str] = Field(default_factory=list)
duplicate_suggestions: list[VCardDuplicateSuggestion] = Field(default_factory=list)
message: str | None = None
class VCardBatchDiagnosticResponse(BaseModel):
severity: Literal["info", "warning", "error"]
code: str
message: str
source_filename: str | None = None
card_index: int | None = None
field: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
class VCardBatchProgressResponse(BaseModel):
total: int
completed: int
created: int = 0
updated: int = 0
ignored: int = 0
failed: int = 0
class VCardBatchRunResponse(BaseModel):
id: str
address_book_id: str
status: str
input_hash: str
plan_hash: str
parser_version: str
execution_mode: Literal["bounded_sync", "persisted_batch"]
file_count: int
card_count: int
statistics: dict[str, int | str] = Field(default_factory=dict)
diagnostics: list[VCardBatchDiagnosticResponse] = Field(default_factory=list)
plan: list[VCardBatchPlanItemResponse] = Field(default_factory=list)
progress: VCardBatchProgressResponse
can_apply: bool
can_cancel: bool
commit_hash: str | None = None
created_at: datetime
updated_at: datetime
applied_at: datetime | None = None
class VCardBatchSelection(BaseModel):
source_key: str = Field(min_length=1, max_length=1000)
action: VCardCommitAction
class VCardBatchCommitRequest(BaseModel):
expected_plan_hash: str = Field(min_length=64, max_length=64)
selections: list[VCardBatchSelection] = Field(
default_factory=list, max_length=10_000
)
class VCardBatchCancelRequest(BaseModel):
expected_plan_hash: str = Field(min_length=64, max_length=64)
reason: str = Field(min_length=3, max_length=2000)
class VCardExportRequest(BaseModel):
scope: Literal["address_book", "address_list", "contacts"] = "address_book"
address_list_id: str | None = Field(default=None, max_length=36)
contact_ids: list[str] = Field(default_factory=list, max_length=10_000)
version: Literal["3.0", "4.0"] = "4.0"
@model_validator(mode="after")
def validate_scope(self) -> "VCardExportRequest":
if self.scope == "address_list" and not self.address_list_id:
raise ValueError("Address-list export requires address_list_id.")
if self.scope == "contacts" and not self.contact_ids:
raise ValueError(
"Selected-contact export requires at least one contact id."
)
if self.scope != "address_list" and self.address_list_id:
raise ValueError("address_list_id is only valid for address-list export.")
if self.scope != "contacts" and self.contact_ids:
raise ValueError("contact_ids are only valid for selected-contact export.")
if len(set(self.contact_ids)) != len(self.contact_ids):
raise ValueError("Selected contact ids must be unique.")
return self
class VCardExportResponse(BaseModel):
filename: str
media_type: str = "text/vcard"
scope: str
version: str
ordering: str
contact_count: int
content_hash: str
content: str
__all__ = [
"VCardBatchCancelRequest",
"VCardBatchCommitRequest",
"VCardBatchFilePayload",
"VCardBatchPreviewRequest",
"VCardBatchRunResponse",
"VCardBatchSelection",
"VCardExportRequest",
"VCardExportResponse",
]
@@ -0,0 +1,901 @@
from __future__ import annotations
import base64
import binascii
from collections import Counter, defaultdict
import hashlib
import json
import os
from typing import Any
from sqlalchemy.orm import Session, selectinload
from govoplan_addresses.backend.db.models import (
AddressImportRun,
AddressListEntry,
Contact,
)
from govoplan_addresses.backend.schemas import (
ContactCreateRequest,
ContactUpdateRequest,
)
from govoplan_addresses.backend.service import (
AddressBookError,
create_contact,
get_visible_address_book,
get_visible_address_list,
get_visible_contact,
update_contact,
)
from govoplan_addresses.backend.vcard import (
MAX_VCARD_CARDS,
VCARD_PARSER_VERSION,
contacts_to_vcard,
parse_vcards_with_issues,
)
from govoplan_addresses.backend.vcard_batch_schemas import (
VCardBatchCancelRequest,
VCardBatchCommitRequest,
VCardBatchPreviewRequest,
VCardExportRequest,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.db.base import utcnow
MAX_VCARD_BATCH_BYTES = 10_000_000
DEFAULT_PERSISTED_BATCH_THRESHOLD = 500
def preview_vcard_batch(
session: Session,
principal: ApiPrincipal,
address_book_id: str,
payload: VCardBatchPreviewRequest,
) -> AddressImportRun:
book = get_visible_address_book(session, principal, address_book_id)
if book.read_only:
raise AddressBookError("Static vCard imports require a writable address book.")
decoded = _decode_files(payload)
input_hash = _hash_json(
{
"files": [
{
"filename": filename,
"sha256": hashlib.sha256(raw).hexdigest(),
"size": len(raw),
}
for filename, raw in decoded
]
}
)
parsed_cards, diagnostics = _parse_files(decoded)
plan = _plan_cards(
session,
book.id,
parsed_cards,
duplicate_card_policy=payload.duplicate_card_policy,
existing_contact_policy=payload.existing_contact_policy,
)
statistics: dict[str, int | str] = dict(
Counter(str(item["action"]) for item in plan)
)
statistics.update(
{
"files": len(decoded),
"cards": len(parsed_cards),
"errors": sum(item["severity"] == "error" for item in diagnostics),
"warnings": sum(item["severity"] == "warning" for item in diagnostics),
"parser_version": VCARD_PARSER_VERSION,
"execution_mode": _execution_mode(len(parsed_cards)),
}
)
plan_hash = _hash_json(
{
"address_book_id": book.id,
"input_hash": input_hash,
"parser_version": VCARD_PARSER_VERSION,
"duplicate_card_policy": payload.duplicate_card_policy,
"existing_contact_policy": payload.existing_contact_policy,
"plan": plan,
}
)
source_filename = decoded[0][0]
if len(decoded) > 1:
source_filename = f"{source_filename} (+{len(decoded) - 1} files)"
run = AddressImportRun(
tenant_id=book.tenant_id,
address_book_id=book.id,
profile_id=None,
source_filename=source_filename[:500],
source_format="vcard",
input_hash=input_hash,
plan_hash=plan_hash,
status="previewed",
row_count=len(parsed_cards),
statistics=statistics,
diagnostics=diagnostics,
plan_data=plan,
result_evidence={
"parser_version": VCARD_PARSER_VERSION,
"file_manifest": [
{
"filename": filename,
"sha256": hashlib.sha256(raw).hexdigest(),
"size": len(raw),
}
for filename, raw in decoded
],
"progress": _progress(len(plan)),
},
created_by_account_id=principal.account_id,
)
session.add(run)
session.flush()
return run
def get_vcard_batch_run(
session: Session,
principal: ApiPrincipal,
run_id: str,
) -> AddressImportRun:
book_ids = [book.id for book in _visible_books(session, principal)]
if not book_ids:
raise AddressBookError("vCard batch run not found.")
item = (
session.query(AddressImportRun)
.filter(
AddressImportRun.id == run_id,
AddressImportRun.address_book_id.in_(book_ids),
AddressImportRun.source_format == "vcard",
)
.one_or_none()
)
if item is None:
raise AddressBookError("vCard batch run not found.")
return item
def apply_vcard_batch(
session: Session,
principal: ApiPrincipal,
run_id: str,
payload: VCardBatchCommitRequest,
) -> AddressImportRun:
run = get_vcard_batch_run(session, principal, run_id)
if run.plan_hash != payload.expected_plan_hash:
raise AddressBookError("The reviewed vCard plan changed; create a new preview.")
selections = _selection_map(payload)
commit_hash = _hash_json(
{
"plan_hash": run.plan_hash,
"selections": [
{"source_key": key, "action": selections[key]}
for key in sorted(selections)
],
}
)
evidence = dict(run.result_evidence or {})
if run.status == "applied":
if evidence.get("commit_hash") != commit_hash:
raise AddressBookError(
"This vCard batch was already applied with a different selection."
)
return run
if run.status != "previewed":
raise AddressBookError(
f"vCard batch cannot be applied from status {run.status!r}."
)
if not selections:
raise AddressBookError(
"Select at least one vCard action before applying the batch."
)
plan_by_key = {str(item["source_key"]): item for item in run.plan_data or []}
unknown = sorted(set(selections).difference(plan_by_key))
if unknown:
raise AddressBookError(
"The selection contains cards that are not part of the reviewed plan."
)
created_ids: list[str] = []
updated_ids: list[str] = []
ignored = 0
for source_key in sorted(plan_by_key):
item = plan_by_key[source_key]
action = selections.get(source_key, "ignore")
allowed = set(item.get("allowed_actions") or [])
if action not in allowed:
raise AddressBookError(
f'Action {action!r} is not allowed for vCard "{item.get("display_name") or source_key}".'
)
if action == "ignore":
ignored += 1
continue
contact = _apply_plan_item(
session,
principal,
run=run,
item=item,
action=action,
)
if action == "create":
created_ids.append(contact.id)
else:
updated_ids.append(contact.id)
run.status = "applied"
run.applied_at = utcnow()
run.result_evidence = {
**evidence,
"commit_hash": commit_hash,
"selection_count": len(selections),
"created_contact_ids": created_ids,
"updated_contact_ids": updated_ids,
"ignored_count": ignored,
"applied_by_account_id": principal.account_id,
"applied_at": run.applied_at.isoformat(),
"progress": {
"total": len(plan_by_key),
"completed": len(plan_by_key),
"created": len(created_ids),
"updated": len(updated_ids),
"ignored": ignored,
"failed": 0,
},
}
run.statistics = {
**dict(run.statistics or {}),
"applied_create": len(created_ids),
"applied_update": len(updated_ids),
"applied_ignore": ignored,
}
return run
def cancel_vcard_batch(
session: Session,
principal: ApiPrincipal,
run_id: str,
payload: VCardBatchCancelRequest,
) -> AddressImportRun:
run = get_vcard_batch_run(session, principal, run_id)
if run.plan_hash != payload.expected_plan_hash:
raise AddressBookError("The reviewed vCard plan changed; reload the batch.")
if run.status == "cancelled":
return run
if run.status != "previewed":
raise AddressBookError("Only a previewed vCard batch can be cancelled.")
run.status = "cancelled"
run.result_evidence = {
**dict(run.result_evidence or {}),
"cancel_reason": payload.reason.strip(),
"cancelled_by_account_id": principal.account_id,
"cancelled_at": utcnow().isoformat(),
}
return run
def vcard_batch_payload(run: AddressImportRun) -> dict[str, Any]:
evidence = dict(run.result_evidence or {})
progress = dict(evidence.get("progress") or _progress(run.row_count))
return {
"id": run.id,
"address_book_id": run.address_book_id,
"status": run.status,
"input_hash": run.input_hash,
"plan_hash": run.plan_hash,
"parser_version": str(evidence.get("parser_version") or VCARD_PARSER_VERSION),
"execution_mode": str(
(run.statistics or {}).get("execution_mode") or "bounded_sync"
),
"file_count": int((run.statistics or {}).get("files") or 0),
"card_count": run.row_count,
"statistics": dict(run.statistics or {}),
"diagnostics": list(run.diagnostics or []),
"plan": [_public_plan_item(item) for item in run.plan_data or []],
"progress": progress,
"can_apply": run.status == "previewed" and bool(run.plan_data),
"can_cancel": run.status == "previewed",
"commit_hash": evidence.get("commit_hash"),
"created_at": run.created_at,
"updated_at": run.updated_at,
"applied_at": run.applied_at,
}
def vcard_diagnostics_payload(run: AddressImportRun) -> dict[str, Any]:
return {
"schema_version": "1.0",
"run_id": run.id,
"status": run.status,
"input_hash": run.input_hash,
"plan_hash": run.plan_hash,
"parser_version": (run.result_evidence or {}).get("parser_version"),
"statistics": dict(run.statistics or {}),
"diagnostics": list(run.diagnostics or []),
"effects": [_public_plan_item(item) for item in run.plan_data or []],
}
def export_vcards(
session: Session,
principal: ApiPrincipal,
address_book_id: str,
payload: VCardExportRequest,
) -> dict[str, Any]:
book = get_visible_address_book(session, principal, address_book_id)
contacts = _export_contacts(session, principal, book.id, payload)
contacts.sort(key=lambda item: (item.display_name.casefold(), item.id))
content = contacts_to_vcard(contacts, version=payload.version)
scope_label = {
"address_book": book.name,
"address_list": "address-list",
"contacts": "selected-contacts",
}[payload.scope]
return {
"filename": f"{_safe_filename(scope_label)}-{payload.version.replace('.', '')}.vcf",
"media_type": "text/vcard",
"scope": payload.scope,
"version": payload.version,
"ordering": "display_name_casefold_then_contact_id",
"contact_count": len(contacts),
"content_hash": hashlib.sha256(content.encode("utf-8")).hexdigest(),
"content": content,
}
def _decode_files(payload: VCardBatchPreviewRequest) -> list[tuple[str, bytes]]:
decoded: list[tuple[str, bytes]] = []
total = 0
for item in payload.files:
filename = item.filename.strip()
if not filename.casefold().endswith(".vcf"):
raise AddressBookError("vCard batch uploads accept only .vcf files.")
try:
raw = base64.b64decode(item.content_base64, validate=True)
except (binascii.Error, ValueError) as exc:
raise AddressBookError(
f'vCard file "{filename}" is not valid base64.'
) from exc
if not raw:
raise AddressBookError(f'vCard file "{filename}" is empty.')
total += len(raw)
if total > MAX_VCARD_BATCH_BYTES:
raise AddressBookError(
f"Combined vCard uploads are limited to {MAX_VCARD_BATCH_BYTES} bytes."
)
decoded.append((filename, raw))
return decoded
def _parse_files(
decoded: list[tuple[str, bytes]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
cards: list[dict[str, Any]] = []
diagnostics: list[dict[str, Any]] = []
for file_index, (filename, raw) in enumerate(decoded):
try:
content = raw.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise AddressBookError(
f'vCard file "{filename}" is not valid UTF-8: {exc}.'
) from exc
remaining = MAX_VCARD_CARDS - len(cards)
if remaining < 1:
raise AddressBookError(
f"vCard batches are limited to {MAX_VCARD_CARDS} cards."
)
result = parse_vcards_with_issues(content, max_cards=remaining)
for issue in result.issues:
diagnostics.append(
{
"severity": issue.severity,
"code": "vcard_parse_error"
if issue.severity == "error"
else "vcard_parse_warning",
"message": issue.message,
"source_filename": filename,
"card_index": issue.index or None,
"field": issue.field,
"details": {"line": issue.line} if issue.line is not None else {},
}
)
for card_index, parsed in enumerate(result.cards, start=1):
raw_hash = hashlib.sha256(parsed.raw.encode("utf-8")).hexdigest()
identity = (
f"uid:{parsed.source_ref.strip()}"
if parsed.source_ref and parsed.source_ref.strip()
else f"sha256:{raw_hash}"
)
source_key = hashlib.sha256(
f"{file_index}:{filename}:{card_index}:{raw_hash}".encode("utf-8")
).hexdigest()
cards.append(
{
"source_key": source_key,
"source_identity": identity,
"source_filename": filename,
"card_index": card_index,
"raw": parsed.raw,
"source_ref": parsed.source_ref.strip()
if parsed.source_ref
else None,
"source_revision": parsed.source_revision.strip()
if parsed.source_revision
else None,
"payload": parsed.payload.model_dump(mode="json"),
}
)
return cards, diagnostics
def _plan_cards(
session: Session,
address_book_id: str,
cards: list[dict[str, Any]],
*,
duplicate_card_policy: str,
existing_contact_policy: str,
) -> list[dict[str, Any]]:
contacts = (
session.query(Contact)
.options(
selectinload(Contact.emails),
selectinload(Contact.phones),
selectinload(Contact.postal_addresses),
)
.filter(
Contact.address_book_id == address_book_id, Contact.deleted_at.is_(None)
)
.all()
)
by_source: dict[str, list[Contact]] = defaultdict(list)
by_email: dict[str, list[Contact]] = defaultdict(list)
for contact in contacts:
if contact.source_ref:
by_source[contact.source_ref.strip()].append(contact)
for email in contact.emails:
normalized = (email.normalized_email or email.email).strip().casefold()
if normalized:
by_email[normalized].append(contact)
identity_positions: dict[str, list[int]] = defaultdict(list)
for index, card in enumerate(cards):
identity_positions[str(card["source_identity"])].append(index)
result: list[dict[str, Any]] = []
for index, card in enumerate(cards):
positions = identity_positions[str(card["source_identity"])]
if len(positions) > 1:
chosen = positions[0] if duplicate_card_policy == "first" else positions[-1]
if duplicate_card_policy == "reject":
result.append(
_planned_card(
card,
action="conflict",
allowed=["ignore"],
message="Duplicate UID or identical card appears in this batch.",
)
)
continue
if index != chosen:
result.append(
_planned_card(
card,
action="ignore",
allowed=["ignore"],
message=f"Duplicate card ignored by {duplicate_card_policy} policy.",
)
)
continue
suggestions = _duplicate_candidates(
card, by_source=by_source, by_email=by_email
)
exact_source = [item for item in suggestions if "source_uid" in item["reasons"]]
candidates = exact_source or suggestions
if len(candidates) > 1:
result.append(
_planned_card(
card,
action="conflict",
allowed=["create", "ignore"],
suggestions=suggestions,
message="Multiple existing contacts match this card; create explicitly or ignore it.",
)
)
continue
existing = next(
(
contact
for contact in contacts
if candidates and contact.id == candidates[0]["contact_id"]
),
None,
)
if existing is None:
result.append(
_planned_card(
card,
action="create",
allowed=["create", "ignore"],
suggestions=suggestions,
)
)
continue
changed = _changed_fields(existing, card["payload"])
if not changed:
result.append(
_planned_card(
card,
action="unchanged",
allowed=["ignore"],
contact=existing,
suggestions=suggestions,
message="Existing contact already matches the parsed card.",
)
)
elif existing_contact_policy == "reject":
result.append(
_planned_card(
card,
action="conflict",
allowed=["ignore"],
contact=existing,
suggestions=suggestions,
changed=changed,
message="An existing contact matches and the preview policy rejects updates.",
)
)
elif existing_contact_policy == "ignore":
result.append(
_planned_card(
card,
action="ignore",
allowed=["update", "ignore"],
contact=existing,
suggestions=suggestions,
changed=changed,
message="Existing contact is ignored by preview policy.",
)
)
else:
result.append(
_planned_card(
card,
action="update",
allowed=["update", "ignore"],
contact=existing,
suggestions=suggestions,
changed=changed,
)
)
return result
def _planned_card(
card: dict[str, Any],
*,
action: str,
allowed: list[str],
contact: Contact | None = None,
suggestions: list[dict[str, Any]] | None = None,
changed: list[str] | None = None,
message: str | None = None,
) -> dict[str, Any]:
return {
**card,
"row_number": int(card["card_index"]),
"action": action,
"allowed_actions": allowed,
"contact_id": contact.id if contact is not None else None,
"expected_contact_hash": _contact_hash(contact)
if contact is not None
else None,
"display_name": card["payload"].get("display_name"),
"changed_fields": changed or [],
"duplicate_suggestions": suggestions or [],
"message": message,
}
def _duplicate_candidates(
card: dict[str, Any],
*,
by_source: dict[str, list[Contact]],
by_email: dict[str, list[Contact]],
) -> list[dict[str, Any]]:
reasons: dict[str, set[str]] = defaultdict(set)
contacts: dict[str, Contact] = {}
source_ref = card.get("source_ref")
if source_ref:
for contact in by_source.get(str(source_ref), []):
contacts[contact.id] = contact
reasons[contact.id].add("source_uid")
for item in card["payload"].get("emails") or []:
normalized = str(item.get("email") or "").strip().casefold()
for contact in by_email.get(normalized, []):
contacts[contact.id] = contact
reasons[contact.id].add("email")
return [
{
"contact_id": contact_id,
"display_name": contacts[contact_id].display_name,
"reasons": sorted(reasons[contact_id]),
}
for contact_id in sorted(
contacts, key=lambda item: (contacts[item].display_name.casefold(), item)
)[:5]
]
def _apply_plan_item(
session: Session,
principal: ApiPrincipal,
*,
run: AddressImportRun,
item: dict[str, Any],
action: str,
) -> Contact:
contact_payload = ContactCreateRequest.model_validate(item["payload"])
if action == "create":
if item.get("source_ref"):
appeared = (
session.query(Contact)
.filter(
Contact.address_book_id == run.address_book_id,
Contact.source_ref == item["source_ref"],
Contact.deleted_at.is_(None),
)
.first()
)
if appeared is not None:
raise AddressBookError(
"A matching vCard UID appeared after preview; preview the batch again."
)
contact = create_contact(
session, principal, run.address_book_id, contact_payload
)
else:
contact_id = str(item.get("contact_id") or "")
if not contact_id:
raise AddressBookError(
"The reviewed vCard update has no stable target contact."
)
current = get_visible_contact(session, principal, contact_id)
if _contact_hash(current) != item.get("expected_contact_hash"):
raise AddressBookError(
f'Contact "{current.display_name}" changed after preview; preview the batch again.'
)
contact = update_contact(
session,
principal,
current.id,
ContactUpdateRequest.model_validate(item["payload"]),
)
contact.source_kind = "vcard"
contact.source_ref = (
item.get("source_ref")
or f"vcard-sha256:{str(item['source_identity']).split(':', 1)[-1]}"
)
contact.source_payload_kind = "vcard"
contact.source_payload_raw = item["raw"]
contact.source_revision = item.get("source_revision")
provenance = dict(contact.provenance or {})
provenance["vcard_batch"] = {
"run_id": run.id,
"input_hash": run.input_hash,
"plan_hash": run.plan_hash,
"parser_version": VCARD_PARSER_VERSION,
"source_filename": item["source_filename"],
"card_index": item["card_index"],
}
contact.provenance = provenance
session.flush()
return contact
def _changed_fields(contact: Contact, payload: dict[str, Any]) -> list[str]:
current = _contact_projection(contact)
incoming = _payload_projection(payload)
return sorted(key for key in incoming if current.get(key) != incoming.get(key))
def _contact_hash(contact: Contact) -> str:
return _hash_json(_contact_projection(contact))
def _contact_projection(contact: Contact) -> dict[str, Any]:
return {
"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
],
}
def _payload_projection(payload: dict[str, Any]) -> dict[str, Any]:
return {
key: payload.get(key)
for key in (
"display_name",
"given_name",
"family_name",
"organization",
"role_title",
"note",
"tags",
"emails",
"phones",
"postal_addresses",
)
}
def _selection_map(payload: VCardBatchCommitRequest) -> dict[str, str]:
result: dict[str, str] = {}
for selection in payload.selections:
if selection.source_key in result:
raise AddressBookError("Each vCard may be selected only once.")
result[selection.source_key] = selection.action
return result
def _public_plan_item(item: dict[str, Any]) -> dict[str, Any]:
return {
key: item.get(key)
for key in (
"source_key",
"source_filename",
"card_index",
"action",
"allowed_actions",
"contact_id",
"display_name",
"changed_fields",
"duplicate_suggestions",
"message",
)
}
def _export_contacts(
session: Session,
principal: ApiPrincipal,
address_book_id: str,
payload: VCardExportRequest,
) -> list[Contact]:
if payload.scope == "address_book":
return _loaded_contacts(session, address_book_id=address_book_id)
if payload.scope == "contacts":
contacts = [
get_visible_contact(session, principal, contact_id)
for contact_id in payload.contact_ids
]
if any(contact.address_book_id != address_book_id for contact in contacts):
raise AddressBookError(
"Every selected contact must belong to the exported address book."
)
return contacts
address_list = get_visible_address_list(
session, principal, str(payload.address_list_id)
)
if address_list.address_book_id != address_book_id:
raise AddressBookError(
"The selected address list does not belong to the exported address book."
)
contact_ids = [
item.contact_id
for item in (
session.query(AddressListEntry)
.filter(AddressListEntry.address_list_id == address_list.id)
.order_by(AddressListEntry.order_index.asc(), AddressListEntry.id.asc())
.all()
)
]
if not contact_ids:
return []
return _loaded_contacts(
session, address_book_id=address_book_id, contact_ids=set(contact_ids)
)
def _loaded_contacts(
session: Session,
*,
address_book_id: str,
contact_ids: set[str] | None = None,
) -> list[Contact]:
query = (
session.query(Contact)
.options(
selectinload(Contact.emails),
selectinload(Contact.phones),
selectinload(Contact.postal_addresses),
)
.filter(
Contact.address_book_id == address_book_id, Contact.deleted_at.is_(None)
)
)
if contact_ids is not None:
query = query.filter(Contact.id.in_(contact_ids))
return query.all()
def _visible_books(session: Session, principal: ApiPrincipal):
from govoplan_addresses.backend.service import list_address_books
return list_address_books(session, principal)
def _execution_mode(card_count: int) -> str:
raw = os.getenv(
"GOVOPLAN_ADDRESSES_VCARD_JOB_THRESHOLD", str(DEFAULT_PERSISTED_BATCH_THRESHOLD)
)
try:
threshold = max(1, min(MAX_VCARD_CARDS, int(raw)))
except ValueError:
threshold = DEFAULT_PERSISTED_BATCH_THRESHOLD
return "persisted_batch" if card_count >= threshold else "bounded_sync"
def _progress(total: int) -> dict[str, int]:
return {
"total": total,
"completed": 0,
"created": 0,
"updated": 0,
"ignored": 0,
"failed": 0,
}
def _hash_json(value: Any) -> str:
return hashlib.sha256(
json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
).encode("utf-8")
).hexdigest()
def _safe_filename(value: str) -> str:
safe = "".join(
character if character.isalnum() or character in {"-", "_"} else "-"
for character in value.strip()
)
return safe.strip("-")[:120] or "contacts"
__all__ = [
"apply_vcard_batch",
"cancel_vcard_batch",
"export_vcards",
"get_vcard_batch_run",
"preview_vcard_batch",
"vcard_batch_payload",
"vcard_diagnostics_payload",
]
+871 -1
View File
@@ -1,20 +1,40 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from dataclasses import asdict
from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
from sqlalchemy import create_engine, inspect from sqlalchemy import create_engine, inspect
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.distribution_lists import (
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
DistributionSourceReference,
RecipientChannelFactsRequest,
)
from govoplan_core.core.contact_points import (
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
CONTACT_POINT_CONTRACT_VERSION,
ContactPointResolutionRequest,
ContactPointSourceRequest,
)
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_core.security.credential_envelopes import (
CredentialEnvelope,
create_credential_envelope,
)
from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult
from govoplan_addresses.backend.capabilities import ( from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_CONTACT_WRITER, CAPABILITY_ADDRESSES_CONTACT_WRITER,
CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_LOOKUP,
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
AddressesChannelFactsCapability,
AddressesContactPointResolutionCapability,
AddressesContactWriterCapability, AddressesContactWriterCapability,
AddressesLookupCapability, AddressesLookupCapability,
AddressesPeopleSearchProvider, AddressesPeopleSearchProvider,
@@ -30,9 +50,15 @@ from govoplan_addresses.backend.db.models import (
AddressSyncSource, AddressSyncSource,
AddressSyncTombstone, AddressSyncTombstone,
Contact, Contact,
ContactChannelRule,
ContactEmail, ContactEmail,
ContactFieldProvenance,
ContactMergeRecord,
ContactPhone, ContactPhone,
ContactPointSnapshot,
ContactPointQualityDecision,
ContactPostalAddress, ContactPostalAddress,
ContactRedirect,
) )
from govoplan_addresses.backend.schemas import ( from govoplan_addresses.backend.schemas import (
AddressBookCreateRequest, AddressBookCreateRequest,
@@ -48,13 +74,29 @@ from govoplan_addresses.backend.schemas import (
AddressSyncSourceUpdateRequest, AddressSyncSourceUpdateRequest,
AddressSyncTombstoneCreateRequest, AddressSyncTombstoneCreateRequest,
ContactCreateRequest, ContactCreateRequest,
ContactChannelRuleCreateRequest,
ContactEmailPayload, ContactEmailPayload,
ContactMergeRecoveryRequest,
ContactMergeRequest,
ContactPhonePayload,
ContactPointQualityDecisionCreateRequest,
ContactPostalAddressPayload, ContactPostalAddressPayload,
ContactPointSnapshotResponse,
ContactUpdateRequest,
) )
from govoplan_addresses.backend.manifest import manifest from govoplan_addresses.backend.manifest import manifest
from govoplan_addresses.backend.router import _sync_source_response from govoplan_addresses.backend.router import (
_sync_source_response,
api_create_address_list_entry,
api_create_contact,
api_delete_address_list_entry,
api_delete_contact,
api_restore_contact,
api_update_contact,
)
from govoplan_addresses.backend.service import ( from govoplan_addresses.backend.service import (
AddressBookError, AddressBookError,
address_quality_summary,
address_book_contact_counts, address_book_contact_counts,
address_list_entry_counts, address_list_entry_counts,
create_address_book, create_address_book,
@@ -62,9 +104,13 @@ from govoplan_addresses.backend.service import (
create_address_list_entry, create_address_list_entry,
create_carddav_sync_source, create_carddav_sync_source,
create_contact, create_contact,
create_contact_channel_rule,
create_contact_quality_decision,
create_sync_source, create_sync_source,
count_contacts,
delete_address_list_entry, delete_address_list_entry,
delete_contact, delete_contact,
end_contact_channel_rule,
delete_sync_source, delete_sync_source,
discover_carddav_address_books, discover_carddav_address_books,
export_address_book_vcard, export_address_book_vcard,
@@ -73,6 +119,9 @@ from govoplan_addresses.backend.service import (
list_address_lists, list_address_lists,
list_address_books, list_address_books,
list_contacts, list_contacts,
list_contact_channel_rules,
list_contact_field_provenance,
list_contact_merges,
list_sync_conflicts, list_sync_conflicts,
list_sync_diagnostics, list_sync_diagnostics,
list_sync_sources, list_sync_sources,
@@ -82,12 +131,19 @@ from govoplan_addresses.backend.service import (
record_sync_tombstone, record_sync_tombstone,
run_sync_source, run_sync_source,
preview_sync_source, preview_sync_source,
merge_contacts,
recover_contact_merge,
restore_contact, restore_contact,
resolve_contact_redirect,
resolve_sync_conflict, resolve_sync_conflict,
start_sync_attempt, start_sync_attempt,
finish_sync_attempt, finish_sync_attempt,
update_sync_source, update_sync_source,
update_contact,
suggest_duplicate_contacts,
resolve_trusted_deployment_carddav_credential_ref, resolve_trusted_deployment_carddav_credential_ref,
_carddav_client_for_source,
_filtered_contact_query,
) )
@@ -141,6 +197,8 @@ class Principal:
"addresses:contact:read", "addresses:contact:read",
"addresses:contact:write", "addresses:contact:write",
"addresses:contact:delete", "addresses:contact:delete",
"addresses:governance:read",
"addresses:governance:write",
"addresses:sync:read", "addresses:sync:read",
"addresses:sync:write", "addresses:sync:write",
} }
@@ -175,12 +233,19 @@ class AddressServiceTest(unittest.TestCase):
ContactEmail.__table__, ContactEmail.__table__,
ContactPhone.__table__, ContactPhone.__table__,
ContactPostalAddress.__table__, ContactPostalAddress.__table__,
ContactChannelRule.__table__,
ContactPointSnapshot.__table__,
ContactPointQualityDecision.__table__,
ContactMergeRecord.__table__,
ContactRedirect.__table__,
ContactFieldProvenance.__table__,
AddressListEntry.__table__, AddressListEntry.__table__,
AddressSyncSource.__table__, AddressSyncSource.__table__,
AddressSyncTombstone.__table__, AddressSyncTombstone.__table__,
AddressSyncConflict.__table__, AddressSyncConflict.__table__,
AddressSyncDiagnostic.__table__, AddressSyncDiagnostic.__table__,
ChangeSequenceEntry.__table__, ChangeSequenceEntry.__table__,
CredentialEnvelope.__table__,
], ],
) )
self.session = sessionmaker(bind=self.engine)() self.session = sessionmaker(bind=self.engine)()
@@ -246,6 +311,140 @@ class AddressServiceTest(unittest.TestCase):
self.session.commit() self.session.commit()
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id]) self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
def test_contact_query_is_postgresql_json_safe(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="PostgreSQL-safe"),
)
self.session.commit()
contact_query = _filtered_contact_query(
self.session,
self.principal,
address_book_id=book.id,
address_list_id=None,
query="example",
include_deleted=False,
)
compiled = str(contact_query.statement.compile(dialect=postgresql.dialect()))
self.assertNotIn("SELECT DISTINCT", compiled.upper())
self.assertIn("EXISTS", compiled.upper())
def test_contact_and_relationship_routes_emit_value_free_audit_evidence(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Audited"),
)
self.session.commit()
with patch("govoplan_addresses.backend.router.audit_from_principal") as audit:
created = api_create_contact(
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
emails=[ContactEmailPayload(email="ada@example.local")],
),
self.principal,
self.session,
)
create_details = audit.call_args.kwargs["details"]
self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_created")
self.assertEqual(create_details["contact_point_counts"]["email"], 1)
self.assertNotIn("ada@example.local", repr(create_details))
original_email_id = create_details["contact_point_ids"]["email"][0]
updated = api_update_contact(
created.id,
ContactUpdateRequest(
emails=[ContactEmailPayload(email="ada.new@example.local")]
),
self.principal,
self.session,
)
update_details = audit.call_args.kwargs["details"]
self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_updated")
self.assertEqual(update_details["previous_contact_point_ids"]["email"], [original_email_id])
self.assertNotEqual(update_details["contact_point_ids"]["email"], [original_email_id])
self.assertNotIn("ada.new@example.local", repr(update_details))
api_delete_contact(created.id, self.principal, self.session)
self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_deleted")
api_restore_contact(created.id, self.principal, self.session)
self.assertEqual(audit.call_args.kwargs["action"], "addresses.contact_restored")
address_list = create_address_list(
self.session,
self.principal,
book.id,
AddressListCreateRequest(name="Audited list"),
)
self.session.commit()
entry = api_create_address_list_entry(
address_list.id,
AddressListEntryCreateRequest(
contact_id=updated.id,
contact_email_id=updated.emails[0].id,
),
self.principal,
self.session,
)
self.assertEqual(
audit.call_args.kwargs["action"],
"addresses.address_list_entry_created",
)
self.assertEqual(audit.call_args.kwargs["details"]["contact_id"], updated.id)
api_delete_address_list_entry(entry.id, self.principal, self.session)
self.assertEqual(
audit.call_args.kwargs["action"],
"addresses.address_list_entry_deleted",
)
def test_contact_windows_report_exact_totals(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Paged"),
)
self.session.flush()
contacts = [
create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name=name,
emails=[ContactEmailPayload(email=f"{name.lower()}@example.local")],
),
)
for name in ("Ada", "Barbara", "Claude", "Dorothy", "Edsger")
]
self.session.commit()
page = list_contacts(
self.session,
self.principal,
address_book_id=book.id,
limit=2,
offset=2,
)
self.assertEqual([contact.id for contact in page], [contacts[2].id, contacts[3].id])
self.assertEqual(
count_contacts(self.session, self.principal, address_book_id=book.id),
5,
)
self.assertEqual(
count_contacts(
self.session,
self.principal,
address_book_id=book.id,
query="example.local",
),
5,
)
def test_vcard_import_and_export_preserves_common_fields(self) -> None: def test_vcard_import_and_export_preserves_common_fields(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported")) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported"))
self.session.commit() self.session.commit()
@@ -330,6 +529,12 @@ END:VCARD
self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided) self.assertIn(CAPABILITY_ADDRESSES_LOOKUP, provided)
self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided) self.assertIn(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, provided)
self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided) self.assertIn(CAPABILITY_ADDRESSES_CONTACT_WRITER, provided)
self.assertIn(CAPABILITY_RECIPIENT_CHANNEL_FACTS, provided)
self.assertIn(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, provided)
self.assertIn(
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
manifest.capability_factories,
)
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients")) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Recipients"))
self.session.commit() self.session.commit()
@@ -408,6 +613,448 @@ END:VCARD
) )
self.assertEqual(blocked.exception.decision.reason, "address_book_read_only") self.assertEqual(blocked.exception.decision.reason, "address_book_read_only")
def test_channel_facts_are_effective_purpose_aware_and_provenance_bearing(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Governed recipients"),
)
self.session.flush()
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
emails=[ContactEmailPayload(email="ada@example.local", is_primary=True)],
postal_addresses=[
ContactPostalAddressPayload(
street="Main Street 1",
postal_code="10115",
locality="Berlin",
country="Germany",
is_primary=True,
)
],
),
)
self.session.flush()
rule = create_contact_channel_rule(
self.session,
self.principal,
contact.id,
ContactChannelRuleCreateRequest(
channel="email",
purpose="campaign_delivery",
contact_point_id=contact.emails[0].id,
decision="opted_out",
legal_basis="consent",
evidence_ref="case:consent-42",
reason="Recipient withdrew email consent.",
),
)
self.session.commit()
source = DistributionSourceReference(
provider="addresses",
resource_type="contact",
resource_id=contact.id,
)
facts = AddressesChannelFactsCapability().resolve_channel_facts(
self.session,
self.principal,
request=RecipientChannelFactsRequest(
tenant_id=self.principal.tenant_id,
source=source,
recipient_key=f"contact:{contact.id}",
effective_at=utcnow() + timedelta(seconds=1),
purpose="campaign_delivery",
requested_channels=("email", "postal"),
),
)
candidates = {item.channel: item for item in facts.candidates}
self.assertEqual(candidates["email"].status, "suppressed")
self.assertEqual(candidates["email"].reason_code, "addresses.channel.opted_out")
self.assertEqual(candidates["email"].decision_provenance["selected_rule_id"], rule.id)
self.assertEqual(candidates["email"].decision_provenance["evidence_ref"], "case:consent-42")
self.assertEqual(candidates["postal"].status, "usable")
self.assertEqual(candidates["postal"].decision_provenance["governance_state"], "unknown")
self.assertTrue(facts.source_revision)
self.assertEqual(len(facts.source_fingerprint or ""), 64)
governed_snapshot = AddressesRecipientSourceCapability().snapshot_address_book(
self.session,
self.principal,
address_book_id=book.id,
purpose="campaign_delivery",
)
self.assertEqual(governed_snapshot.recipients, ())
self.assertEqual(len(governed_snapshot.excluded), 1)
self.assertEqual(governed_snapshot.excluded[0].reason_code, "addresses.channel.opted_out")
self.assertTrue(governed_snapshot.provenance["governance_applied"])
unrelated = AddressesChannelFactsCapability().resolve_channel_facts(
self.session,
self.principal,
request=RecipientChannelFactsRequest(
tenant_id=self.principal.tenant_id,
source=source,
recipient_key=f"contact:{contact.id}",
effective_at=utcnow() + timedelta(seconds=1),
purpose="service_notice",
requested_channels=("email",),
),
)
self.assertEqual(unrelated.candidates[0].status, "usable")
self.assertEqual(unrelated.candidates[0].decision_provenance["governance_state"], "unknown")
ended = end_contact_channel_rule(self.session, self.principal, rule.id)
self.session.commit()
expired = AddressesChannelFactsCapability().resolve_channel_facts(
self.session,
self.principal,
request=RecipientChannelFactsRequest(
tenant_id=self.principal.tenant_id,
source=source,
recipient_key=f"contact:{contact.id}",
effective_at=utcnow() + timedelta(seconds=1),
purpose="campaign_delivery",
requested_channels=("email",),
),
)
self.assertIsNotNone(ended.effective_until)
self.assertEqual(expired.candidates[0].status, "usable")
self.assertEqual(expired.explanations[0].code, "addresses.channel_fact.expired")
self.assertEqual(list_contact_channel_rules(self.session, self.principal, contact.id)[0].id, rule.id)
def test_contact_quality_preserves_originals_provenance_and_excludes_invalid_targets(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Quality review"),
)
self.session.flush()
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
emails=[ContactEmailPayload(email=" Ada@Example.LOCAL ")],
phones=[ContactPhonePayload(phone="+49 (30) 123 45")],
postal_addresses=[
ContactPostalAddressPayload(
street=" Main Street 1 ",
postal_code=" 10115 ",
locality=" Berlin ",
country=" Germany ",
)
],
provenance={
"field_visibility": {
"organization": "restricted",
}
},
),
)
self.session.commit()
self.session.refresh(contact)
self.assertEqual(contact.emails[0].email, "Ada@Example.LOCAL")
self.assertEqual(contact.emails[0].original_email, " Ada@Example.LOCAL ")
self.assertEqual(contact.emails[0].normalized_email, "ada@example.local")
self.assertEqual(contact.phones[0].original_phone, "+49 (30) 123 45")
self.assertEqual(contact.phones[0].normalized_phone, "+493012345")
self.assertEqual(contact.postal_addresses[0].original_value["street"], " Main Street 1 ")
self.assertEqual(contact.postal_addresses[0].normalized_value["street"], "main street 1")
initial_provenance = list_contact_field_provenance(
self.session,
self.principal,
contact.id,
current_only=True,
)
self.assertTrue(any(item.field_path == "display_name" for item in initial_provenance))
self.assertTrue(any(item.field_path.endswith(".email") for item in initial_provenance))
self.assertEqual(
next(item for item in initial_provenance if item.field_path == "organization").visibility,
"restricted",
)
update_contact(
self.session,
self.principal,
contact.id,
ContactUpdateRequest(organization="Analytical Engine Office"),
)
quality = create_contact_quality_decision(
self.session,
self.principal,
contact.id,
ContactPointQualityDecisionCreateRequest(
channel="email",
contact_point_id=contact.emails[0].id,
state="undeliverable",
reason_code="addresses.quality.smtp_hard_bounce",
reason="The remote server rejected this address permanently.",
evidence_ref="mail:delivery:42",
),
)
self.session.commit()
history = list_contact_field_provenance(
self.session,
self.principal,
contact.id,
)
self.assertTrue(any(not item.selected for item in history))
current_organization = next(
item
for item in history
if item.field_path == "organization" and item.selected
)
self.assertEqual(current_organization.value, "Analytical Engine Office")
self.assertEqual(current_organization.reason_code, "addresses.contact.quality_updated")
facts = AddressesChannelFactsCapability().resolve_channel_facts(
self.session,
self.principal,
request=RecipientChannelFactsRequest(
tenant_id=self.principal.tenant_id,
source=DistributionSourceReference(
provider="addresses",
resource_type="contact",
resource_id=contact.id,
),
recipient_key=f"contact:{contact.id}",
effective_at=utcnow() + timedelta(seconds=1),
purpose="campaign_delivery",
requested_channels=("email",),
),
)
self.assertEqual(facts.candidates[0].status, "invalid")
self.assertEqual(facts.candidates[0].reason_code, "addresses.quality.smtp_hard_bounce")
self.assertEqual(facts.candidates[0].decision_provenance["quality_decision_id"], quality.id)
snapshot = AddressesRecipientSourceCapability().snapshot_address_book(
self.session,
self.principal,
address_book_id=book.id,
purpose="campaign_delivery",
)
self.assertEqual(snapshot.recipients, ())
self.assertEqual(snapshot.excluded[0].reason_code, "addresses.quality.smtp_hard_bounce")
summary = address_quality_summary(
self.session,
self.principal,
address_book_id=book.id,
)
self.assertEqual(summary.contact_count, 1)
self.assertEqual(summary.contact_point_count, 3)
self.assertEqual(summary.quality_counts["undeliverable"], 1)
self.assertEqual(summary.correction_count, 1)
self.assertEqual(summary.corrections[0].contact_id, contact.id)
def test_duplicate_merge_recovery_preserves_references_and_rejects_tampering(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Duplicate review"),
)
self.session.flush()
winner = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
organization="Analytical Engine Office",
emails=[ContactEmailPayload(email="ada@example.local")],
),
)
loser = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
organization="Analytical Engine Office",
role_title="Mathematician",
emails=[
ContactEmailPayload(email="ADA@example.local"),
ContactEmailPayload(email="ada.private@example.local"),
],
),
)
address_list = create_address_list(
self.session,
self.principal,
book.id,
AddressListCreateRequest(name="Recipients"),
)
self.session.flush()
original_loser_email_id = loser.emails[1].id
entry = create_address_list_entry(
self.session,
self.principal,
address_list.id,
AddressListEntryCreateRequest(
contact_id=loser.id,
contact_email_id=original_loser_email_id,
),
)
create_contact_quality_decision(
self.session,
self.principal,
loser.id,
ContactPointQualityDecisionCreateRequest(
channel="email",
contact_point_id=original_loser_email_id,
state="stale",
reason="This private address needs confirmation.",
),
)
self.session.commit()
scan = suggest_duplicate_contacts(
self.session,
self.principal,
address_book_id=book.id,
)
self.assertEqual(scan.scanned_contacts, 2)
self.assertEqual(scan.candidate_pairs, 1)
self.assertEqual(scan.suggestions[0].score, 100)
self.assertEqual(scan.suggestions[0].confidence, "strong")
self.assertEqual(
{feature.code for feature in scan.suggestions[0].features},
{"email_exact", "name_organization_exact"},
)
merge = merge_contacts(
self.session,
self.principal,
ContactMergeRequest(
winner_contact_id=winner.id,
duplicate_contact_ids=[loser.id],
reason="Confirmed duplicate record.",
field_sources={"role_title": loser.id},
contact_point_strategy="union",
),
)
merge_id = merge.id
after_hash = merge.after_hash
winner_id = winner.id
loser_id = loser.id
entry_id = entry.id
self.session.commit()
self.session.expire_all()
resolved = resolve_contact_redirect(self.session, self.principal, loser_id)
self.assertTrue(resolved.redirected)
self.assertEqual(resolved.resolved_contact_id, winner_id)
merged_winner = self.session.get(Contact, winner_id)
merged_loser = self.session.get(Contact, loser_id)
assert merged_winner is not None
assert merged_loser is not None
self.assertEqual(merged_winner.role_title, "Mathematician")
self.assertEqual(
{item.normalized_email for item in merged_winner.emails},
{"ada@example.local", "ada.private@example.local"},
)
self.assertIsNotNone(merged_loser.deleted_at)
merged_entry = self.session.get(AddressListEntry, entry_id)
assert merged_entry is not None
self.assertEqual(merged_entry.contact_id, winner_id)
self.assertNotEqual(merged_entry.contact_email_id, original_loser_email_id)
self.assertTrue(
any(
item.state == "stale"
and item.contact_point_id == merged_entry.contact_email_id
for item in merged_winner.quality_decisions
)
)
retained_role_title = next(
item
for item in list_contact_field_provenance(
self.session,
self.principal,
winner_id,
current_only=True,
)
if item.field_path == "role_title"
)
self.assertEqual(retained_role_title.source_ref, f"addresses:contact:{loser_id}")
self.assertEqual(retained_role_title.metadata_["source_contact_id"], loser_id)
self.assertEqual(list_contact_merges(self.session, self.principal)[0].id, merge_id)
merged_winner.note = "Changed after merge"
self.session.commit()
with self.assertRaisesRegex(AddressBookError, "changed after this merge"):
recover_contact_merge(
self.session,
self.principal,
merge_id,
ContactMergeRecoveryRequest(
reason="Correct the duplicate decision.",
expected_after_hash=after_hash,
),
action="undo",
)
self.session.rollback()
merged_winner = self.session.get(Contact, winner_id)
assert merged_winner is not None
merged_winner.note = None
self.session.commit()
recovered = recover_contact_merge(
self.session,
self.principal,
merge_id,
ContactMergeRecoveryRequest(
reason="Correct the duplicate decision.",
expected_after_hash=after_hash,
),
action="undo",
)
self.session.commit()
self.assertEqual(recovered.status, "undone")
self.session.expire_all()
restored_winner = self.session.get(Contact, winner_id)
restored_loser = self.session.get(Contact, loser_id)
restored_entry = self.session.get(AddressListEntry, entry_id)
assert restored_winner is not None
assert restored_loser is not None
assert restored_entry is not None
self.assertIsNone(restored_winner.role_title)
self.assertIsNone(restored_loser.deleted_at)
self.assertEqual(restored_entry.contact_id, loser_id)
self.assertEqual(restored_entry.contact_email_id, original_loser_email_id)
self.assertFalse(resolve_contact_redirect(self.session, self.principal, loser_id).redirected)
second_merge = merge_contacts(
self.session,
self.principal,
ContactMergeRequest(
winner_contact_id=winner_id,
duplicate_contact_ids=[loser_id],
reason="Re-run duplicate decision.",
),
)
self.session.commit()
split = recover_contact_merge(
self.session,
self.principal,
second_merge.id,
ContactMergeRecoveryRequest(
reason="Split records after review.",
expected_after_hash=second_merge.after_hash,
),
action="split",
)
self.session.commit()
self.assertEqual(split.status, "split")
def test_address_lists_group_contacts_and_expose_recipient_sources(self) -> None: def test_address_lists_group_contacts_and_expose_recipient_sources(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal")) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Personal"))
other_book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Other")) other_book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Other"))
@@ -464,6 +1111,27 @@ END:VCARD
self.assertEqual(entries[0].contact_email.email, "ada.private@example.local") self.assertEqual(entries[0].contact_email.email, "ada.private@example.local")
self.assertEqual(entries[1].target_kind, "postal_address") self.assertEqual(entries[1].target_kind, "postal_address")
self.assertEqual(entries[1].contact_postal_address.locality, "Berlin") self.assertEqual(entries[1].contact_postal_address.locality, "Berlin")
self.assertEqual(
[
item.id
for item in list_contacts(
self.session,
self.principal,
address_book_id=book.id,
address_list_id=address_list.id,
)
],
[contact.id],
)
self.assertEqual(
count_contacts(
self.session,
self.principal,
address_book_id=book.id,
address_list_id=address_list.id,
),
1,
)
with self.assertRaisesRegex(ValueError, "same address book"): with self.assertRaisesRegex(ValueError, "same address book"):
create_address_list_entry( create_address_list_entry(
@@ -489,6 +1157,170 @@ END:VCARD
self.session.commit() self.session.commit()
self.assertEqual(list_address_list_entries(self.session, self.principal, address_list.id), []) self.assertEqual(list_address_list_entries(self.session, self.principal, address_list.id), [])
def test_contact_point_resolution_supports_external_refs_and_frozen_postal_snapshots(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Official contacts"),
)
self.session.commit()
self.session.refresh(book)
contact = create_contact(
self.session,
self.principal,
book.id,
ContactCreateRequest(
display_name="Ada Lovelace",
emails=[
ContactEmailPayload(
label="private",
email="ada.private@example.local",
is_primary=True,
)
],
postal_addresses=[
ContactPostalAddressPayload(
label="official",
street="Main Street 1",
postal_code="10115",
locality="Berlin",
country="Germany",
is_primary=True,
),
ContactPostalAddressPayload(
label="private",
street="Side Street 2",
postal_code="10117",
locality="Berlin",
country="Germany",
),
],
),
)
contact.source_kind = "idm"
contact.source_ref = "idm:identity:identity-1"
self.session.flush()
create_contact_channel_rule(
self.session,
self.principal,
contact.id,
ContactChannelRuleCreateRequest(
channel="postal",
purpose="official_notice",
contact_point_id=contact.postal_addresses[0].id,
decision="preferred",
legal_basis="public_task",
evidence_ref="idm:function-assignment:17",
preference_rank=1,
locale="de-DE",
),
)
address_list = create_address_list(
self.session,
self.principal,
book.id,
AddressListCreateRequest(name="Postal recipients"),
)
self.session.flush()
postal_entry = create_address_list_entry(
self.session,
self.principal,
address_list.id,
AddressListEntryCreateRequest(
contact_id=contact.id,
contact_postal_address_id=contact.postal_addresses[0].id,
),
)
# Providers must also work before the surrounding transaction commits;
# SQLite aggregate timestamps are naive while new ORM rows are UTC-aware.
self.session.flush()
capability = AddressesContactPointResolutionCapability()
direct = capability.resolve_contact_points(
self.session,
self.principal,
request=ContactPointResolutionRequest(
tenant_id=self.principal.tenant_id,
subject=DistributionSourceReference(
provider="idm",
resource_type="identity",
resource_id="identity-1",
),
effective_at=utcnow(),
purpose="official_notice",
requested_channels=("postal",),
address_purpose="official",
fallback_rule="none",
locale="de-DE",
postal_format="international",
),
)
self.assertEqual(CONTACT_POINT_CONTRACT_VERSION, direct.contract_version)
self.assertEqual("usable", direct.status)
self.assertEqual(contact.id, direct.contact_id)
self.assertEqual(1, len(direct.candidates))
self.assertEqual(contact.postal_addresses[0].id, direct.candidates[0].contact_point_id)
self.assertEqual("official", direct.candidates[0].address_purpose)
self.assertIn("Ada Lovelace", direct.candidates[0].target)
self.assertIn("Germany", direct.candidates[0].target)
self.assertEqual("de-DE", direct.candidates[0].locale)
self.assertTrue(direct.candidates[0].preference_revision)
self.assertEqual(1, len(direct.excluded))
self.assertEqual(
"addresses.address_purpose.not_selected",
direct.excluded[0].reason_code,
)
source_request = ContactPointSourceRequest(
tenant_id=self.principal.tenant_id,
source_id=f"addresses:address_list:{address_list.id}",
effective_at=utcnow(),
purpose="official_notice",
requested_channels=("email", "postal"),
address_purpose="official",
fallback_rule="none",
locale="de-DE",
postal_format="international",
)
preview = capability.preview_source(
self.session,
self.principal,
request=source_request,
limit=1,
)
self.assertEqual(1, preview.total_count)
self.assertEqual(1, preview.usable_count)
self.assertFalse(preview.has_more)
self.assertEqual(postal_entry.id, preview.resolutions[0].provenance["address_list_entry_ids"][0])
self.assertEqual("postal", preview.resolutions[0].candidates[0].channel)
snapshot = capability.freeze_source(
self.session,
self.principal,
request=source_request,
)
self.session.commit()
original_target = snapshot.resolutions[0].candidates[0].target
contact.postal_addresses[0].street = "Changed Street 99"
contact.postal_addresses[0].country = "France"
self.session.commit()
frozen = capability.get_snapshot(
self.session,
self.principal,
snapshot_id=snapshot.id,
)
self.assertIsNotNone(frozen)
assert frozen is not None
self.assertEqual(original_target, frozen.resolutions[0].candidates[0].target)
self.assertIn("Main Street 1", frozen.resolutions[0].candidates[0].target)
self.assertNotIn("Changed Street 99", frozen.resolutions[0].candidates[0].target)
self.assertEqual(snapshot.snapshot_hash, frozen.snapshot_hash)
self.assertEqual(1, frozen.recipient_count)
response = ContactPointSnapshotResponse.model_validate(asdict(frozen))
self.assertEqual(snapshot.id, response.id)
self.assertEqual("postal", response.resolutions[0].candidates[0].channel)
def test_sync_source_marks_read_only_books_and_can_be_made_writable(self) -> None: def test_sync_source_marks_read_only_books_and_can_be_made_writable(self) -> None:
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="CardDAV")) book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="CardDAV"))
self.session.commit() self.session.commit()
@@ -845,6 +1677,44 @@ END:VCARD
), ),
) )
def test_carddav_source_can_use_reusable_core_credential(self) -> None:
book = create_address_book(
self.session,
self.principal,
AddressBookCreateRequest(scope_type="user", name="Shared credential"),
)
credential = create_credential_envelope(
self.session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Shared DAV login",
credential_kind="username_password",
public_data={"username": "ada"},
secret_data={"password": "secret"},
allowed_modules=["addresses"],
inherit_to_lower_scopes=True,
)
source = create_carddav_sync_source(
self.session,
self.principal,
book.id,
AddressCardDavSourceCreateRequest(
collection_url="https://dav.example.test/addressbooks/personal/",
auth_type="basic",
credential_ref=f"credential-envelope:{credential.id}",
),
)
self.session.commit()
client = _carddav_client_for_source(self.session, source)
response_auth = _sync_source_response(source).metadata["carddav"]
self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret")
self.assertEqual(response_auth["credential_envelope_id"], credential.id)
self.assertTrue(response_auth["has_credential"])
source = create_carddav_sync_source( source = create_carddav_sync_source(
self.session, self.session,
self.principal, self.principal,
+588
View File
@@ -0,0 +1,588 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, User
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressImportRun,
AddressList,
AddressListEntry,
AddressSyncConflict,
AddressSyncSource,
AddressSyncTombstone,
Contact,
ContactChannelRule,
ContactEmail,
ContactFieldProvenance,
ContactMergeRecord,
ContactPhone,
ContactPointQualityDecision,
ContactPostalAddress,
ContactRedirect,
)
from govoplan_addresses.backend.dsar_provider import (
ADDRESSES_DSAR_CAPABILITY,
AddressesDsarProvider,
)
from govoplan_addresses.backend.manifest import manifest
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
class _Registry:
def __init__(
self,
provider: AddressesDsarProvider,
*,
addresses_active: bool = True,
) -> None:
self.provider = provider
self.addresses_active = addresses_active
def capability_names(self):
return (ADDRESSES_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "addresses"
def tenant_entitlement_resolver(self):
addresses_active = self.addresses_active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": (("addresses",) if addresses_active else ())},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "addresses"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != ADDRESSES_DSAR_CAPABILITY:
raise KeyError(name)
class AddressesDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(bind=self.engine)
self.session = sessionmaker(bind=self.engine, future=True)()
now = datetime.now(timezone.utc)
self.account = Account(
id="account-1",
email="subject@example.test",
normalized_email="subject@example.test",
display_name="Subject",
password_hash="password-secret-do-not-export",
)
self.user = User(
id="membership-1",
tenant_id="tenant-1",
account_id=self.account.id,
email=self.account.email,
display_name="Subject",
)
self.book = AddressBook(
id="book-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Residents",
created_by_account_id=self.account.id,
metadata_={"secret": "book-metadata-do-not-export"},
)
self.contact = Contact(
id="contact-1",
tenant_id="tenant-1",
address_book_id=self.book.id,
display_name="Subject Person",
given_name="Subject",
family_name="Person",
organization="Example household",
note="A bounded subject note",
tags=["resident"],
source_kind="carddav",
source_ref="https://source.invalid/private/contact.vcf",
source_payload_kind="vcard",
source_payload_raw="raw-source-payload-do-not-export",
source_revision="revision-7",
provenance={"secret": "contact-provenance-do-not-export"},
created_by_account_id=self.account.id,
metadata_={"secret": "contact-metadata-do-not-export"},
)
self.email = ContactEmail(
id="email-1",
contact_id=self.contact.id,
label="private",
email="Subject@Example.test",
original_email="Subject@Example.test",
normalized_email="subject@example.test",
provenance={"secret": "email-provenance-do-not-export"},
is_primary=True,
)
self.phone = ContactPhone(
id="phone-1",
contact_id=self.contact.id,
label="mobile",
phone="+49 30 123456",
original_phone="030 123456",
normalized_phone="+4930123456",
provenance={"secret": "phone-provenance-do-not-export"},
is_primary=True,
)
self.postal = ContactPostalAddress(
id="postal-1",
contact_id=self.contact.id,
label="home",
street="Example Street 1",
postal_code="10115",
locality="Berlin",
country="DE",
original_value={"secret": "postal-original-do-not-export"},
normalized_value={"secret": "postal-normalized-do-not-export"},
provenance={"secret": "postal-provenance-do-not-export"},
is_primary=True,
)
self.address_list = AddressList(
id="list-1",
tenant_id="tenant-1",
address_book_id=self.book.id,
name="District residents",
created_by_account_id="another-account",
)
self.list_entry = AddressListEntry(
id="entry-1",
address_list_id=self.address_list.id,
contact_id=self.contact.id,
contact_email_id=self.email.id,
target_kind="email",
metadata_={"secret": "list-entry-metadata-do-not-export"},
)
self.channel_rule = ContactChannelRule(
id="rule-1",
tenant_id="tenant-1",
contact_id=self.contact.id,
channel="email",
purpose="resident-notice",
contact_point_id=self.email.id,
decision="allow",
legal_basis="public task",
evidence_ref="records://consent/evidence-1",
reason="Current resident preference",
effective_from=now,
created_by_account_id="another-account",
metadata_={"secret": "rule-metadata-do-not-export"},
)
self.quality = ContactPointQualityDecision(
id="quality-1",
tenant_id="tenant-1",
contact_id=self.contact.id,
channel="email",
contact_point_id=self.email.id,
state="valid",
reason_code="verified",
reason="Verified by operator",
evidence_ref="files://private/evidence",
effective_from=now,
created_by_account_id="another-account",
metadata_={"secret": "quality-metadata-do-not-export"},
)
self.provenance = ContactFieldProvenance(
id="provenance-1",
tenant_id="tenant-1",
contact_id=self.contact.id,
field_path="emails[0].email",
value={"secret": "field-value-do-not-export"},
source_kind="carddav",
source_ref="https://source.invalid/private",
source_revision="revision-7",
precedence=10,
selected=True,
reason_code="source_authority",
explanation="Selected from the authoritative source",
visibility="operator",
created_by_account_id="another-account",
metadata_={"secret": "field-metadata-do-not-export"},
)
self.sync_source = AddressSyncSource(
id="source-1",
tenant_id="tenant-1",
address_book_id=self.book.id,
connector_type="carddav",
display_name="Residents CardDAV",
external_account_ref="private-account-ref-do-not-export",
external_address_book_ref="private-book-ref-do-not-export",
sync_token="sync-token-do-not-export",
etag="private-etag-do-not-export",
remote_revision="private-remote-revision-do-not-export",
last_diagnostic={"secret": "diagnostic-do-not-export"},
created_by_account_id=self.account.id,
metadata_={"secret": "source-metadata-do-not-export"},
)
self.tombstone = AddressSyncTombstone(
id="tombstone-1",
tenant_id="tenant-1",
sync_source_id=self.sync_source.id,
address_book_id=self.book.id,
contact_id=self.contact.id,
remote_uid="private-uid-do-not-export",
resource_href="private-href-do-not-export",
synced_at=now,
metadata_={"secret": "tombstone-metadata-do-not-export"},
)
self.conflict = AddressSyncConflict(
id="conflict-1",
tenant_id="tenant-1",
sync_source_id=self.sync_source.id,
address_book_id=self.book.id,
contact_id=self.contact.id,
remote_uid="private-conflict-uid-do-not-export",
resource_href="private-conflict-href-do-not-export",
field_path="family_name",
local_value={"secret": "local-value-do-not-export"},
remote_value={"secret": "remote-value-do-not-export"},
status="resolved",
resolution="local",
resolved_at=now,
resolved_by_account_id=self.account.id,
metadata_={"secret": "conflict-metadata-do-not-export"},
)
self.import_run = AddressImportRun(
id="import-1",
tenant_id="tenant-1",
address_book_id=self.book.id,
source_filename="contacts.csv",
source_format="csv",
input_hash="a" * 64,
plan_hash="b" * 64,
status="applied",
row_count=1,
statistics={"secret": "statistics-do-not-export"},
diagnostics=[{"secret": "import-diagnostic-do-not-export"}],
plan_data=[{"secret": "import-plan-do-not-export"}],
result_evidence={"secret": "import-result-do-not-export"},
created_by_account_id=self.account.id,
applied_at=now,
)
self.merge = ContactMergeRecord(
id="merge-1",
tenant_id="tenant-1",
address_book_id=self.book.id,
winner_contact_id=self.contact.id,
loser_contact_ids=["old-contact-1"],
status="active",
reason="Duplicate contact",
survivorship={"secret": "survivorship-do-not-export"},
decisions=[{"secret": "merge-decisions-do-not-export"}],
before_payload={"secret": "merge-before-do-not-export"},
after_payload={"secret": "merge-after-do-not-export"},
before_hash="c" * 64,
after_hash="d" * 64,
created_by_account_id="another-account",
provenance={"secret": "merge-provenance-do-not-export"},
)
self.redirect = ContactRedirect(
id="redirect-1",
tenant_id="tenant-1",
source_contact_id="old-contact-1",
target_contact_id=self.contact.id,
merge_record_id=self.merge.id,
)
self.unrelated = Contact(
id="contact-unrelated",
tenant_id="tenant-1",
address_book_id=self.book.id,
display_name="Unrelated Person",
note="unrelated-person-do-not-export",
)
unrelated_email = ContactEmail(
id="email-unrelated",
contact_id=self.unrelated.id,
email="unrelated@example.test",
original_email="unrelated@example.test",
normalized_email="unrelated@example.test",
)
tenant_two_book = AddressBook(
id="book-tenant-2",
tenant_id="tenant-2",
scope_type="tenant",
scope_id="tenant-2",
name="Other tenant",
)
tenant_two_contact = Contact(
id="contact-tenant-2",
tenant_id="tenant-2",
address_book_id=tenant_two_book.id,
display_name="Other Tenant Subject",
note="other-tenant-do-not-export",
)
tenant_two_email = ContactEmail(
id="email-tenant-2",
contact_id=tenant_two_contact.id,
email="subject@example.test",
original_email="subject@example.test",
normalized_email="subject@example.test",
)
self.session.add_all(
[
self.account,
self.user,
self.book,
self.contact,
self.email,
self.phone,
self.postal,
self.address_list,
self.list_entry,
self.channel_rule,
self.quality,
self.provenance,
self.sync_source,
self.tombstone,
self.conflict,
self.import_run,
self.merge,
self.redirect,
self.unrelated,
unrelated_email,
tenant_two_book,
tenant_two_contact,
tenant_two_email,
]
)
self.session.commit()
self.provider = AddressesDsarProvider()
self.subject = DsarSubjectRef(
account_id=self.account.id,
email=self.account.email,
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
provided_names = {item.name for item in manifest.provides_interfaces}
self.assertIn(ADDRESSES_DSAR_CAPABILITY, provided_names)
provider = manifest.capability_factories[ADDRESSES_DSAR_CAPABILITY](None)
self.assertIsInstance(provider, DsarProvider)
def test_search_is_tenant_scoped_related_and_minimized(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
resource_types = {record.resource_type for record in records}
self.assertTrue(
{
"addresses_contact",
"addresses_contact_email",
"addresses_contact_phone",
"addresses_contact_postal_address",
"addresses_list_membership",
"addresses_channel_rule",
"addresses_quality_decision",
"addresses_field_provenance",
"addresses_merge_record",
"addresses_contact_redirect",
"addresses_sync_tombstone",
"addresses_sync_conflict",
"addresses_address_book_attribution",
"addresses_sync_source_attribution",
"addresses_import_run_attribution",
}.issubset(resource_types)
)
serialized = repr([record.to_dict() for record in records])
excluded_values = (
"password-secret-do-not-export",
"raw-source-payload-do-not-export",
"contact-provenance-do-not-export",
"book-metadata-do-not-export",
"field-value-do-not-export",
"sync-token-do-not-export",
"private-account-ref-do-not-export",
"private-remote-revision-do-not-export",
"local-value-do-not-export",
"remote-value-do-not-export",
"import-plan-do-not-export",
"merge-before-do-not-export",
"merge-after-do-not-export",
"unrelated-person-do-not-export",
"other-tenant-do-not-export",
)
for value in excluded_values:
self.assertNotIn(value, serialized)
def test_conflicting_selectors_and_uncorroborated_reference_fail_closed(
self,
) -> None:
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"addresses.email": "other@example.test"},
),
)
uncorroborated = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"addresses.contact": self.unrelated.id},
),
)
self.assertEqual((), conflict)
self.assertEqual((), uncorroborated)
def test_plan_retains_evidence_and_routes_contact_data_to_review(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=records,
)
kinds = {action.kind for action in actions}
self.assertEqual({"manual_review", "retain"}, kinds)
self.assertFalse(any(action.executable for action in actions))
retained = [action for action in actions if action.kind == "retain"]
self.assertTrue(retained)
self.assertTrue(all(action.rationale for action in retained))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=actions,
request_id="dsar-addresses-1",
)
self.assertEqual({"blocked"}, {result.status for result in results})
self.assertIsNotNone(self.session.get(Contact, self.contact.id))
def test_execution_rejects_foreign_or_forged_executable_actions(self) -> None:
foreign = DsarErasureActionRef(
action_id="mail:delete:contact:contact-1",
provider_id="mail",
module_id="mail",
kind="delete",
resource_type="addresses_contact",
resource_id=self.contact.id,
title="Foreign delete",
rationale="Must be rejected",
executable=True,
)
forged = DsarErasureActionRef(
action_id="addresses:delete:addresses_contact:contact-1",
provider_id="addresses",
module_id="addresses",
kind="delete",
resource_type="addresses_contact",
resource_id=self.contact.id,
title="Forged delete",
rationale="Must be rejected",
executable=True,
)
with self.assertRaises(ValueError):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(foreign,),
request_id="dsar-addresses-2",
)
with self.assertRaises(ValueError):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(forged,),
request_id="dsar-addresses-2",
)
def test_core_workflow_discovers_active_and_inactive_provider(self) -> None:
request = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-ADDRESSES-1",
request_kind="access",
subject=self.subject,
purpose="Respond to an authorized privacy request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=request,
expected_revision=1,
)
self.assertEqual("searched", request.status)
self.assertEqual(["addresses"], request.coverage["covered_modules"])
self.assertEqual([], request.coverage["modules_without_provider"])
disabled = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-ADDRESSES-DISABLED",
request_kind="access",
subject=self.subject,
purpose="Verify disabled-module coverage.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, addresses_active=False),
row=disabled,
expected_revision=1,
)
self.assertEqual(0, disabled.search_result["record_count"])
self.assertEqual(
[ADDRESSES_DSAR_CAPABILITY],
disabled.coverage["inactive_provider_capabilities"],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,54 @@
from __future__ import annotations
import unittest
from govoplan_addresses.backend.manifest import manifest
class AddressesInterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in manifest.documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual(
{"title", "summary", "body"},
set(german),
topic.id,
)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
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()
+208
View File
@@ -0,0 +1,208 @@
from __future__ import annotations
import base64
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_addresses.backend.db.models import AddressBook, Contact
from govoplan_addresses.backend.import_schemas import (
AddressImportConfiguration,
AddressImportPreviewRequest,
AddressImportProfileCreateRequest,
)
from govoplan_addresses.backend.imports import (
apply_address_import,
create_import_profile,
import_run_payload,
preview_address_import,
)
from govoplan_addresses.backend.ldif import parse_ldif_rows
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",
}
def encoded(value: bytes | str) -> str:
raw = value.encode() if isinstance(value, str) else value
return base64.b64encode(raw).decode()
class AddressLdifImportTests(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)
def create_profile(self, **configuration_overrides):
configuration = AddressImportConfiguration(
field_mappings={
"source_key": "dn",
"display_name": "cn",
"given_name": "givenName",
"family_name": "sn",
"email": "mail",
"phone": "telephoneNumber",
"organization": "o",
},
**configuration_overrides,
)
profile = create_import_profile(
self.session,
self.principal,
AddressImportProfileCreateRequest(
scope_type="tenant",
name="Directory export",
source_format="ldif",
configuration=configuration,
),
)
self.session.flush()
return profile
def test_parser_unfolds_and_decodes_text_without_projecting_binary_or_urls(self) -> None:
rows, diagnostics = parse_ldif_rows(
b"version: 1\n\n"
b"# exported contact\n"
b"dn: uid=ada,ou=people,dc=example,dc=test\n"
b"cn:: QWRhIExvdmVsYWNl\n"
b"sn: Love\n"
b" lace\n"
b"mail: ada@example.test\n"
b"mail: ada.work@example.test\n"
b"jpegPhoto:: /9j/\n"
b"seeAlso:< https://example.test/contact/ada\n",
max_entries=10,
)
self.assertEqual(1, len(rows))
self.assertEqual(["Ada Lovelace"], rows[0][1]["cn"])
self.assertEqual(["Lovelace"], rows[0][1]["sn"])
self.assertEqual(["ada@example.test", "ada.work@example.test"], rows[0][1]["mail"])
self.assertNotIn("jpegphoto", rows[0][1])
self.assertNotIn("seealso", rows[0][1])
self.assertEqual(
{"ldif_binary_value_ignored", "ldif_url_value_ignored"},
{item["code"] for item in diagnostics},
)
def test_preview_apply_and_repeat_preserve_multivalue_provenance(self) -> None:
profile = self.create_profile(default_tags=["ldif"])
source = (
"dn: uid=ada,ou=people,dc=example,dc=test\n"
"cn: Ada Lovelace\n"
"givenName: Ada\n"
"sn: Lovelace\n"
"mail: ada@example.test\n"
"mail: ada.work@example.test\n"
"telephoneNumber: +49 30 123\n"
"o: Analysis Office\n"
)
request = AddressImportPreviewRequest(
profile_id=profile.id,
filename="contacts.ldif",
content_base64=encoded(source),
)
run = preview_address_import(self.session, self.principal, self.book.id, request)
self.assertEqual(1, run.statistics["create"])
self.assertFalse([item for item in run.diagnostics if item["severity"] == "error"])
apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash)
contact = self.session.query(Contact).one()
self.assertEqual(["ada@example.test", "ada.work@example.test"], [item.email for item in contact.emails])
self.assertEqual("ldif", contact.source_kind)
self.assertEqual("ldif", contact.provenance["import"]["source_format"])
self.assertEqual(64, len(contact.provenance["import"]["source_record_hash"]))
self.assertNotIn(source, repr(import_run_payload(run)))
repeated = preview_address_import(self.session, self.principal, self.book.id, request)
self.assertEqual(1, repeated.statistics["unchanged"])
apply_address_import(self.session, self.principal, repeated.id, expected_plan_hash=repeated.plan_hash)
self.assertEqual(1, self.session.query(Contact).count())
def test_change_records_are_rejected_by_default_and_add_requires_explicit_policy(self) -> None:
source = (
"dn: uid=ada,ou=people,dc=example,dc=test\n"
"changetype: add\n"
"cn: Ada Lovelace\n"
"mail: ada@example.test\n"
)
rejected_profile = self.create_profile()
rejected = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=rejected_profile.id,
filename="changes.ldif",
content_base64=encoded(source),
),
)
self.assertEqual(0, rejected.row_count)
self.assertIn("ldif_change_record_rejected", {item["code"] for item in rejected.diagnostics})
self.assertFalse(import_run_payload(rejected)["can_apply"])
allowed_profile = self.create_profile(ldif_change_record_policy="treat_add_as_entry")
allowed = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=allowed_profile.id,
filename="changes.ldif",
content_base64=encoded(source),
),
)
self.assertEqual(1, allowed.statistics["create"])
self.assertIn("ldif_add_record_imported", {item["code"] for item in allowed.diagnostics})
def test_invalid_base64_is_a_correction_diagnostic_and_blocks_apply(self) -> None:
profile = self.create_profile()
run = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=profile.id,
filename="broken.ldif",
content_base64=encoded(
"dn: uid=ada,dc=example,dc=test\n"
"cn:: this-is-not-base64!\n"
"mail: ada@example.test\n"
),
),
)
self.assertIn("ldif_invalid_base64", {item["code"] for item in run.diagnostics})
self.assertFalse(import_run_payload(run)["can_apply"])
with self.assertRaisesRegex(ValueError, "error diagnostics"):
apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash)
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(
"d6e8f9a0b1c2",
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()
+309
View File
@@ -0,0 +1,309 @@
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,
get_import_run,
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",
}
class OtherTenantPrincipal(Principal):
@property
def tenant_id(self) -> str:
return "tenant-2"
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(
expected_plan_hash=run.plan_hash,
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_rollback_rejects_a_stale_review_hash(self) -> None:
run = preview_address_import(
self.session,
self.principal,
self.book.id,
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"
),
),
)
apply_address_import(
self.session,
self.principal,
run.id,
expected_plan_hash=run.plan_hash,
)
with self.assertRaisesRegex(ValueError, "reviewed import plan changed"):
rollback_address_import(
self.session,
self.principal,
run.id,
AddressImportRollbackRequest(
expected_plan_hash="0" * 64,
reason="The operator selected the wrong monthly file.",
),
)
def test_persisted_run_read_is_tenant_bounded_and_source_safe(self) -> None:
source = "id;first;last;email;organization\n1;Ada;Lovelace;ada@example.test;Analysis Office\n"
run = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="contacts.csv",
content_base64=encoded(source),
),
)
self.session.flush()
payload = import_run_payload(get_import_run(self.session, self.principal, run.id))
self.assertEqual("previewed", payload["status"])
self.assertEqual(run.plan_hash, payload["plan_hash"])
self.assertNotIn("plan_data", payload)
self.assertNotIn(source, repr(payload))
with self.assertRaisesRegex(ValueError, "not found"):
get_import_run(self.session, OtherTenantPrincipal(), run.id)
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()
+270
View File
@@ -0,0 +1,270 @@
from __future__ import annotations
import base64
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressList,
AddressListEntry,
Contact,
)
from govoplan_addresses.backend.schemas import ContactCreateRequest
from govoplan_addresses.backend.service import AddressBookError, create_contact
from govoplan_addresses.backend.vcard import (
MAX_VCARD_UNFOLDED_LINE_CHARS,
parse_vcards_with_issues,
)
from govoplan_addresses.backend.vcard_batch_schemas import (
VCardBatchCancelRequest,
VCardBatchCommitRequest,
VCardBatchFilePayload,
VCardBatchPreviewRequest,
VCardBatchSelection,
VCardExportRequest,
)
from govoplan_addresses.backend.vcard_batches import (
apply_vcard_batch,
cancel_vcard_batch,
export_vcards,
preview_vcard_batch,
vcard_batch_payload,
)
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",
}
def vcard(uid: str, name: str, email: str) -> str:
return (
"BEGIN:VCARD\r\n"
"VERSION:4.0\r\n"
f"UID:{uid}\r\n"
f"FN:{name}\r\n"
f"EMAIL:{email}\r\n"
"END:VCARD\r\n"
)
def batch_file(filename: str, content: str) -> VCardBatchFilePayload:
return VCardBatchFilePayload(
filename=filename,
content_base64=base64.b64encode(content.encode()).decode(),
)
class VCardBatchTests(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="Batch contacts",
source_kind="local",
read_only=False,
)
self.session.add(self.book)
self.session.flush()
def test_multifile_preview_selective_apply_and_repeat_are_idempotent(self) -> None:
run = preview_vcard_batch(
self.session,
self.principal,
self.book.id,
VCardBatchPreviewRequest(
files=[
batch_file(
"ada.vcf", vcard("ada-1", "Ada Lovelace", "ada@example.test")
),
batch_file(
"grace.vcf",
vcard("grace-1", "Grace Hopper", "grace@example.test"),
),
]
),
)
self.assertEqual(0, self.session.query(Contact).count())
self.assertEqual("previewed", run.status)
self.assertEqual(2, run.row_count)
self.assertEqual("govoplan-vcard/2", run.result_evidence["parser_version"])
self.assertNotIn("ada@example.test", repr(vcard_batch_payload(run)))
selections = [
VCardBatchSelection(
source_key=run.plan_data[0]["source_key"], action="create"
),
VCardBatchSelection(
source_key=run.plan_data[1]["source_key"], action="ignore"
),
]
request = VCardBatchCommitRequest(
expected_plan_hash=run.plan_hash, selections=selections
)
applied = apply_vcard_batch(self.session, self.principal, run.id, request)
repeated = apply_vcard_batch(self.session, self.principal, run.id, request)
self.assertIs(applied, repeated)
self.assertEqual(1, self.session.query(Contact).count())
self.assertEqual("Ada Lovelace", self.session.query(Contact).one().display_name)
self.assertEqual(1, applied.result_evidence["progress"]["ignored"])
with self.assertRaisesRegex(AddressBookError, "different selection"):
apply_vcard_batch(
self.session,
self.principal,
run.id,
VCardBatchCommitRequest(
expected_plan_hash=run.plan_hash,
selections=[
VCardBatchSelection(
source_key=run.plan_data[1]["source_key"], action="create"
)
],
),
)
def test_duplicate_uid_policy_and_cancellation(self) -> None:
run = preview_vcard_batch(
self.session,
self.principal,
self.book.id,
VCardBatchPreviewRequest(
files=[
batch_file(
"duplicates.vcf",
vcard("same", "First", "first@example.test")
+ vcard("same", "Last", "last@example.test"),
)
],
duplicate_card_policy="reject",
),
)
self.assertEqual(
["conflict", "conflict"], [item["action"] for item in run.plan_data]
)
cancelled = cancel_vcard_batch(
self.session,
self.principal,
run.id,
VCardBatchCancelRequest(
expected_plan_hash=run.plan_hash,
reason="Operator rejected duplicate source UIDs.",
),
)
self.assertEqual("cancelled", cancelled.status)
self.assertEqual(0, self.session.query(Contact).count())
last = preview_vcard_batch(
self.session,
self.principal,
self.book.id,
VCardBatchPreviewRequest(
files=[
batch_file(
"duplicates.vcf",
vcard("same", "First", "first@example.test")
+ vcard("same", "Last", "last@example.test"),
)
],
duplicate_card_policy="last",
),
)
self.assertEqual(
["ignore", "create"], [item["action"] for item in last.plan_data]
)
def test_deterministic_scoped_export_supports_vcard_versions(self) -> None:
grace = create_contact(
self.session,
self.principal,
self.book.id,
ContactCreateRequest(display_name="Grace Hopper"),
)
ada = create_contact(
self.session,
self.principal,
self.book.id,
ContactCreateRequest(display_name="Ada Lovelace"),
)
address_list = AddressList(
tenant_id="tenant-1",
address_book_id=self.book.id,
name="Selected",
source_kind="local",
read_only=False,
)
self.session.add(address_list)
self.session.flush()
self.session.add(
AddressListEntry(
address_list_id=address_list.id, contact_id=grace.id, order_index=0
)
)
self.session.flush()
selected = export_vcards(
self.session,
self.principal,
self.book.id,
VCardExportRequest(
scope="contacts", contact_ids=[grace.id, ada.id], version="3.0"
),
)
repeated = export_vcards(
self.session,
self.principal,
self.book.id,
VCardExportRequest(
scope="contacts", contact_ids=[ada.id, grace.id], version="3.0"
),
)
listed = export_vcards(
self.session,
self.principal,
self.book.id,
VCardExportRequest(scope="address_list", address_list_id=address_list.id),
)
self.assertEqual(selected["content_hash"], repeated["content_hash"])
self.assertLess(
selected["content"].index("Ada Lovelace"),
selected["content"].index("Grace Hopper"),
)
self.assertIn("VERSION:3.0", selected["content"])
self.assertEqual(1, listed["contact_count"])
self.assertIn("Grace Hopper", listed["content"])
def test_parser_rejects_pathological_unfolded_lines(self) -> None:
result = parse_vcards_with_issues(
"BEGIN:VCARD\nFN:"
+ ("a" * (MAX_VCARD_UNFOLDED_LINE_CHARS + 1))
+ "\nEND:VCARD"
)
self.assertEqual([], result.cards)
self.assertIn("unfolded lines", result.issues[0].message)
if __name__ == "__main__":
unittest.main()
+7 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/addresses-webui", "name": "@govoplan/addresses-webui",
"version": "0.1.9", "version": "0.1.21",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,14 +14,15 @@
"./styles/addresses.css": "./src/styles/addresses.css" "./styles/addresses.css": "./src/styles/addresses.css"
}, },
"scripts": { "scripts": {
"test:ui-structure": "node scripts/test-selection-list-structure.mjs" "test:ui-structure": "node scripts/test-selection-list-structure.mjs",
"test:import-run": "rm -rf .import-run-test-build && mkdir -p .import-run-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-run-test-build/package.json && ../../govoplan-core/webui/node_modules/.bin/tsc -p tsconfig.import-run-tests.json && node .import-run-test-build/tests/import-run-state.test.js"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.11", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1" "react-router": ">=8.3.0 <9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
+649 -5
View File
@@ -39,6 +39,11 @@ export type ContactEmail = {
id?: string; id?: string;
label?: string | null; label?: string | null;
email: string; email: string;
original_email?: string;
normalized_email?: string;
provenance?: Record<string, unknown>;
quality_state?: ContactPointQualityState;
quality_reason_code?: string | null;
is_primary: boolean; is_primary: boolean;
}; };
@@ -46,6 +51,11 @@ export type ContactPhone = {
id?: string; id?: string;
label?: string | null; label?: string | null;
phone: string; phone: string;
original_phone?: string;
normalized_phone?: string;
provenance?: Record<string, unknown>;
quality_state?: ContactPointQualityState;
quality_reason_code?: string | null;
is_primary: boolean; is_primary: boolean;
}; };
@@ -57,9 +67,35 @@ export type ContactPostalAddress = {
locality?: string | null; locality?: string | null;
region?: string | null; region?: string | null;
country?: string | null; country?: string | null;
original_value?: Record<string, unknown>;
normalized_value?: Record<string, unknown>;
provenance?: Record<string, unknown>;
quality_state?: ContactPointQualityState;
quality_reason_code?: string | null;
is_primary: boolean; is_primary: boolean;
}; };
export type ContactPointQualityState = "valid" | "invalid" | "returned" | "stale" | "undeliverable";
export type ContactFieldProvenance = {
id: string;
contact_id: string;
field_path: string;
value?: unknown;
source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
precedence: number;
selected: boolean;
reason_code: string;
explanation?: string | null;
visibility: "inherit" | "private" | "restricted" | "public";
merge_record_id?: string | null;
created_by_account_id?: string | null;
metadata: Record<string, unknown>;
created_at: string;
};
export type Contact = { export type Contact = {
id: string; id: string;
tenant_id?: string | null; tenant_id?: string | null;
@@ -79,11 +115,142 @@ export type Contact = {
emails: ContactEmail[]; emails: ContactEmail[];
phones: ContactPhone[]; phones: ContactPhone[];
postal_addresses: ContactPostalAddress[]; postal_addresses: ContactPostalAddress[];
field_provenance?: ContactFieldProvenance[];
deleted_at?: string | null; deleted_at?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
export type ContactPointQualityDecision = {
id: string;
tenant_id?: string | null;
contact_id: string;
channel: "email" | "phone" | "postal" | "internal_mail" | "portal";
contact_point_id?: string | null;
state: ContactPointQualityState;
reason_code: string;
reason?: string | null;
evidence_ref?: string | null;
effective_from: string;
effective_until?: string | null;
created_by_account_id?: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type ContactDuplicateFeature = {
code: string;
label: string;
weight: number;
value: string;
};
export type ContactDuplicateSuggestion = {
left: Contact;
right: Contact;
score: number;
confidence: "possible" | "likely" | "strong";
features: ContactDuplicateFeature[];
};
export type ContactDuplicateSuggestionList = {
suggestions: ContactDuplicateSuggestion[];
scanned_contacts: number;
candidate_pairs: number;
truncated: boolean;
};
export type ContactMergeRecord = {
id: string;
tenant_id?: string | null;
address_book_id: string;
winner_contact_id: string;
loser_contact_ids: string[];
status: string;
reason: string;
survivorship: Record<string, unknown>;
decisions: Array<Record<string, unknown>>;
before_hash: string;
after_hash: string;
created_by_account_id?: string | null;
recovered_at?: string | null;
recovered_by_account_id?: string | null;
recovery_action?: string | null;
recovery_reason?: string | null;
provenance: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type AddressQualityCorrection = {
contact_id: string;
display_name: string;
channel: "email" | "phone" | "postal" | "internal_mail" | "portal";
contact_point_id?: string | null;
state: ContactPointQualityState;
reason_code: string;
reason?: string | null;
effective_from: string;
};
export type AddressQualitySummary = {
contact_count: number;
contact_point_count: number;
quality_counts: Record<string, number>;
duplicate_suggestion_count: number;
correction_count: number;
corrections: AddressQualityCorrection[];
truncated: boolean;
};
export type AddressDistributionChannel = "email" | "postal" | "internal_mail" | "portal";
export type AddressChannelDecision =
| "allowed"
| "opted_in"
| "preferred"
| "opted_out"
| "suppressed"
| "invalid"
| "returned"
| "temporarily_unavailable";
export type ContactChannelRule = {
id: string;
tenant_id?: string | null;
contact_id: string;
channel: AddressDistributionChannel;
purpose?: string | null;
contact_point_id?: string | null;
decision: AddressChannelDecision;
legal_basis?: string | null;
evidence_ref?: string | null;
reason?: string | null;
preference_rank?: number | null;
locale?: string | null;
effective_from?: string | null;
effective_until?: string | null;
metadata: Record<string, unknown>;
created_by_account_id?: string | null;
created_at: string;
updated_at: string;
};
export type ContactChannelRulePayload = {
channel: AddressDistributionChannel;
purpose?: string | null;
contact_point_id?: string | null;
decision: AddressChannelDecision;
legal_basis?: string | null;
evidence_ref?: string | null;
reason?: string | null;
preference_rank?: number | null;
locale?: string | null;
effective_from?: string | null;
effective_until?: string | null;
metadata?: Record<string, unknown>;
};
export type AddressBookCreatePayload = { export type AddressBookCreatePayload = {
scope_type: AddressBookScope; scope_type: AddressBookScope;
group_id?: string | null; group_id?: string | null;
@@ -168,8 +335,12 @@ type AddressListEntryListResponse = {
entries: AddressListEntry[]; entries: AddressListEntry[];
}; };
type ContactListResponse = { export type ContactListResponse = {
contacts: Contact[]; contacts: Contact[];
total: number;
offset: number;
limit: number;
has_more: boolean;
}; };
type AddressBookWriteTargetsResponse = { type AddressBookWriteTargetsResponse = {
@@ -183,6 +354,60 @@ export type VCardImportResult = {
issues: Array<{ index: number; message: string; severity: "warning" | "error"; field?: string | null; line?: number | null }>; issues: Array<{ index: number; message: string; severity: "warning" | "error"; field?: string | null; line?: number | null }>;
}; };
export type VCardBatchPlanItem = {
source_key: string;
source_filename: string;
card_index: number;
action: "create" | "update" | "ignore" | "unchanged" | "conflict";
allowed_actions: Array<"create" | "update" | "ignore">;
contact_id?: string | null;
display_name?: string | null;
changed_fields: string[];
duplicate_suggestions: Array<{ contact_id: string; display_name: string; reasons: string[] }>;
message?: string | null;
};
export type VCardBatchRun = {
id: string;
address_book_id: string;
status: string;
input_hash: string;
plan_hash: string;
parser_version: string;
execution_mode: "bounded_sync" | "persisted_batch";
file_count: number;
card_count: number;
statistics: Record<string, number | string>;
diagnostics: Array<{
severity: "info" | "warning" | "error";
code: string;
message: string;
source_filename?: string | null;
card_index?: number | null;
field?: string | null;
details: Record<string, unknown>;
}>;
plan: VCardBatchPlanItem[];
progress: { total: number; completed: number; created: number; updated: number; ignored: number; failed: number };
can_apply: boolean;
can_cancel: boolean;
commit_hash?: string | null;
created_at: string;
updated_at: string;
applied_at?: string | null;
};
export type VCardExportResult = {
filename: string;
media_type: string;
scope: "address_book" | "address_list" | "contacts";
version: "3.0" | "4.0";
ordering: string;
contact_count: number;
content_hash: string;
content: string;
};
export type AddressSyncSource = { export type AddressSyncSource = {
id: string; id: string;
tenant_id?: string | null; tenant_id?: string | null;
@@ -216,6 +441,22 @@ export type AddressCardDavAddressBook = {
sync_token?: string | null; sync_token?: string | null;
}; };
export type AddressCredentialEnvelope = {
id: string;
scope_type: string;
scope_id?: string | null;
name: string;
description?: string | null;
credential_kind: string;
public_data: Record<string, unknown>;
secret_keys: string[];
secret_configured: boolean;
allowed_modules: string[];
inherit_to_lower_scopes: boolean;
is_active: boolean;
revision: string;
};
export type AddressSyncPlanStats = { export type AddressSyncPlanStats = {
created: number; created: number;
updated: number; updated: number;
@@ -297,6 +538,93 @@ export type AddressSyncConflict = {
updated_at: string; updated_at: string;
}; };
export type AddressImportConfiguration = {
field_mappings: Record<string, string>;
delimiter: "," | ";" | "\t" | "|";
encoding: "utf-8" | "utf-8-sig" | "cp1252" | "latin-1";
header_row: number;
sheet_name?: string | null;
source_key_column?: string | null;
duplicate_source_key_policy: "reject" | "first" | "last";
existing_contact_policy: "update" | "ignore" | "reject";
blank_value_policy: "ignore" | "clear" | "reject";
ldif_change_record_policy: "reject" | "ignore" | "treat_add_as_entry";
locale?: string | null;
default_tags: string[];
max_rows: number;
};
export type AddressImportProfile = {
id: string;
profile_key: string;
version: number;
tenant_id?: string | null;
scope_type: AddressBookScope;
scope_id?: string | null;
name: string;
description?: string | null;
source_format: "csv" | "xlsx" | "ldif";
configuration: AddressImportConfiguration;
is_current: boolean;
created_at: string;
updated_at: string;
};
export type AddressImportEffect = {
row_number: number;
action: "create" | "update" | "conflict" | "unchanged" | "ignored";
source_key?: string | null;
contact_id?: string | null;
display_name?: string | null;
changed_fields: string[];
message?: string | null;
};
export type AddressImportDiagnostic = {
severity: "info" | "warning" | "error";
code: string;
message: string;
row_number?: number | null;
field?: string | null;
details: Record<string, unknown>;
};
export type AddressImportRun = {
id: string;
address_book_id: string;
profile_id: string | null;
source_filename: string;
source_format: string;
input_hash: string;
plan_hash: string;
status: string;
row_count: number;
statistics: Record<string, number>;
diagnostics: AddressImportDiagnostic[];
effects: AddressImportEffect[];
can_apply: boolean;
result_evidence: Record<string, unknown>;
created_at: string;
updated_at: string;
applied_at?: string | null;
rolled_back_at?: string | null;
};
export type AddressLdapSourcePayload = {
url: string;
credential_ref?: string | null;
bind_dn?: string | null;
start_tls: boolean;
connect_timeout?: number;
receive_timeout?: number;
display_name: string;
base_dn: string;
search_filter: string;
page_size: number;
max_entries: number;
attribute_map: Record<string, string>;
};
type AddressSyncSourceListResponse = { type AddressSyncSourceListResponse = {
sync_sources: AddressSyncSource[]; sync_sources: AddressSyncSource[];
}; };
@@ -305,6 +633,10 @@ type AddressCardDavDiscoveryResponse = {
address_books: AddressCardDavAddressBook[]; address_books: AddressCardDavAddressBook[];
}; };
type AddressCredentialEnvelopeListResponse = {
credentials: AddressCredentialEnvelope[];
};
type AddressSyncDiagnosticListResponse = { type AddressSyncDiagnosticListResponse = {
diagnostics: AddressSyncDiagnostic[]; diagnostics: AddressSyncDiagnostic[];
}; };
@@ -317,6 +649,26 @@ type AddressSyncConflictListResponse = {
conflicts: AddressSyncConflict[]; conflicts: AddressSyncConflict[];
}; };
type AddressImportProfileListResponse = {
profiles: AddressImportProfile[];
};
type AddressLdapDiscoveryResponse = {
base_dns: string[];
};
type ContactChannelRuleListResponse = {
rules: ContactChannelRule[];
};
type ContactPointQualityDecisionListResponse = {
decisions: ContactPointQualityDecision[];
};
type ContactMergeRecordListResponse = {
merges: ContactMergeRecord[];
};
function queryString(params: Record<string, string | number | null | undefined>): string { function queryString(params: Record<string, string | number | null | undefined>): string {
const search = new URLSearchParams(); const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) { for (const [key, value] of Object.entries(params)) {
@@ -450,7 +802,14 @@ export async function listAddressSyncSources(
export function discoverCardDavAddressBooks( export function discoverCardDavAddressBooks(
settings: ApiSettings, settings: ApiSettings,
payload: { url: string; auth_type: "none" | "basic" | "bearer"; username?: string | null; password?: string | null; bearer_token?: string | null } payload: {
url: string;
auth_type: "none" | "basic" | "bearer";
username?: string | null;
password?: string | null;
bearer_token?: string | null;
credential_ref?: string | null;
}
): Promise<AddressCardDavAddressBook[]> { ): Promise<AddressCardDavAddressBook[]> {
return apiFetch<AddressCardDavDiscoveryResponse>(settings, "/api/v1/addresses/carddav/discover", { return apiFetch<AddressCardDavDiscoveryResponse>(settings, "/api/v1/addresses/carddav/discover", {
method: "POST", method: "POST",
@@ -468,6 +827,7 @@ export function createCardDavSyncSource(
username?: string | null; username?: string | null;
password?: string | null; password?: string | null;
bearer_token?: string | null; bearer_token?: string | null;
credential_ref?: string | null;
sync_direction: "read_only" | "import" | "export" | "two_way"; sync_direction: "read_only" | "import" | "export" | "two_way";
read_only?: boolean | null; read_only?: boolean | null;
sync_token?: string | null; sync_token?: string | null;
@@ -481,6 +841,38 @@ export function createCardDavSyncSource(
}); });
} }
export function discoverLdapBaseDns(
settings: ApiSettings,
payload: Pick<AddressLdapSourcePayload, "url" | "credential_ref" | "bind_dn" | "start_tls" | "connect_timeout" | "receive_timeout">
): Promise<string[]> {
return apiFetch<AddressLdapDiscoveryResponse>(settings, "/api/v1/addresses/ldap/discover", {
method: "POST",
body: JSON.stringify(payload)
}).then((response) => response.base_dns);
}
export function createLdapSyncSource(
settings: ApiSettings,
addressBookId: string,
payload: AddressLdapSourcePayload
): Promise<AddressSyncSource> {
return apiFetch<AddressSyncSource>(settings, `/api/v1/addresses/address-books/${addressBookId}/ldap/sources`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listAddressCredentials(
settings: ApiSettings,
sourceId?: string | null
): Promise<AddressCredentialEnvelope[]> {
const response = await apiFetch<AddressCredentialEnvelopeListResponse>(
settings,
`/api/v1/addresses/credentials${queryString({ source_id: sourceId })}`
);
return response.credentials;
}
export function updateAddressSyncSource( export function updateAddressSyncSource(
settings: ApiSettings, settings: ApiSettings,
syncSourceId: string, syncSourceId: string,
@@ -549,11 +941,15 @@ export function resolveAddressSyncConflict(
}); });
} }
export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;query?: string | null;limit?: number;includeDeleted?: boolean;} = {}): Promise<Contact[]> { export function listContactsPage(settings: ApiSettings, options: {addressBookId?: string | null;addressListId?: string | null;query?: string | null;limit?: number;offset?: number;includeDeleted?: boolean;} = {}): Promise<ContactListResponse> {
const response = await apiFetch<ContactListResponse>( return apiFetch<ContactListResponse>(
settings, settings,
`/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, query: options.query, limit: options.limit, include_deleted: options.includeDeleted ? "true" : null })}` `/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, address_list_id: options.addressListId, query: options.query, limit: options.limit, offset: options.offset, include_deleted: options.includeDeleted ? "true" : null })}`
); );
}
export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;addressListId?: string | null;query?: string | null;limit?: number;offset?: number;includeDeleted?: boolean;} = {}): Promise<Contact[]> {
const response = await listContactsPage(settings, options);
return response.contacts; return response.contacts;
} }
@@ -579,6 +975,135 @@ export function restoreContact(settings: ApiSettings, contactId: string): Promis
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" }); return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" });
} }
export function getAddressQualitySummary(settings: ApiSettings, addressBookId: string): Promise<AddressQualitySummary> {
return apiFetch<AddressQualitySummary>(settings, `/api/v1/addresses/address-books/${addressBookId}/quality-summary`);
}
export function listContactDuplicateSuggestions(
settings: ApiSettings,
addressBookId: string,
options: { contactId?: string | null; minimumScore?: number; limit?: number; scanLimit?: number } = {}
): Promise<ContactDuplicateSuggestionList> {
return apiFetch<ContactDuplicateSuggestionList>(
settings,
`/api/v1/addresses/address-books/${addressBookId}/duplicate-suggestions${queryString({
contact_id: options.contactId,
minimum_score: options.minimumScore,
limit: options.limit,
scan_limit: options.scanLimit
})}`
);
}
export async function listContactQualityDecisions(settings: ApiSettings, contactId: string): Promise<ContactPointQualityDecision[]> {
const response = await apiFetch<ContactPointQualityDecisionListResponse>(
settings,
`/api/v1/addresses/contacts/${contactId}/quality-decisions`
);
return response.decisions;
}
export function createContactQualityDecision(
settings: ApiSettings,
contactId: string,
payload: {
channel: ContactPointQualityDecision["channel"];
contact_point_id?: string | null;
state: ContactPointQualityState;
reason_code?: string | null;
reason?: string | null;
evidence_ref?: string | null;
}
): Promise<ContactPointQualityDecision> {
return apiFetch<ContactPointQualityDecision>(settings, `/api/v1/addresses/contacts/${contactId}/quality-decisions`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function listContactProvenance(
settings: ApiSettings,
contactId: string,
options: { currentOnly?: boolean; limit?: number } = {}
): Promise<ContactFieldProvenance[]> {
return apiFetch<ContactFieldProvenance[]>(
settings,
`/api/v1/addresses/contacts/${contactId}/provenance${queryString({
current_only: options.currentOnly ? "true" : null,
limit: options.limit
})}`
);
}
export async function listContactMerges(
settings: ApiSettings,
options: { addressBookId?: string | null; contactId?: string | null; limit?: number } = {}
): Promise<ContactMergeRecord[]> {
const response = await apiFetch<ContactMergeRecordListResponse>(
settings,
`/api/v1/addresses/contact-merges${queryString({
address_book_id: options.addressBookId,
contact_id: options.contactId,
limit: options.limit
})}`
);
return response.merges;
}
export function mergeContacts(
settings: ApiSettings,
payload: {
winner_contact_id: string;
duplicate_contact_ids: string[];
reason: string;
field_sources?: Record<string, string>;
contact_point_strategy?: "union" | "winner_only";
source_precedence?: string[];
}
): Promise<ContactMergeRecord> {
return apiFetch<ContactMergeRecord>(settings, "/api/v1/addresses/contact-merges", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function recoverContactMerge(
settings: ApiSettings,
merge: ContactMergeRecord,
action: "undo" | "split",
reason: string
): Promise<ContactMergeRecord> {
return apiFetch<ContactMergeRecord>(settings, `/api/v1/addresses/contact-merges/${merge.id}/${action}`, {
method: "POST",
body: JSON.stringify({ reason, expected_after_hash: merge.after_hash })
});
}
export async function listContactChannelRules(settings: ApiSettings, contactId: string): Promise<ContactChannelRule[]> {
const response = await apiFetch<ContactChannelRuleListResponse>(
settings,
`/api/v1/addresses/contacts/${contactId}/channel-rules`
);
return response.rules;
}
export function createContactChannelRule(
settings: ApiSettings,
contactId: string,
payload: ContactChannelRulePayload
): Promise<ContactChannelRule> {
return apiFetch<ContactChannelRule>(settings, `/api/v1/addresses/contacts/${contactId}/channel-rules`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function endContactChannelRule(settings: ApiSettings, ruleId: string): Promise<ContactChannelRule> {
return apiFetch<ContactChannelRule>(settings, `/api/v1/addresses/contact-channel-rules/${ruleId}`, {
method: "DELETE"
});
}
export function importAddressBookVcards(settings: ApiSettings, addressBookId: string, content: string): Promise<VCardImportResult> { export function importAddressBookVcards(settings: ApiSettings, addressBookId: string, content: string): Promise<VCardImportResult> {
return apiFetch<VCardImportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, { return apiFetch<VCardImportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/import`, {
method: "POST", method: "POST",
@@ -586,10 +1111,129 @@ export function importAddressBookVcards(settings: ApiSettings, addressBookId: st
}); });
} }
export function previewVCardBatch(
settings: ApiSettings,
addressBookId: string,
payload: {
files: Array<{ filename: string; content_base64: string }>;
duplicate_card_policy?: "reject" | "first" | "last";
existing_contact_policy?: "update" | "ignore" | "reject";
}
): Promise<VCardBatchRun> {
return apiFetch<VCardBatchRun>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcard-batches/preview`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function getVCardBatch(settings: ApiSettings, runId: string): Promise<VCardBatchRun> {
return apiFetch<VCardBatchRun>(settings, `/api/v1/addresses/vcard-batches/${encodeURIComponent(runId)}`);
}
export function applyVCardBatch(
settings: ApiSettings,
run: VCardBatchRun,
selections: Array<{ source_key: string; action: "create" | "update" | "ignore" }>
): Promise<VCardBatchRun> {
return apiFetch<VCardBatchRun>(settings, `/api/v1/addresses/vcard-batches/${encodeURIComponent(run.id)}/apply`, {
method: "POST",
body: JSON.stringify({ expected_plan_hash: run.plan_hash, selections })
});
}
export function cancelVCardBatch(settings: ApiSettings, run: VCardBatchRun, reason: string): Promise<VCardBatchRun> {
return apiFetch<VCardBatchRun>(settings, `/api/v1/addresses/vcard-batches/${encodeURIComponent(run.id)}/cancel`, {
method: "POST",
body: JSON.stringify({ expected_plan_hash: run.plan_hash, reason })
});
}
export function exportVCardBatchDiagnostics(settings: ApiSettings, runId: string): Promise<string> {
return apiFetch<string>(settings, `/api/v1/addresses/vcard-batches/${encodeURIComponent(runId)}/diagnostics`);
}
export async function listAddressImportProfiles(settings: ApiSettings): Promise<AddressImportProfile[]> {
const response = await apiFetch<AddressImportProfileListResponse>(settings, "/api/v1/addresses/import-profiles");
return response.profiles;
}
export function createAddressImportProfile(
settings: ApiSettings,
payload: {
scope_type: AddressBookScope;
scope_id?: string | null;
name: string;
description?: string | null;
source_format: "csv" | "xlsx" | "ldif";
configuration: AddressImportConfiguration;
}
): Promise<AddressImportProfile> {
return apiFetch<AddressImportProfile>(settings, "/api/v1/addresses/import-profiles", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateAddressImportProfile(
settings: ApiSettings,
profileId: string,
payload: { name?: string; description?: string | null; configuration?: AddressImportConfiguration }
): Promise<AddressImportProfile> {
return apiFetch<AddressImportProfile>(settings, `/api/v1/addresses/import-profiles/${profileId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function previewAddressImport(
settings: ApiSettings,
addressBookId: string,
payload: { profile_id: string; filename: string; content_base64: string }
): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/address-books/${addressBookId}/imports/preview`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function getAddressImportRun(settings: ApiSettings, runId: string): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(runId)}`);
}
export function applyAddressImport(settings: ApiSettings, run: AddressImportRun): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(run.id)}/apply`, {
method: "POST",
body: JSON.stringify({ expected_plan_hash: run.plan_hash })
});
}
export function rollbackAddressImport(settings: ApiSettings, run: AddressImportRun, reason: string): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${encodeURIComponent(run.id)}/rollback`, {
method: "POST",
body: JSON.stringify({ expected_plan_hash: run.plan_hash, reason })
});
}
export function exportAddressBookVcards(settings: ApiSettings, addressBookId: string): Promise<string> { export function exportAddressBookVcards(settings: ApiSettings, addressBookId: string): Promise<string> {
return apiFetch<string>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/export`); return apiFetch<string>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/export`);
} }
export function exportScopedVcards(
settings: ApiSettings,
addressBookId: string,
payload: {
scope: "address_book" | "address_list" | "contacts";
address_list_id?: string | null;
contact_ids?: string[];
version?: "3.0" | "4.0";
}
): Promise<VCardExportResult> {
return apiFetch<VCardExportResult>(settings, `/api/v1/addresses/address-books/${addressBookId}/vcards/export`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function exportContactVcard(settings: ApiSettings, contactId: string): Promise<string> { export function exportContactVcard(settings: ApiSettings, contactId: string): Promise<string> {
return apiFetch<string>(settings, `/api/v1/addresses/contacts/${contactId}/vcard`); return apiFetch<string>(settings, `/api/v1/addresses/contacts/${contactId}/vcard`);
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
export type ImportRunLifecycle = {
label: string;
tone: "info" | "success" | "warning" | "inactive";
canApply: boolean;
canRollback: boolean;
guidance: string;
};
export function importRunIdFromSearch(search: URLSearchParams): string {
return search.get("import_run")?.trim() ?? "";
}
export function withImportRunSearch(search: URLSearchParams, runId?: string | null): URLSearchParams {
const next = new URLSearchParams(search);
const normalized = runId?.trim();
if (normalized) next.set("import_run", normalized);
else next.delete("import_run");
return next;
}
export function importRunLifecycle(status: string): ImportRunLifecycle {
if (status === "previewed") {
return {
label: "Ready for review",
tone: "info",
canApply: true,
canRollback: false,
guidance: "Review the persisted effects and diagnostics before applying this plan."
};
}
if (status === "applied") {
return {
label: "Applied",
tone: "success",
canApply: false,
canRollback: true,
guidance: "The plan has already been applied. Rollback remains guarded by its recorded plan hash and contact evidence."
};
}
if (status === "rolled_back") {
return {
label: "Rolled back",
tone: "inactive",
canApply: false,
canRollback: false,
guidance: "This run was rolled back and cannot be applied again. Create a new preview to import the source again."
};
}
if (status === "expired") {
return {
label: "Expired",
tone: "warning",
canApply: false,
canRollback: false,
guidance: "This preview is no longer actionable. Create a new preview from the original source."
};
}
return {
label: status || "Unavailable",
tone: "warning",
canApply: false,
canRollback: false,
guidance: "This run is not actionable in its current lifecycle state."
};
}
export function unavailableImportRunMessage(status?: number): string {
if (status === 404 || status === 410) {
return "This import run is missing, expired, or not available to your tenant. No source data was loaded.";
}
return "The persisted import run could not be loaded. Reload it after the service becomes available.";
}
@@ -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;
+82 -2
View File
@@ -45,7 +45,47 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Tenant directory and approved shared contacts.", "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.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.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.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: { de: {
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Kontakt hinzufügen", "i18n:govoplan-addresses.add_contact.6da0b4b8": "Kontakt hinzufügen",
@@ -91,6 +131,46 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Mandantenverzeichnis und freigegebene geteilte Kontakte.", "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.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.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.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"
} }
}; };
+9 -1
View File
@@ -17,8 +17,16 @@ export const addressesModule: PlatformWebModule = {
dependencies: [], dependencies: [],
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"], optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
translations, 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 }], navItems: [{ to: "/address-book", label: "i18n:govoplan-addresses.address_book.f6327f59", iconName: "book-user", anyOf: ["addresses:contact:read"], order: 80 }],
routes: [{ path: "/address-book", anyOf: ["addresses:contact:read"], order: 80, render: ({ settings, auth, onAuthChange }) => createElement(AddressBookPage, { settings, auth, onAuthChange }) }] 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; export default addressesModule;
+259 -5
View File
@@ -9,7 +9,7 @@
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
border: var(--border-line); border: var(--border-line);
border-radius: 6px; border-radius: var(--radius-compact);
background: var(--panel-soft); background: var(--panel-soft);
padding: 14px; padding: 14px;
} }
@@ -150,6 +150,75 @@
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-run-state {
align-items: center;
background: var(--panel-soft);
border: var(--border-line);
border-radius: var(--radius-compact);
display: grid;
gap: 10px 14px;
grid-template-columns: minmax(180px, 1fr) auto minmax(220px, 1.4fr) auto;
padding: 10px 12px;
}
.address-import-run-state p {
margin: 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: var(--radius-compact);
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-record-list,
.address-sync-plan-grid { .address-sync-plan-grid {
display: grid; display: grid;
@@ -308,6 +377,11 @@
align-content: start; align-content: start;
} }
.address-contact-pagination {
border-top: var(--border-line);
flex: 0 0 auto;
}
.address-contact-selection-list { .address-contact-selection-list {
gap: 2px; gap: 2px;
} }
@@ -347,7 +421,7 @@
.address-tag { .address-tag {
background: var(--panel-soft); background: var(--panel-soft);
border: var(--border-line); border: var(--border-line);
border-radius: 999px; border-radius: var(--radius-pill);
color: var(--muted); color: var(--muted);
display: inline-flex; display: inline-flex;
font-size: 0.75rem; font-size: 0.75rem;
@@ -415,6 +489,23 @@
overflow-wrap: anywhere; 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 { .address-membership-row {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -477,7 +568,7 @@
.address-member-candidate-list { .address-member-candidate-list {
border: var(--border-line); border: var(--border-line);
border-radius: 6px; border-radius: var(--radius-compact);
display: grid; display: grid;
max-height: min(460px, calc(100vh - 300px)); max-height: min(460px, calc(100vh - 300px));
overflow: auto; overflow: auto;
@@ -510,9 +601,19 @@
min-height: 280px; 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 { .address-form-section {
border: var(--border-line); border: var(--border-line);
border-radius: 6px; border-radius: var(--radius-compact);
display: grid; display: grid;
gap: 10px; gap: 10px;
padding: 12px; padding: 12px;
@@ -534,6 +635,149 @@
gap: 8px; 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: var(--radius-compact);
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-email,
.address-form-row-phone { .address-form-row-phone {
grid-template-columns: 92px minmax(88px, 0.3fr) minmax(220px, 1fr) 34px; grid-template-columns: 92px minmax(88px, 0.3fr) minmax(220px, 1fr) 34px;
@@ -580,11 +824,21 @@
opacity: 0.72; opacity: 0.72;
} }
@media (max-width: 980px) { @media (max-width: 1100px) {
.address-book-workspace, .address-book-workspace,
.address-import-run-state,
.address-form-row-email, .address-form-row-email,
.address-form-row-phone, .address-form-row-phone,
.address-form-row-postal { .address-form-row-postal {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.address-quality-row,
.address-contact-point-value {
grid-template-columns: 1fr;
}
.address-contact-point-value > small {
grid-column: auto;
}
} }
+22
View File
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import {
importRunIdFromSearch,
importRunLifecycle,
unavailableImportRunMessage,
withImportRunSearch
} from "../src/features/addressbook/importRunState";
const initial = new URLSearchParams("q=Ada&import_run=run-123");
assert.equal(importRunIdFromSearch(initial), "run-123");
assert.equal(withImportRunSearch(initial, "run-456").toString(), "q=Ada&import_run=run-456");
assert.equal(withImportRunSearch(initial, null).toString(), "q=Ada");
assert.equal(importRunLifecycle("previewed").canApply, true);
assert.equal(importRunLifecycle("previewed").canRollback, false);
assert.equal(importRunLifecycle("applied").canApply, false);
assert.equal(importRunLifecycle("applied").canRollback, true);
assert.equal(importRunLifecycle("rolled_back").canRollback, false);
assert.equal(importRunLifecycle("expired").label, "Expired");
assert.match(unavailableImportRunMessage(404), /missing, expired, or not available to your tenant/);
console.log("Address import-run deep-link and lifecycle state tests passed.");
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"typeRoots": ["../../govoplan-core/webui/node_modules/@types"],
"types": ["node"],
"outDir": ".import-run-test-build"
},
"include": [
"src/features/addressbook/importRunState.ts",
"tests/import-run-state.test.ts"
]
}