20 Commits
Author SHA1 Message Date
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
37 changed files with 12921 additions and 222 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Addresses Codex Guide
## Scope
This repository owns reusable postal and electronic address records, address books, normalization, and governed address references for consuming modules.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Addresses internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- Keep identity and organization ownership in their respective modules.
- Expose optional integrations through Core capabilities and typed references.
+62 -12
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.
@@ -43,6 +42,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 +63,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 +78,26 @@ 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.
`addresses.recipient_source` returns: `addresses.recipient_source` returns:
@@ -97,10 +115,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 +129,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)
+118 -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,89 @@ 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 Tabular Imports
CSV and XLSX use versioned, scoped mapping profiles rather than live sync
sources. Profiles retain delimiter, encoding, header or worksheet selection,
stable source-key mapping, field mappings, locale and tags, row limits, and
explicit duplicate, blank-value, and existing-contact policies. Updating a
profile creates an immutable next version; prior import runs continue to point
at the reviewed version.
Preview decodes at most 10 MB and 10,000 rows, validates every referenced
column and source key, and returns an effect or diagnostic for every data row.
XLSX parsing is read-only; formulas are rejected and macros/legacy workbook
formats are not accepted. The input SHA-256 and deterministic plan hash are
stored with full effects. Apply uses exactly that plan, rejects changed target
contacts, and is idempotent. Created IDs and pre-update snapshots provide a
guarded rollback: rollback proceeds only while each imported contact still
matches its recorded post-apply hash. Arbitrary transforms remain Dataflow's
responsibility; Files and Datasources are optional origins, not prerequisites
for direct upload.
## Quality, Deduplication, And Recovery
Quality is evidence about a concrete contact point, separate from communication
consent or Policy. Effective decisions use one of `valid`, `invalid`,
`returned`, `stale`, or `undeliverable`, retain reason/evidence references, and
end an overlapping prior decision rather than rewriting history. Recipient
capabilities project the current decision into a stable status and reason code;
consumers can exclude invalid points or explicitly handle stale points without
copying Addresses rules.
Duplicate suggestions are bounded to 500 scanned contacts and 100 returned
pairs. Every score is composed from visible exact-match features such as a
normalized email, phone, postal address, or name/organization combination. A
suggestion does not mutate data.
A merge is an explicit, transactional decision. The caller selects a surviving
contact, scalar-field sources, source precedence, and either union or
survivor-only contact-point handling. The merge records before/after evidence
and hashes, field/contact-point decisions, copied quality/governance evidence,
and stable loser-to-winner redirects. Address-list entries are repointed in the
same transaction. Undo and split restore the recorded contacts and memberships
only when the current evidence still matches the post-merge hash; later edits
must be reconciled first. Core change-sequence evidence is always written. Core
audit entries are written by HTTP mutation routes without requiring the
optional Audit module.
## Connector Direction ## 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 +271,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` - [ ] [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
+38
View File
@@ -0,0 +1,38 @@
# Addresses Interface Pattern Migration
This migration applies the GovOPlaN interface pattern language to the Address
Book route, source tree, contact directory/detail workspace, governance facts,
imports, synchronization, quality review, and reversible merge operations.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/address-book` source tree | Governed directory | Select, create, archive, restore, import, or connect source | Shared loading/empty/error, permission, read-only, help, and destructive confirmation states |
| Contact list/detail | Directory/list-detail | Inspect, create, revise, archive, restore, export, or add to list | Server pagination, stable selection, contextual contact semantics, and guarded drafts |
| Address-list editor/membership | Reference collection editor | Group reusable contact points | Same-book constraint, duplicate explanation, read-only provenance, and retained references |
| Communication governance | Effective-policy fact editor | Allow, suppress, prefer, or end a channel fact | Effective dates, legal/evidence context, required permissions, and retained history |
| CardDAV/LDAP/import/sync | External-provider operation | Preview and apply bounded external change | Explicit authority/direction, credentials, diagnostics, conflicts, stale state, and outcome evidence |
| Quality and merge | Governed correction workflow | Record quality, merge, undo, or split | Required reason, chosen survivor/field provenance, confirmation, redirects, and reversible evidence |
## Consequence And Availability Rules
- User, group, tenant, and authorized system scopes determine discovery and
management authority. Visible inherited or external sources can be read-only.
- Address lists group contact points in one address book. Cross-module reusable
recipient expansion remains owned by Distribution Lists.
- Archive/delete actions are confirmed and preserve governed history according
to retention. External-source disconnect retains local contacts but removes
source diagnostics and conflict state as explicitly stated.
- Imports and synchronization separate preview from apply; incomplete external
reads never infer deletions.
- Merge and communication-governance operations append auditable evidence and
never silently erase prior state.
- Request feedback is rendered as a compact shared alert over the full-height
workspace. It does not become a grid row or displace the source, contact, and
detail columns.
Backend and WebUI manifests publish matching route/section/action surfaces.
English and German catalogues include the owned interaction vocabulary; major
object drafts are guarded, and optional modules remain behind declared
capabilities rather than private imports.
+57
View File
@@ -0,0 +1,57 @@
# Address Quality And Reversible Merges
## Operator Workflow
Open the shield action for a selected address book to review its quality. The
dialog shows:
- the number of contacts and contact points in the bounded scan
- current invalid, returned, stale, and undeliverable contact points
- explainable duplicate suggestions with their score inputs
- active and recovered merge records
Each contact point also has a `Quality` action in the contact detail. Recording
a new state ends an overlapping current state and retains both entries in
history. Use a stable reason code and an evidence reference when the state came
from delivery, import, or correction evidence.
`valid` makes the point normally usable. `invalid`, `returned`, and
`undeliverable` make it invalid for recipient resolution. `stale` remains a
distinct status so a downstream workflow can warn, request confirmation, or
block according to Policy. A later `valid` decision is a correction; it does
not delete the earlier evidence.
## Duplicate Review
Suggestions do not merge automatically. The score is the bounded sum of named
exact-match features. The operator chooses the surviving contact and whether to
combine unique contact points or retain only the survivor's points. The API can
additionally select the source contact for each scalar field and rank source
kinds.
A successful merge:
- archives each duplicate and redirects its stable contact ID to the survivor
- records scalar and contact-point survivorship decisions
- carries field and contact-point source provenance forward
- copies applicable quality and communication-governance evidence
- repoints address-list entries to the survivor and mapped contact point
- stores deterministic before/after evidence hashes
- emits core change-sequence and audit evidence
The merge history offers `Undo` and `Split`. Both restore the exact recorded
pre-merge contacts and list memberships. Recovery is deliberately rejected when
the contact or membership evidence changed after the merge. Reconcile those
later edits before retrying; the system does not silently discard them.
## Consumer Contract
Consumers resolve live contacts through `addresses.contact_point_resolution` or
`distribution.recipient_channel_facts`. They receive quality status, stable
reason codes, evidence provenance, and the current source revision. Consumers
must not read Addresses tables or recreate quality rules. A workflow requiring
historical proof freezes a contact-point snapshot before delivery.
The duplicate and quality endpoints are bounded. `truncated=true` means the
operator should narrow the source or run a staged API review; it does not mean
that the unreturned contacts were found clean.
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/addresses-webui", "name": "@govoplan/addresses-webui",
"version": "0.1.9", "version": "0.1.15",
"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.15",
"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.15"
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.15",
"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] = mapped_column(
ForeignKey("addresses_import_profiles.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
source_filename: Mapped[str] = mapped_column(String(500), nullable=False)
source_format: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
input_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
plan_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="previewed", index=True)
row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
statistics: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
diagnostics: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
plan_data: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
result_evidence: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
rolled_back_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
address_book: Mapped[AddressBook] = relationship()
profile: Mapped[AddressImportProfile] = relationship()
__all__ = [ __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",
] ]
@@ -0,0 +1,172 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
AddressImportFormat = Literal["csv", "xlsx"]
AddressImportScope = Literal["user", "group", "tenant", "system"]
IMPORT_TARGET_FIELDS = frozenset(
{
"source_key",
"display_name",
"given_name",
"family_name",
"organization",
"role_title",
"note",
"email",
"phone",
"street",
"postal_code",
"locality",
"region",
"country",
"tags",
"visibility",
}
)
class AddressImportConfiguration(BaseModel):
field_mappings: dict[str, str] = Field(default_factory=dict, max_length=40)
delimiter: Literal[",", ";", "\t", "|"] = ","
encoding: Literal["utf-8", "utf-8-sig", "cp1252", "latin-1"] = "utf-8-sig"
header_row: int = Field(default=1, ge=1, le=100)
sheet_name: str | None = Field(default=None, max_length=255)
source_key_column: str | None = Field(default=None, max_length=255)
duplicate_source_key_policy: Literal["reject", "first", "last"] = "reject"
existing_contact_policy: Literal["update", "ignore", "reject"] = "update"
blank_value_policy: Literal["ignore", "clear", "reject"] = "ignore"
locale: str | None = Field(default=None, max_length=35)
default_tags: list[str] = Field(default_factory=list, max_length=100)
max_rows: int = Field(default=10_000, ge=1, le=10_000)
@model_validator(mode="after")
def validate_mappings(self) -> "AddressImportConfiguration":
invalid = sorted(set(self.field_mappings).difference(IMPORT_TARGET_FIELDS))
if invalid:
raise ValueError(f"Unsupported address import target fields: {', '.join(invalid)}")
for target, column in self.field_mappings.items():
if not target.strip() or not column.strip():
raise ValueError("Import mapping targets and source columns cannot be blank.")
if "source_key" not in self.field_mappings and not self.source_key_column:
raise ValueError("Address import profiles require a stable source-key column.")
return self
class AddressImportProfileCreateRequest(BaseModel):
scope_type: AddressImportScope = "tenant"
scope_id: str | None = Field(default=None, max_length=36)
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
source_format: AddressImportFormat
configuration: AddressImportConfiguration
class AddressImportProfileUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
configuration: AddressImportConfiguration | None = None
class AddressImportProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
profile_key: str
version: int
tenant_id: str | None = None
scope_type: str
scope_id: str | None = None
name: str
description: str | None = None
source_format: str
configuration: AddressImportConfiguration
is_current: bool
created_by_account_id: str | None = None
superseded_at: datetime | None = None
created_at: datetime
updated_at: datetime
class AddressImportProfileListResponse(BaseModel):
profiles: list[AddressImportProfileResponse] = Field(default_factory=list)
class AddressImportFilePayload(BaseModel):
filename: str = Field(min_length=1, max_length=500)
content_base64: str = Field(min_length=1, max_length=14_000_000)
class AddressImportPreviewRequest(AddressImportFilePayload):
profile_id: str = Field(min_length=1, max_length=36)
class AddressImportEffectResponse(BaseModel):
row_number: int
action: Literal["create", "update", "conflict", "unchanged", "ignored"]
source_key: str | None = None
contact_id: str | None = None
display_name: str | None = None
changed_fields: list[str] = Field(default_factory=list)
message: str | None = None
class AddressImportDiagnosticResponse(BaseModel):
severity: Literal["info", "warning", "error"]
code: str
message: str
row_number: int | None = None
field: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
class AddressImportRunResponse(BaseModel):
id: str
address_book_id: str
profile_id: str
source_filename: str
source_format: str
input_hash: str
plan_hash: str
status: str
row_count: int
statistics: dict[str, int] = Field(default_factory=dict)
diagnostics: list[AddressImportDiagnosticResponse] = Field(default_factory=list)
effects: list[AddressImportEffectResponse] = Field(default_factory=list)
can_apply: bool
result_evidence: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
applied_at: datetime | None = None
rolled_back_at: datetime | None = None
class AddressImportCommitRequest(BaseModel):
expected_plan_hash: str = Field(min_length=64, max_length=64)
class AddressImportRollbackRequest(BaseModel):
reason: str = Field(min_length=3, max_length=2000)
__all__ = [
"AddressImportCommitRequest",
"AddressImportConfiguration",
"AddressImportDiagnosticResponse",
"AddressImportEffectResponse",
"AddressImportFilePayload",
"AddressImportFormat",
"AddressImportPreviewRequest",
"AddressImportProfileCreateRequest",
"AddressImportProfileListResponse",
"AddressImportProfileResponse",
"AddressImportProfileUpdateRequest",
"AddressImportRollbackRequest",
"AddressImportRunResponse",
"IMPORT_TARGET_FIELDS",
]
+911
View File
@@ -0,0 +1,911 @@
from __future__ import annotations
import base64
import binascii
import csv
import hashlib
import json
from collections import Counter
from io import BytesIO, StringIO
from typing import Any
from sqlalchemy import and_, false, or_
from sqlalchemy.orm import Session
from govoplan_addresses.backend.db.models import (
AddressImportProfile,
AddressImportRun,
Contact,
)
from govoplan_addresses.backend.import_schemas import (
AddressImportConfiguration,
AddressImportPreviewRequest,
AddressImportProfileCreateRequest,
AddressImportProfileUpdateRequest,
AddressImportRollbackRequest,
)
from govoplan_addresses.backend.schemas import (
ContactCreateRequest,
ContactEmailPayload,
ContactPhonePayload,
ContactPostalAddressPayload,
ContactUpdateRequest,
)
from govoplan_addresses.backend.service import (
AddressBookError,
create_contact,
delete_contact,
get_visible_address_book,
get_visible_contact,
restore_contact,
update_contact,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.db.base import utcnow
MAX_IMPORT_BYTES = 10_000_000
MAX_IMPORT_COLUMNS = 200
def _account_id(principal: ApiPrincipal) -> str:
return principal.account_id
def _tenant_id(principal: ApiPrincipal) -> str:
return principal.tenant_id
def _profile_scope_predicate(principal: ApiPrincipal):
tenant_id = _tenant_id(principal)
predicates = [AddressImportProfile.scope_type == "system"]
predicates.extend(
[
and_(AddressImportProfile.tenant_id == tenant_id, AddressImportProfile.scope_type == "tenant"),
and_(
AddressImportProfile.tenant_id == tenant_id,
AddressImportProfile.scope_type == "user",
AddressImportProfile.scope_id == _account_id(principal),
),
]
)
group_ids = tuple(principal.group_ids)
if group_ids:
predicates.append(
and_(
AddressImportProfile.tenant_id == tenant_id,
AddressImportProfile.scope_type == "group",
AddressImportProfile.scope_id.in_(group_ids),
)
)
return or_(*predicates) if predicates else false()
def list_import_profiles(
session: Session,
principal: ApiPrincipal,
*,
include_history: bool = False,
) -> list[AddressImportProfile]:
query = session.query(AddressImportProfile).filter(_profile_scope_predicate(principal))
if not include_history:
query = query.filter(AddressImportProfile.is_current.is_(True))
return query.order_by(AddressImportProfile.name.asc(), AddressImportProfile.version.desc()).all()
def get_import_profile(
session: Session,
principal: ApiPrincipal,
profile_id: str,
) -> AddressImportProfile:
profile = (
session.query(AddressImportProfile)
.filter(_profile_scope_predicate(principal), AddressImportProfile.id == profile_id)
.one_or_none()
)
if profile is None:
raise AddressBookError("Address import profile not found.")
return profile
def create_import_profile(
session: Session,
principal: ApiPrincipal,
payload: AddressImportProfileCreateRequest,
) -> AddressImportProfile:
tenant_id, scope_id = _validated_profile_scope(principal, payload.scope_type, payload.scope_id)
profile = AddressImportProfile(
tenant_id=tenant_id,
scope_type=payload.scope_type,
scope_id=scope_id,
name=payload.name.strip(),
description=_trim(payload.description),
source_format=payload.source_format,
configuration=payload.configuration.model_dump(mode="json"),
is_current=True,
created_by_account_id=_account_id(principal),
)
session.add(profile)
return profile
def update_import_profile(
session: Session,
principal: ApiPrincipal,
profile_id: str,
payload: AddressImportProfileUpdateRequest,
) -> AddressImportProfile:
current = get_import_profile(session, principal, profile_id)
if not current.is_current:
raise AddressBookError("Only the current import profile version can be updated.")
current.is_current = False
current.superseded_at = utcnow()
next_profile = AddressImportProfile(
profile_key=current.profile_key,
version=current.version + 1,
tenant_id=current.tenant_id,
scope_type=current.scope_type,
scope_id=current.scope_id,
name=(payload.name.strip() if payload.name is not None else current.name),
description=(payload.description.strip() or None if payload.description is not None else current.description),
source_format=current.source_format,
configuration=(
payload.configuration.model_dump(mode="json")
if payload.configuration is not None
else dict(current.configuration or {})
),
is_current=True,
created_by_account_id=_account_id(principal),
)
session.add(next_profile)
return next_profile
def retire_import_profile(
session: Session,
principal: ApiPrincipal,
profile_id: str,
) -> None:
profile = get_import_profile(session, principal, profile_id)
if profile.scope_type == "system" and not principal.has("addresses:address_book:admin"):
raise AddressBookError("System import profiles require address-book administration permission.")
profile.is_current = False
profile.superseded_at = utcnow()
def preview_address_import(
session: Session,
principal: ApiPrincipal,
address_book_id: str,
payload: AddressImportPreviewRequest,
) -> AddressImportRun:
book = get_visible_address_book(session, principal, address_book_id)
if book.read_only:
raise AddressBookError("Static imports require a writable address book.")
profile = get_import_profile(session, principal, payload.profile_id)
raw = _decode_payload(payload.content_base64)
input_hash = hashlib.sha256(raw).hexdigest()
config = AddressImportConfiguration.model_validate(profile.configuration)
rows, parse_diagnostics = _parse_rows(
raw,
filename=payload.filename,
source_format=profile.source_format,
config=config,
)
plan_data, map_diagnostics = _plan_rows(
session,
book_id=book.id,
profile=profile,
input_hash=input_hash,
rows=rows,
config=config,
)
diagnostics = [*parse_diagnostics, *map_diagnostics]
statistics = dict(Counter(item["action"] for item in plan_data))
statistics["rows"] = len(rows)
statistics["errors"] = sum(item["severity"] == "error" for item in diagnostics)
statistics["warnings"] = sum(item["severity"] == "warning" for item in diagnostics)
plan_hash = _hash_json(
{
"profile_id": profile.id,
"profile_version": profile.version,
"address_book_id": book.id,
"input_hash": input_hash,
"plan": plan_data,
}
)
run = AddressImportRun(
tenant_id=book.tenant_id,
address_book_id=book.id,
profile_id=profile.id,
source_filename=payload.filename.strip(),
source_format=profile.source_format,
input_hash=input_hash,
plan_hash=plan_hash,
status="previewed",
row_count=len(rows),
statistics=statistics,
diagnostics=diagnostics,
plan_data=plan_data,
result_evidence={},
created_by_account_id=_account_id(principal),
)
session.add(run)
session.flush()
return run
def get_import_run(
session: Session,
principal: ApiPrincipal,
run_id: str,
) -> AddressImportRun:
visible_book_ids = [book.id for book in _visible_import_books(session, principal)]
if not visible_book_ids:
raise AddressBookError("Address import run not found.")
run = (
session.query(AddressImportRun)
.filter(AddressImportRun.id == run_id, AddressImportRun.address_book_id.in_(visible_book_ids))
.one_or_none()
)
if run is None:
raise AddressBookError("Address import run not found.")
return run
def apply_address_import(
session: Session,
principal: ApiPrincipal,
run_id: str,
*,
expected_plan_hash: str,
) -> AddressImportRun:
run = get_import_run(session, principal, run_id)
if run.status == "applied":
return run
if run.status != "previewed":
raise AddressBookError(f"Import run cannot be applied from status {run.status!r}.")
if run.plan_hash != expected_plan_hash:
raise AddressBookError("The reviewed import plan changed; create a new preview.")
if any(item.get("severity") == "error" for item in run.diagnostics or []):
raise AddressBookError("Import plans with error diagnostics cannot be applied.")
if any(item.get("action") == "conflict" for item in run.plan_data or []):
raise AddressBookError("Resolve import conflicts by correcting the file or mapping profile and preview again.")
created_ids: list[str] = []
updated: list[dict[str, Any]] = []
for item in run.plan_data or []:
action = item.get("action")
if action in {"ignored", "unchanged"}:
continue
source_ref = str(item["source_ref"])
existing = _contact_by_source_ref(session, run.address_book_id, source_ref)
if action == "create":
if existing is not None and existing.deleted_at is None:
raise AddressBookError("A target contact appeared after preview; preview the import again.")
contact = create_contact(
session,
principal,
run.address_book_id,
ContactCreateRequest.model_validate(item["payload"]),
)
_stamp_import_contact(contact, run=run, item=item)
session.flush()
created_ids.append(contact.id)
item["contact_id"] = contact.id
item["after_hash"] = _contact_hash(contact)
elif action == "update":
if existing is None:
raise AddressBookError("An import target disappeared after preview; preview the import again.")
if _contact_hash(existing) != item.get("expected_contact_hash"):
raise AddressBookError(
f'Contact "{existing.display_name}" changed after preview; preview the import again.'
)
before = _contact_snapshot(existing)
if existing.deleted_at is not None:
restore_contact(session, principal, existing.id)
contact = update_contact(
session,
principal,
existing.id,
ContactUpdateRequest.model_validate(item["payload"]),
)
_stamp_import_contact(contact, run=run, item=item)
session.flush()
updated.append({"contact_id": contact.id, "before": before, "after_hash": _contact_hash(contact)})
item["contact_id"] = contact.id
run.status = "applied"
run.applied_at = utcnow()
run.plan_data = list(run.plan_data or [])
run.result_evidence = {
"input_hash": run.input_hash,
"plan_hash": run.plan_hash,
"created_contact_ids": created_ids,
"updated_contacts": updated,
"applied_by_account_id": _account_id(principal),
"applied_at": run.applied_at.isoformat(),
}
return run
def rollback_address_import(
session: Session,
principal: ApiPrincipal,
run_id: str,
payload: AddressImportRollbackRequest,
) -> AddressImportRun:
run = get_import_run(session, principal, run_id)
if run.status == "rolled_back":
return run
if run.status != "applied":
raise AddressBookError("Only an applied import can be rolled back.")
evidence = dict(run.result_evidence or {})
updated = list(evidence.get("updated_contacts") or [])
created_ids = list(evidence.get("created_contact_ids") or [])
expected_hashes = {
str(item["contact_id"]): str(item["after_hash"])
for item in updated
}
expected_hashes.update(
{
str(item["contact_id"]): str(item["after_hash"])
for item in run.plan_data or []
if item.get("contact_id") in created_ids and item.get("after_hash")
}
)
for contact_id, expected_hash in expected_hashes.items():
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
if _contact_hash(contact) != expected_hash:
raise AddressBookError(
f'Contact "{contact.display_name}" changed after import; automatic rollback is unsafe.'
)
for contact_id in created_ids:
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
if contact.deleted_at is None:
delete_contact(session, principal, contact.id)
for item in updated:
contact = get_visible_contact(session, principal, str(item["contact_id"]), include_deleted=True)
snapshot = dict(item["before"])
if contact.deleted_at is not None:
restore_contact(session, principal, contact.id)
update_contact(
session,
principal,
contact.id,
ContactUpdateRequest.model_validate(snapshot["payload"]),
)
contact.source_kind = snapshot.get("source_kind") or "local"
contact.source_ref = snapshot.get("source_ref")
contact.source_revision = snapshot.get("source_revision")
contact.source_payload_kind = snapshot.get("source_payload_kind")
contact.source_payload_raw = snapshot.get("source_payload_raw")
contact.provenance = dict(snapshot.get("provenance") or {})
contact.metadata_ = dict(snapshot.get("metadata") or {})
run.status = "rolled_back"
run.rolled_back_at = utcnow()
run.result_evidence = {
**evidence,
"rollback_reason": payload.reason,
"rolled_back_by_account_id": _account_id(principal),
"rolled_back_at": run.rolled_back_at.isoformat(),
}
return run
def import_run_payload(run: AddressImportRun) -> dict[str, Any]:
diagnostics = list(run.diagnostics or [])
effects = [
{
"row_number": int(item["row_number"]),
"action": item["action"],
"source_key": item.get("source_key"),
"contact_id": item.get("contact_id"),
"display_name": item.get("display_name"),
"changed_fields": list(item.get("changed_fields") or []),
"message": item.get("message"),
}
for item in run.plan_data or []
]
can_apply = (
run.status == "previewed"
and not any(item.get("severity") == "error" for item in diagnostics)
and not any(item.get("action") == "conflict" for item in run.plan_data or [])
)
evidence = dict(run.result_evidence or {})
public_evidence = {
key: evidence[key]
for key in (
"input_hash",
"plan_hash",
"applied_by_account_id",
"applied_at",
"rollback_reason",
"rolled_back_by_account_id",
"rolled_back_at",
)
if evidence.get(key) is not None
}
if evidence:
public_evidence["created_contact_count"] = len(evidence.get("created_contact_ids") or [])
public_evidence["updated_contact_count"] = len(evidence.get("updated_contacts") or [])
return {
"id": run.id,
"address_book_id": run.address_book_id,
"profile_id": run.profile_id,
"source_filename": run.source_filename,
"source_format": run.source_format,
"input_hash": run.input_hash,
"plan_hash": run.plan_hash,
"status": run.status,
"row_count": run.row_count,
"statistics": dict(run.statistics or {}),
"diagnostics": diagnostics,
"effects": effects,
"can_apply": can_apply,
# Full before-images remain private rollback evidence and must not be
# projected through a normal import-run read response.
"result_evidence": public_evidence,
"created_at": run.created_at,
"updated_at": run.updated_at,
"applied_at": run.applied_at,
"rolled_back_at": run.rolled_back_at,
}
def _validated_profile_scope(
principal: ApiPrincipal,
scope_type: str,
requested_scope_id: str | None,
) -> tuple[str | None, str | None]:
if scope_type == "system":
if not principal.has("addresses:address_book:admin"):
raise AddressBookError("System import profiles require address-book administration permission.")
return None, None
tenant_id = _tenant_id(principal)
if scope_type == "tenant":
return tenant_id, tenant_id
if scope_type == "user":
return tenant_id, _account_id(principal)
if scope_type == "group":
scope_id = _trim(requested_scope_id)
if scope_id is None:
raise AddressBookError("Group import profiles require a group id.")
if scope_id not in principal.group_ids and not principal.has("addresses:address_book:admin"):
raise AddressBookError("The selected group is not visible to the current principal.")
return tenant_id, scope_id
raise AddressBookError("Unsupported import profile scope.")
def _decode_payload(encoded: str) -> bytes:
try:
raw = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as exc:
raise AddressBookError("Import file content is not valid base64.") from exc
if not raw:
raise AddressBookError("Import file is empty.")
if len(raw) > MAX_IMPORT_BYTES:
raise AddressBookError(f"Import files are limited to {MAX_IMPORT_BYTES} bytes.")
return raw
def _parse_rows(
raw: bytes,
*,
filename: str,
source_format: str,
config: AddressImportConfiguration,
) -> tuple[list[tuple[int, dict[str, str]]], list[dict[str, Any]]]:
if source_format == "csv":
return _parse_csv(raw, config=config)
if source_format == "xlsx":
if not filename.casefold().endswith(".xlsx"):
raise AddressBookError("XLSX imports require an .xlsx file; macros and legacy workbooks are not accepted.")
return _parse_xlsx(raw, config=config)
raise AddressBookError(f"Unsupported address import format: {source_format!r}.")
def _parse_csv(
raw: bytes,
*,
config: AddressImportConfiguration,
) -> tuple[list[tuple[int, dict[str, str]]], list[dict[str, Any]]]:
try:
text = raw.decode(config.encoding)
except UnicodeDecodeError as exc:
raise AddressBookError(f"CSV is not valid {config.encoding}: {exc}.") from exc
reader = csv.reader(StringIO(text), delimiter=config.delimiter)
all_rows = list(reader)
if len(all_rows) < config.header_row:
raise AddressBookError("CSV does not contain the configured header row.")
header = _headers(all_rows[config.header_row - 1])
result: list[tuple[int, dict[str, str]]] = []
for row_number, values in enumerate(all_rows[config.header_row :], start=config.header_row + 1):
if not any(str(value).strip() for value in values):
continue
if len(values) > MAX_IMPORT_COLUMNS:
raise AddressBookError(f"CSV row {row_number} exceeds the {MAX_IMPORT_COLUMNS}-column limit.")
result.append((row_number, _row_dict(header, values)))
if len(result) > config.max_rows:
raise AddressBookError(f"CSV exceeds the configured {config.max_rows}-row limit.")
return result, []
def _parse_xlsx(
raw: bytes,
*,
config: AddressImportConfiguration,
) -> tuple[list[tuple[int, dict[str, str]]], list[dict[str, Any]]]:
try:
from openpyxl import load_workbook
except ImportError as exc: # pragma: no cover - dependency/package failure
raise AddressBookError("XLSX import support is not installed.") from exc
try:
workbook = load_workbook(BytesIO(raw), read_only=True, data_only=False, keep_links=False)
except Exception as exc:
raise AddressBookError(f"XLSX workbook could not be read: {exc}.") from exc
if len(workbook.sheetnames) > 100:
raise AddressBookError("XLSX workbooks are limited to 100 sheets.")
if config.sheet_name:
if config.sheet_name not in workbook.sheetnames:
raise AddressBookError(f'XLSX sheet "{config.sheet_name}" was not found.')
sheet = workbook[config.sheet_name]
else:
sheet = workbook[workbook.sheetnames[0]]
rows = list(sheet.iter_rows(min_row=config.header_row, max_row=config.header_row))
if not rows:
raise AddressBookError("XLSX does not contain the configured header row.")
header = _headers([cell.value for cell in rows[0]])
result: list[tuple[int, dict[str, str]]] = []
for row_number, cells in enumerate(sheet.iter_rows(min_row=config.header_row + 1), start=config.header_row + 1):
if len(cells) > MAX_IMPORT_COLUMNS:
raise AddressBookError(f"XLSX row {row_number} exceeds the {MAX_IMPORT_COLUMNS}-column limit.")
if any(cell.data_type == "f" for cell in cells):
raise AddressBookError(f"XLSX row {row_number} contains a formula; formulas are never evaluated during import.")
values = [cell.value for cell in cells]
if not any(value is not None and str(value).strip() for value in values):
continue
result.append((row_number, _row_dict(header, values)))
if len(result) > config.max_rows:
raise AddressBookError(f"XLSX exceeds the configured {config.max_rows}-row limit.")
return result, []
def _headers(values: list[Any]) -> list[str]:
headers = [str(value).strip() if value is not None else "" for value in values]
if not headers or not any(headers):
raise AddressBookError("Import header row is empty.")
if len(headers) > MAX_IMPORT_COLUMNS:
raise AddressBookError(f"Import files are limited to {MAX_IMPORT_COLUMNS} columns.")
blank = [index + 1 for index, value in enumerate(headers) if not value]
if blank:
raise AddressBookError(f"Import header contains blank column names at positions {blank}.")
duplicates = sorted(name for name, count in Counter(headers).items() if count > 1)
if duplicates:
raise AddressBookError(f"Import header contains duplicate columns: {', '.join(duplicates)}.")
return headers
def _row_dict(headers: list[str], values: list[Any]) -> dict[str, str]:
padded = [*values, *([None] * max(0, len(headers) - len(values)))]
return {
header: "" if value is None else str(value).strip()
for header, value in zip(headers, padded, strict=False)
}
def _plan_rows(
session: Session,
*,
book_id: str,
profile: AddressImportProfile,
input_hash: str,
rows: list[tuple[int, dict[str, str]]],
config: AddressImportConfiguration,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
diagnostics: list[dict[str, Any]] = []
plan: list[dict[str, Any]] = []
headers = set(rows[0][1]) if rows else set()
referenced_columns = set(config.field_mappings.values())
if config.source_key_column:
referenced_columns.add(config.source_key_column)
missing_columns = sorted(referenced_columns.difference(headers))
for column in missing_columns:
diagnostics.append(_diagnostic("error", "missing_column", f'Configured column "{column}" is missing.', field=column))
if missing_columns:
return [], diagnostics
key_column = config.source_key_column or config.field_mappings["source_key"]
keyed_rows: list[tuple[int, dict[str, str], str]] = []
key_counts: Counter[str] = Counter()
for row_number, row in rows:
key = row.get(key_column, "").strip()
if not key:
diagnostics.append(_diagnostic("error", "missing_source_key", "Stable source key is blank.", row_number=row_number, field=key_column))
plan.append(_plan_effect(row_number, "conflict", source_key=None, message="Stable source key is blank."))
continue
key_counts[key] += 1
keyed_rows.append((row_number, row, key))
first_index: dict[str, int] = {}
last_index: dict[str, int] = {}
for index, (_row_number, _row, key) in enumerate(keyed_rows):
first_index.setdefault(key, index)
last_index[key] = index
for index, (row_number, row, key) in enumerate(keyed_rows):
if key_counts[key] > 1:
if config.duplicate_source_key_policy == "reject":
diagnostics.append(_diagnostic("error", "duplicate_source_key", f'Duplicate source key "{key}".', row_number=row_number, field=key_column))
plan.append(_plan_effect(row_number, "conflict", source_key=key, message="Duplicate source key."))
continue
chosen = first_index[key] if config.duplicate_source_key_policy == "first" else last_index[key]
if index != chosen:
diagnostics.append(_diagnostic("warning", "duplicate_source_key_ignored", f'Duplicate source key "{key}" was ignored by profile policy.', row_number=row_number, field=key_column))
plan.append(_plan_effect(row_number, "ignored", source_key=key, message="Duplicate row ignored by profile policy."))
continue
mapped, row_diagnostics = _mapped_fields(row_number, row, config=config)
diagnostics.extend(row_diagnostics)
source_ref = f"import:{profile.profile_key}:{key}"
existing = _contact_by_source_ref(session, book_id, source_ref)
payload = _payload_from_mapped(mapped, profile=profile, input_hash=input_hash, row_number=row_number, source_key=key)
display_name = payload.get("display_name") or payload.get("email") or key
if any(item["severity"] == "error" for item in row_diagnostics):
plan.append(_plan_effect(row_number, "conflict", source_key=key, display_name=display_name, source_ref=source_ref, payload=payload, message="Row validation failed."))
continue
if existing is None:
plan.append(_plan_effect(row_number, "create", source_key=key, display_name=display_name, source_ref=source_ref, payload=payload, changed_fields=sorted(mapped)))
continue
if config.existing_contact_policy == "ignore":
plan.append(_plan_effect(row_number, "ignored", source_key=key, contact_id=existing.id, display_name=existing.display_name, source_ref=source_ref, payload=payload, message="Existing contact retained by profile policy."))
continue
if config.existing_contact_policy == "reject":
diagnostics.append(_diagnostic("error", "existing_contact", f'Contact for source key "{key}" already exists.', row_number=row_number))
plan.append(_plan_effect(row_number, "conflict", source_key=key, contact_id=existing.id, display_name=existing.display_name, source_ref=source_ref, payload=payload, message="Existing contact rejected by profile policy."))
continue
changed_fields = _changed_fields(existing, mapped)
plan.append(
_plan_effect(
row_number,
"update" if changed_fields or existing.deleted_at is not None else "unchanged",
source_key=key,
contact_id=existing.id,
display_name=display_name,
source_ref=source_ref,
payload=payload,
changed_fields=changed_fields,
expected_contact_hash=_contact_hash(existing),
)
)
return sorted(plan, key=lambda item: item["row_number"]), diagnostics
def _mapped_fields(
row_number: int,
row: dict[str, str],
*,
config: AddressImportConfiguration,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
mapped: dict[str, Any] = {}
diagnostics: list[dict[str, Any]] = []
for target, column in config.field_mappings.items():
if target == "source_key":
continue
value = row.get(column, "").strip()
if not value:
if config.blank_value_policy == "reject":
diagnostics.append(_diagnostic("error", "blank_value", f'Column "{column}" is blank.', row_number=row_number, field=target))
elif config.blank_value_policy == "clear":
mapped[target] = [] if target == "tags" else None
continue
mapped[target] = [item.strip() for item in value.split(",") if item.strip()] if target == "tags" else value
if config.default_tags:
mapped["tags"] = list(dict.fromkeys([*(mapped.get("tags") or []), *config.default_tags]))
if not any(mapped.get(name) for name in ("display_name", "given_name", "family_name", "email", "organization")):
diagnostics.append(_diagnostic("error", "missing_identity", "Row has no name, email, or organization to identify the contact.", row_number=row_number))
return mapped, diagnostics
def _payload_from_mapped(
mapped: dict[str, Any],
*,
profile: AddressImportProfile,
input_hash: str,
row_number: int,
source_key: str,
) -> dict[str, Any]:
display_name = mapped.get("display_name") or " ".join(filter(None, [mapped.get("given_name"), mapped.get("family_name")])) or mapped.get("email") or mapped.get("organization")
payload: dict[str, Any] = {
key: mapped.get(key)
for key in ("given_name", "family_name", "organization", "role_title", "note", "tags")
if key in mapped
}
payload["display_name"] = display_name
if "email" in mapped:
payload["emails"] = [] if mapped["email"] is None else [ContactEmailPayload(email=mapped["email"], is_primary=True).model_dump(mode="json")]
if "phone" in mapped:
payload["phones"] = [] if mapped["phone"] is None else [ContactPhonePayload(phone=mapped["phone"], is_primary=True).model_dump(mode="json")]
postal_keys = {"street", "postal_code", "locality", "region", "country"}
if postal_keys.intersection(mapped):
postal = {key: mapped.get(key) for key in postal_keys if key in mapped}
payload["postal_addresses"] = [ContactPostalAddressPayload(**postal, is_primary=True).model_dump(mode="json")] if any(postal.values()) else []
payload["provenance"] = {
"import": {
"profile_key": profile.profile_key,
"profile_id": profile.id,
"profile_version": profile.version,
"input_hash": input_hash,
"row_number": row_number,
"source_key": source_key,
"locale": profile.configuration.get("locale"),
"visibility": mapped.get("visibility"),
}
}
return ContactCreateRequest.model_validate(payload).model_dump(
mode="json",
exclude_unset=True,
exclude_none=False,
)
def _changed_fields(contact: Contact, mapped: dict[str, Any]) -> list[str]:
current: dict[str, Any] = {
"display_name": contact.display_name,
"given_name": contact.given_name,
"family_name": contact.family_name,
"organization": contact.organization,
"role_title": contact.role_title,
"note": contact.note,
"tags": list(contact.tags or []),
"email": contact.emails[0].email if contact.emails else None,
"phone": contact.phones[0].phone if contact.phones else None,
}
if contact.postal_addresses:
postal = contact.postal_addresses[0]
current.update({key: getattr(postal, key) for key in ("street", "postal_code", "locality", "region", "country")})
return sorted(key for key, value in mapped.items() if key != "visibility" and current.get(key) != value)
def _plan_effect(
row_number: int,
action: str,
*,
source_key: str | None,
contact_id: str | None = None,
display_name: str | None = None,
source_ref: str | None = None,
payload: dict[str, Any] | None = None,
changed_fields: list[str] | None = None,
message: str | None = None,
expected_contact_hash: str | None = None,
) -> dict[str, Any]:
return {
"row_number": row_number,
"action": action,
"source_key": source_key,
"contact_id": contact_id,
"display_name": display_name,
"source_ref": source_ref,
"payload": payload or {},
"changed_fields": changed_fields or [],
"message": message,
"expected_contact_hash": expected_contact_hash,
}
def _diagnostic(
severity: str,
code: str,
message: str,
*,
row_number: int | None = None,
field: str | None = None,
) -> dict[str, Any]:
return {
"severity": severity,
"code": code,
"message": message,
"row_number": row_number,
"field": field,
"details": {},
}
def _contact_by_source_ref(session: Session, book_id: str, source_ref: str) -> Contact | None:
return (
session.query(Contact)
.filter(Contact.address_book_id == book_id, Contact.source_ref == source_ref)
.order_by(Contact.created_at.asc(), Contact.id.asc())
.first()
)
def _stamp_import_contact(contact: Contact, *, run: AddressImportRun, item: dict[str, Any]) -> None:
contact.source_kind = run.source_format
contact.source_ref = item["source_ref"]
contact.source_revision = hashlib.sha256(
f'{run.input_hash}:{item["row_number"]}:{item["source_key"]}'.encode()
).hexdigest()
contact.source_payload_kind = f"{run.source_format}-mapped-row"
contact.source_payload_raw = None
provenance = dict(contact.provenance or {})
provenance["import_run_id"] = run.id
provenance["input_hash"] = run.input_hash
provenance["plan_hash"] = run.plan_hash
contact.provenance = provenance
def _contact_snapshot(contact: Contact) -> dict[str, Any]:
return {
"payload": {
"display_name": contact.display_name,
"given_name": contact.given_name,
"family_name": contact.family_name,
"organization": contact.organization,
"role_title": contact.role_title,
"note": contact.note,
"tags": list(contact.tags or []),
"emails": [{"label": item.label, "email": item.email, "is_primary": item.is_primary} for item in contact.emails],
"phones": [{"label": item.label, "phone": item.phone, "is_primary": item.is_primary} for item in contact.phones],
"postal_addresses": [
{
"label": item.label,
"street": item.street,
"postal_code": item.postal_code,
"locality": item.locality,
"region": item.region,
"country": item.country,
"is_primary": item.is_primary,
}
for item in contact.postal_addresses
],
"provenance": dict(contact.provenance or {}),
},
"source_kind": contact.source_kind,
"source_ref": contact.source_ref,
"source_revision": contact.source_revision,
"source_payload_kind": contact.source_payload_kind,
"source_payload_raw": contact.source_payload_raw,
"provenance": dict(contact.provenance or {}),
"metadata": dict(contact.metadata_ or {}),
}
def _contact_hash(contact: Contact) -> str:
return _hash_json({**_contact_snapshot(contact), "deleted_at": contact.deleted_at.isoformat() if contact.deleted_at else None})
def _hash_json(value: object) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()).hexdigest()
def _trim(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
return normalized or None
def _visible_import_books(session: Session, principal: ApiPrincipal):
from govoplan_addresses.backend.service import list_address_books
return list_address_books(session, principal)
__all__ = [
"apply_address_import",
"create_import_profile",
"get_import_profile",
"get_import_run",
"import_run_payload",
"list_import_profiles",
"preview_address_import",
"retire_import_profile",
"rollback_address_import",
"update_import_profile",
]
+196
View File
@@ -0,0 +1,196 @@
from __future__ import annotations
import ssl
from dataclasses import dataclass
from typing import Any
from urllib.parse import unquote, urlsplit
from govoplan_core.core.connector_runtime import ConnectorContractError, ConnectorEndpoint
class AddressLdapError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class AddressLdapEntry:
dn: str
attributes: dict[str, Any]
@dataclass(frozen=True, slots=True)
class AddressLdapSearchResult:
base_dn: str
entries: tuple[AddressLdapEntry, ...]
complete: bool
page_size: int
class AddressLdapClient:
def __init__(
self,
*,
url: str,
bind_dn: str | None = None,
password: str | None = None,
start_tls: bool = True,
connect_timeout: int = 10,
receive_timeout: int = 30,
) -> None:
try:
endpoint = ConnectorEndpoint(
url=url,
tls_mode="start_tls" if start_tls else "required",
)
except ConnectorContractError as exc:
raise AddressLdapError(str(exc)) from exc
parsed = urlsplit(endpoint.url)
if parsed.scheme not in {"ldap", "ldaps"}:
raise AddressLdapError("LDAP endpoints must use ldap:// or ldaps://.")
if parsed.scheme == "ldap" and not start_tls:
raise AddressLdapError("ldap:// endpoints require StartTLS.")
if parsed.path not in {"", "/"}:
self.default_base_dn = unquote(parsed.path.lstrip("/"))
else:
self.default_base_dn = None
self.url = endpoint.url
self.host = parsed.hostname or ""
self.port = parsed.port or (636 if parsed.scheme == "ldaps" else 389)
self.use_ssl = parsed.scheme == "ldaps"
self.start_tls = parsed.scheme == "ldap" and start_tls
self.bind_dn = bind_dn
self.password = password
self.connect_timeout = max(1, min(connect_timeout, 30))
self.receive_timeout = max(1, min(receive_timeout, 120))
def discover_base_dns(self) -> tuple[str, ...]:
connection = self._connection()
try:
from ldap3 import BASE
if not connection.search(
search_base="",
search_filter="(objectClass=*)",
search_scope=BASE,
attributes=["namingContexts", "defaultNamingContext", "rootDomainNamingContext"],
):
raise AddressLdapError(_ldap_result_message(connection.result, "LDAP root DSE discovery failed."))
values: list[str] = []
for entry in connection.entries:
data = entry.entry_attributes_as_dict
for key in ("defaultNamingContext", "rootDomainNamingContext", "namingContexts"):
for value in _as_values(data.get(key)):
normalized = str(value).strip()
if normalized and normalized not in values:
values.append(normalized)
if self.default_base_dn and self.default_base_dn not in values:
values.insert(0, self.default_base_dn)
return tuple(values)
finally:
connection.unbind()
def search(
self,
*,
base_dn: str,
search_filter: str,
attributes: tuple[str, ...],
page_size: int = 500,
max_entries: int = 10_000,
) -> AddressLdapSearchResult:
normalized_base = base_dn.strip() or self.default_base_dn
if not normalized_base:
raise AddressLdapError("LDAP base DN is required.")
page_size = max(1, min(page_size, 1_000))
max_entries = max(1, min(max_entries, 10_000))
connection = self._connection()
entries: list[AddressLdapEntry] = []
complete = True
try:
try:
stream = connection.extend.standard.paged_search(
search_base=normalized_base,
search_filter=search_filter,
attributes=list(attributes),
paged_size=page_size,
generator=True,
)
for response in stream:
response_type = response.get("type")
if response_type != "searchResEntry":
continue
if len(entries) >= max_entries:
complete = False
break
entries.append(
AddressLdapEntry(
dn=str(response.get("dn") or ""),
attributes=dict(response.get("attributes") or {}),
)
)
except Exception as exc:
raise AddressLdapError(f"LDAP paged search failed: {exc}.") from exc
if connection.result and int(connection.result.get("result", 0) or 0) != 0:
raise AddressLdapError(_ldap_result_message(connection.result, "LDAP paged search failed."))
return AddressLdapSearchResult(
base_dn=normalized_base,
entries=tuple(entries),
complete=complete,
page_size=page_size,
)
finally:
connection.unbind()
def _connection(self):
try:
from ldap3 import Connection, Server, Tls
except ImportError as exc: # pragma: no cover - package failure
raise AddressLdapError("LDAP connector support is not installed.") from exc
tls = Tls(validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLS_CLIENT)
server = Server(
self.host,
port=self.port,
use_ssl=self.use_ssl,
tls=tls,
connect_timeout=self.connect_timeout,
)
try:
connection = Connection(
server,
user=self.bind_dn,
password=self.password,
receive_timeout=self.receive_timeout,
raise_exceptions=True,
)
connection.open()
if self.start_tls:
connection.start_tls()
connection.bind()
return connection
except Exception as exc:
raise AddressLdapError(f"LDAP connection or bind failed: {exc}.") from exc
def _as_values(value: Any) -> tuple[Any, ...]:
if value is None:
return ()
if isinstance(value, (list, tuple, set)):
return tuple(value)
return (value,)
def _ldap_result_message(result: dict[str, Any] | None, fallback: str) -> str:
if not result:
return fallback
description = str(result.get("description") or "").strip()
message = str(result.get("message") or "").strip()
detail = ": ".join(part for part in (description, message) if part)
return f"{fallback} {detail}".strip()
__all__ = [
"AddressLdapClient",
"AddressLdapEntry",
"AddressLdapError",
"AddressLdapSearchResult",
]
@@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, model_validator
DEFAULT_LDAP_ATTRIBUTE_MAP: dict[str, str] = {
"source_key": "entryUUID",
"source_revision": "modifyTimestamp",
"display_name": "displayName",
"given_name": "givenName",
"family_name": "sn",
"organization": "o",
"role_title": "title",
"email": "mail",
"phone": "telephoneNumber",
"street": "streetAddress",
"postal_code": "postalCode",
"locality": "l",
"region": "st",
"country": "c",
"tags": "memberOf",
}
class AddressLdapConnectionRequest(BaseModel):
url: str = Field(min_length=1, max_length=2000)
credential_ref: str | None = Field(default=None, max_length=1000)
bind_dn: str | None = Field(default=None, max_length=1000)
start_tls: bool = True
connect_timeout: int = Field(default=10, ge=1, le=30)
receive_timeout: int = Field(default=30, ge=1, le=120)
class AddressLdapDiscoveryResponse(BaseModel):
base_dns: list[str] = Field(default_factory=list)
class AddressLdapSourceCreateRequest(AddressLdapConnectionRequest):
display_name: str = Field(min_length=1, max_length=255)
base_dn: str = Field(min_length=1, max_length=2000)
search_filter: str = Field(default="(&(objectClass=person)(mail=*))", min_length=1, max_length=2000)
page_size: int = Field(default=500, ge=1, le=1000)
max_entries: int = Field(default=10_000, ge=1, le=10_000)
attribute_map: dict[str, str] = Field(default_factory=lambda: dict(DEFAULT_LDAP_ATTRIBUTE_MAP), max_length=40)
@model_validator(mode="after")
def validate_mapping(self) -> "AddressLdapSourceCreateRequest":
if "source_key" not in self.attribute_map:
raise ValueError("LDAP mappings require a stable source_key attribute.")
if not any(key in self.attribute_map for key in ("display_name", "email", "given_name", "family_name", "organization")):
raise ValueError("LDAP mappings require at least one contact identity attribute.")
if any(not key.strip() or not value.strip() for key, value in self.attribute_map.items()):
raise ValueError("LDAP mapping names and attributes cannot be blank.")
return self
class AddressLdapTestResponse(BaseModel):
success: bool
base_dn: str
sampled_entries: int
attributes: list[str] = Field(default_factory=list)
diagnostic: dict[str, Any] = Field(default_factory=dict)
__all__ = [
"AddressLdapConnectionRequest",
"AddressLdapDiscoveryResponse",
"AddressLdapSourceCreateRequest",
"AddressLdapTestResponse",
"DEFAULT_LDAP_ATTRIBUTE_MAP",
]
+333 -5
View File
@@ -12,8 +12,10 @@ from govoplan_addresses.backend.capabilities import (
) )
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.contact_points import CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -26,10 +28,31 @@ from govoplan_core.core.modules import (
PermissionDefinition, PermissionDefinition,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ProviderBehaviorDeclaration,
ProviderObjectDeclaration,
declared_module_architecture,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_addresses.backend.provider_state import (
CARDDAV_PROVIDER_ID,
LDAP_PROVIDER_ID,
carddav_provider_states,
ldap_provider_states,
)
_addresses_table_retirement_provider = drop_table_retirement_provider( _addresses_table_retirement_provider = drop_table_retirement_provider(
addresses_models.AddressImportRun,
addresses_models.AddressImportProfile,
addresses_models.ContactFieldProvenance,
addresses_models.ContactRedirect,
addresses_models.ContactMergeRecord,
addresses_models.ContactPointQualityDecision,
addresses_models.ContactPointSnapshot,
addresses_models.AddressSyncDiagnostic, addresses_models.AddressSyncDiagnostic,
addresses_models.AddressSyncConflict, addresses_models.AddressSyncConflict,
addresses_models.AddressSyncTombstone, addresses_models.AddressSyncTombstone,
@@ -39,6 +62,7 @@ _addresses_table_retirement_provider = drop_table_retirement_provider(
addresses_models.ContactPostalAddress, addresses_models.ContactPostalAddress,
addresses_models.ContactPhone, addresses_models.ContactPhone,
addresses_models.ContactEmail, addresses_models.ContactEmail,
addresses_models.ContactChannelRule,
addresses_models.Contact, addresses_models.Contact,
addresses_models.AddressBook, addresses_models.AddressBook,
label="Addresses", label="Addresses",
@@ -95,6 +119,8 @@ PERMISSIONS = (
_permission("addresses:contact:read", "View contacts", "List and lookup contacts in visible address books."), _permission("addresses:contact:read", "View contacts", "List and lookup contacts in visible address books."),
_permission("addresses:contact:write", "Manage contacts", "Create and edit local contacts."), _permission("addresses:contact:write", "Manage contacts", "Create and edit local contacts."),
_permission("addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."), _permission("addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."),
_permission("addresses:governance:read", "View communication governance", "Inspect effective-dated consent, suppression, and channel-preference facts."),
_permission("addresses:governance:write", "Manage communication governance", "Record and end consent, suppression, and channel-preference facts."),
_permission("addresses:sync:read", "View address sync", "Inspect address sync sources, conflicts, tombstones, and diagnostics."), _permission("addresses:sync:read", "View address sync", "Inspect address sync sources, conflicts, tombstones, and diagnostics."),
_permission("addresses:sync:write", "Manage address sync", "Bind address books to external sources and record sync state."), _permission("addresses:sync:write", "Manage address sync", "Bind address books to external sources and record sync state."),
_permission("addresses:sync:admin", "Administer address sync", "Administer address sync connectors and future destructive sync operations."), _permission("addresses:sync:admin", "Administer address sync", "Administer address sync connectors and future destructive sync operations."),
@@ -116,6 +142,8 @@ ROLE_TEMPLATES = (
"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",
), ),
@@ -124,19 +152,34 @@ ROLE_TEMPLATES = (
slug="address_book_reader", slug="address_book_reader",
name="Address book reader", name="Address book reader",
description="Read visible address books and contacts.", description="Read visible address books and contacts.",
permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:sync:read"), permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:governance:read", "addresses:sync:read"),
), ),
) )
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressSyncSource, Contact from govoplan_addresses.backend.db.models import (
AddressBook,
AddressImportProfile,
AddressImportRun,
AddressList,
AddressSyncSource,
Contact,
ContactMergeRecord,
ContactPointQualityDecision,
ContactPointSnapshot,
)
return { return {
"address_books": session.query(AddressBook).filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None)).count(), "address_books": session.query(AddressBook).filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None)).count(),
"address_lists": session.query(AddressList).filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None)).count(), "address_lists": session.query(AddressList).filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None)).count(),
"contacts": session.query(Contact).filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None)).count(), "contacts": session.query(Contact).filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None)).count(),
"active_contact_merges": session.query(ContactMergeRecord).filter(ContactMergeRecord.tenant_id == tenant_id, ContactMergeRecord.status == "active").count(),
"contact_quality_decisions": session.query(ContactPointQualityDecision).filter(ContactPointQualityDecision.tenant_id == tenant_id).count(),
"contact_point_snapshots": session.query(ContactPointSnapshot).filter(ContactPointSnapshot.tenant_id == tenant_id).count(),
"sync_sources": session.query(AddressSyncSource).filter(AddressSyncSource.tenant_id == tenant_id, AddressSyncSource.enabled.is_(True)).count(), "sync_sources": session.query(AddressSyncSource).filter(AddressSyncSource.tenant_id == tenant_id, AddressSyncSource.enabled.is_(True)).count(),
"address_import_profiles": session.query(AddressImportProfile).filter(AddressImportProfile.tenant_id == tenant_id, AddressImportProfile.is_current.is_(True)).count(),
"address_import_runs": session.query(AddressImportRun).filter(AddressImportRun.tenant_id == tenant_id).count(),
} }
@@ -146,17 +189,119 @@ def _addresses_router(_context: ModuleContext):
return router return router
CARDDAV_PROVIDER = ExternalProviderDeclaration(
id=CARDDAV_PROVIDER_ID,
module_id="addresses",
label="CardDAV address-book synchronization",
maturity="synchronize",
operations=("discover", "read", "write", "delete", "synchronize", "preview"),
objects=(
ProviderObjectDeclaration(
object_type="address_book",
field_groups=("identity", "display", "sync_state"),
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
default_authority_mode="external_mirror",
),
ProviderObjectDeclaration(
object_type="contact",
field_groups=("identity", "name", "postal", "email", "phone", "source_metadata"),
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
default_authority_mode="governed_sync",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="CardDAV sync tokens, resource hrefs, and ETags are retained.",
concurrency="Conditional writes reject stale ETags and preserve explicit conflicts.",
freshness="Last attempt, last success, source status, and sync token are recorded.",
health="Transport failures, diagnostics, and unresolved conflicts are projected separately.",
max_read_items=5000,
idempotency="Stable source, href, UID, and ETag facts prevent duplicate contact effects.",
retry="Only a new governed sync attempt retries failed transport operations.",
timeout_seconds=30,
conflicts="Local and remote values remain in an explicit conflict record until resolved.",
outcome_unknown="Timed-out writes require a subsequent CardDAV read before correction or retry.",
outcome_unknown_supported=True,
evidence="Sync diagnostics, tombstones, conflicts, source revisions, and contact provenance are retained.",
audit_event_types=(
"addresses.sync.started",
"addresses.sync.finished",
"addresses.sync.conflict_recorded",
),
correction="A resolved conflict or later synchronized revision corrects state without rewriting prior evidence.",
rollback="Remote writes are not assumed to be transactionally reversible.",
compensation="A reconciled update or tombstone can compensate after the remote outcome is known.",
reconciliation="Read by resource href and compare ETag, UID, and local revision before applying changes.",
outage="Existing local contacts remain available with stale or unknown freshness.",
classifications=("personal", "confidential"),
purposes=("address-book synchronization", "governed recipient resolution"),
retention="Address-book and audit retention policies apply independently.",
secret_handling="Only credential references and sanitized authentication metadata are persisted in sync state.",
),
capability_names=(CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_CONTACT_WRITER),
documentation_topic_ids=("addresses.boundary",),
)
LDAP_PROVIDER = ExternalProviderDeclaration(
id=LDAP_PROVIDER_ID,
module_id="addresses",
label="Read-only LDAP and Active Directory contacts",
maturity="synchronize",
operations=("discover", "read", "preview", "synchronize"),
objects=(
ProviderObjectDeclaration(
object_type="contact",
field_groups=("identity", "name", "organization", "postal", "email", "phone", "source_metadata"),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_authoritative",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="Stable LDAP source keys plus modifyTimestamp, uSNChanged, entryCSN, or a deterministic attribute digest are retained.",
concurrency="LDAP is authoritative and read-only; local projections are replaced only from a complete reviewed plan.",
freshness="Last attempt, last success, remote revision, and stale provider health remain visible.",
health="TLS, bind, discovery, paging, mapping, truncation, and malformed-entry failures are separate diagnostics.",
max_read_items=10000,
idempotency="The source binding, stable key, and revision prevent duplicate contact projections.",
retry="Failed reads are retried only by a new operator or scheduled sync attempt with bounded timeouts.",
timeout_seconds=120,
conflicts="Duplicate source keys, malformed mappings, and locally changed projections block or require a fresh plan.",
outcome_unknown="Read failures never infer external deletions and retain prior local projections as stale.",
outcome_unknown_supported=True,
evidence="Source keys, revisions, mapping configuration, diagnostics, tombstones, and normalized field provenance are retained.",
audit_event_types=(
"addresses.sync_source_created",
"addresses.sync_previewed",
"addresses.sync_completed",
),
correction="Correct the directory or mapping, then run a new full preview and synchronization.",
rollback="Prior projections remain reconstructable from source revision and contact change evidence; external LDAP is never mutated.",
compensation="A later authoritative refresh restores corrected projections.",
reconciliation="Only a complete paged search may infer an absent source object and create a local tombstone.",
outage="Existing contacts remain available and visibly stale; an unavailable directory never causes deletes.",
classifications=("personal", "confidential", "restricted"),
purposes=("directory projection", "recipient resolution", "identity-linked contact discovery"),
retention="Address, audit, and records policies govern local projections and tombstone evidence.",
secret_handling="Bind secrets remain in reusable credential envelopes; URLs, previews, and diagnostics contain no credentials.",
),
capability_names=(CAPABILITY_ADDRESSES_LOOKUP, CAPABILITY_ADDRESSES_CONTACT_WRITER),
documentation_topic_ids=("addresses.ldap-directory",),
)
manifest = ModuleManifest( manifest = ModuleManifest(
id="addresses", id="addresses",
name="Addresses", name="Addresses",
version="0.1.9", version="0.1.15",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"), optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox", "connectors"),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, version="1.0.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"), ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_addresses_router, route_factory=_addresses_router,
@@ -168,6 +313,14 @@ manifest = ModuleManifest(
package_name="@govoplan/addresses-webui", package_name="@govoplan/addresses-webui",
routes=(FrontendRoute(path="/address-book", component="AddressBookPage", required_any=("addresses:contact:read",), order=80),), routes=(FrontendRoute(path="/address-book", component="AddressBookPage", required_any=("addresses:contact:read",), order=80),),
nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),), nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),),
view_surfaces=(
ViewSurface(id="addresses.page", module_id="addresses", kind="route", label="Address Book", order=80),
ViewSurface(id="addresses.sources", module_id="addresses", kind="section", label="Address sources", order=10),
ViewSurface(id="addresses.contacts", module_id="addresses", kind="section", label="Contacts", order=20),
ViewSurface(id="addresses.detail", module_id="addresses", kind="section", label="Contact detail", order=30),
ViewSurface(id="addresses.governance", module_id="addresses", kind="action", label="Communication governance", order=40),
ViewSurface(id="addresses.sync", module_id="addresses", kind="action", label="Address synchronization", order=50),
),
), ),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id="addresses", module_id="addresses",
@@ -188,20 +341,36 @@ manifest = ModuleManifest(
"govoplan_addresses.backend.capabilities", "govoplan_addresses.backend.capabilities",
fromlist=["contact_writer_capability"], fromlist=["contact_writer_capability"],
).contact_writer_capability(context), ).contact_writer_capability(context),
CAPABILITY_RECIPIENT_CHANNEL_FACTS: lambda context: __import__(
"govoplan_addresses.backend.capabilities",
fromlist=["channel_facts_capability"],
).channel_facts_capability(context),
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION: lambda context: __import__(
"govoplan_addresses.backend.capabilities",
fromlist=["contact_point_resolution_capability"],
).contact_point_resolution_capability(context),
}, },
uninstall_guard_providers=( uninstall_guard_providers=(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
addresses_models.AddressImportRun,
addresses_models.AddressImportProfile,
addresses_models.AddressSyncDiagnostic, addresses_models.AddressSyncDiagnostic,
addresses_models.AddressSyncConflict, addresses_models.AddressSyncConflict,
addresses_models.AddressSyncTombstone, addresses_models.AddressSyncTombstone,
addresses_models.AddressSyncSource, addresses_models.AddressSyncSource,
addresses_models.AddressListEntry, addresses_models.AddressListEntry,
addresses_models.AddressList, addresses_models.AddressList,
addresses_models.ContactFieldProvenance,
addresses_models.ContactRedirect,
addresses_models.ContactMergeRecord,
addresses_models.ContactPointQualityDecision,
addresses_models.ContactPointSnapshot,
addresses_models.AddressBook, addresses_models.AddressBook,
addresses_models.Contact, addresses_models.Contact,
addresses_models.ContactEmail, addresses_models.ContactEmail,
addresses_models.ContactPhone, addresses_models.ContactPhone,
addresses_models.ContactPostalAddress, addresses_models.ContactPostalAddress,
addresses_models.ContactChannelRule,
label="Addresses", label="Addresses",
), ),
), ),
@@ -220,7 +389,166 @@ manifest = ModuleManifest(
audience=("tenant_admin", "operator", "module_admin"), audience=("tenant_admin", "operator", "module_admin"),
related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"), related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
order=30, order=30,
metadata={
"seed": True,
"help_contexts": [
"addresses.page",
"addresses.sources",
"addresses.contacts",
"addresses.detail",
"addresses.state.read-only",
],
},
), ),
DocumentationTopic(
id="addresses.contact-point-resolution",
title="Contact-point resolution and snapshots",
summary="Resolve purpose-aware channel targets and freeze immutable recipient evidence.",
body=(
"Addresses exposes a versioned contact-point capability for email, postal, internal-mail, and portal targets. "
"Callers can request an effective date, communication purpose, address purpose, fallback rule, locale, and "
"postal format. Bounded previews remain live; frozen snapshots retain the resolved values, exclusions, "
"source and governance revisions, provenance, and a deterministic evidence hash even after contacts change."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("dist_lists", "campaigns", "policy", "templates"),
order=31,
metadata={
"seed": True,
"help_contexts": [
"addresses.governance",
"addresses.field.channel",
"addresses.field.contact-point",
"addresses.field.communication-purpose",
"addresses.field.effective-period",
],
},
),
DocumentationTopic(
id="addresses.tabular-imports",
title="CSV and XLSX contact imports",
summary="Preview and apply reusable, versioned contact mappings without silent row loss.",
body=(
"CSV and XLSX files can be mapped with scoped, reusable profile versions. Each preview validates headers, "
"encodings, source keys, duplicates, blank values, workbook limits, and contact identity before any mutation. "
"The reviewed input hash and plan hash are retained with row-level effects and diagnostics. Apply is idempotent, "
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. XLSX formulas, "
"macros, and legacy workbook formats are never executed or imported."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "module_admin", "power_user"),
related_modules=("connectors", "datasources", "dataflow", "files", "audit"),
order=33,
),
DocumentationTopic(
id="addresses.ldap-directory",
title="LDAP and Active Directory address sources",
summary="Project authoritative directory contacts through a bounded, read-only synchronization source.",
body=(
"LDAP sources use LDAPS or StartTLS and reusable credential envelopes. Discovery finds available base DNs; "
"the source profile then controls a bounded paged filter and explicit attribute mapping. Preview never mutates "
"contacts. A complete successful read may create, update, or tombstone local projections; truncated or failed "
"reads suppress absence-based deletes and mark the source stale. Stable source keys, revisions, normalized fields, "
"and provenance remain attached to every retained contact."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("connectors", "idm", "access", "policy", "audit"),
order=34,
),
DocumentationTopic(
id="addresses.quality-and-merge",
title="Contact quality, duplicates, and reversible merges",
summary="Review address quality and duplicate suggestions without losing source evidence.",
body=(
"Addresses preserves original and normalized contact-point values, records field-level provenance, "
"and projects invalid, returned, stale, or undeliverable states into recipient resolution with stable "
"reason codes. Duplicate suggestions are bounded and explain their matching features. An operator can "
"choose the surviving values, merge contact points, and later undo or split the merge while the recorded "
"post-merge evidence still matches. Contact redirects keep stored references resolvable, and address-list "
"memberships are repaired transactionally. Audit remains an optional integration; the Addresses change "
"sequence and merge evidence are always retained."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("campaigns", "dist_lists", "policy", "audit"),
order=32,
),
DocumentationTopic(
id="addresses.reference.fields-and-consequences",
title="Address fields, scope, and action consequences",
summary="Scope, source authority, contact points, list membership, archival, synchronization, and merge consequences.",
body=(
"Address books are scoped to a user, group, tenant, or authorized system context. Inherited and externally authoritative "
"books may remain visible but read-only. Contacts own reusable name, organization, electronic, phone, postal, tag, note, "
"quality, and provenance facts; address lists reference contact points from the same book and do not replace Distribution "
"Lists. Archival hides a book, list, or contact from ordinary selection while preserving governed history and references. "
"CardDAV and LDAP sources expose their direction, authority, freshness, diagnostics, conflict, and stale-state behavior. "
"Imports and synchronization require preview before mutation. Contact merges select a survivor and field provenance, repair "
"list references transactionally, and retain redirects and evidence so a matching merge can be undone or split."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin", "power_user"),
related_modules=("dist_lists", "connectors", "datasources", "campaigns", "policy", "audit"),
order=35,
metadata={
"seed": True,
"help_contexts": [
"addresses.field.book-scope",
"addresses.field.contact-identity",
"addresses.field.organization",
"addresses.field.contact-point",
"addresses.action.archive",
"addresses.action.import",
"addresses.action.sync",
"addresses.action.merge",
],
"consequence_classes": {
"archive": "Removes the object from ordinary selection while retaining governed history and references.",
"import_or_sync": "Applies only a reviewed bounded plan and retains source revision, diagnostics, and provenance.",
"merge": "Repoints governed references to a survivor and retains reversible redirect and provenance evidence.",
"governance_fact": "Adds or ends an effective-dated communication decision without erasing prior facts.",
},
},
),
),
external_providers=(CARDDAV_PROVIDER, LDAP_PROVIDER),
external_provider_state_providers=(
ExternalProviderStateProviderRegistration(
module_id="addresses",
provider_id=CARDDAV_PROVIDER_ID,
provider=carddav_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="addresses",
provider_id=LDAP_PROVIDER_ID,
provider=ldap_provider_states,
),
),
architecture=declared_module_architecture(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/ADDRESS_MODULE_ARCHITECTURE.md",
test_ref="tests/test_addresses_service.py",
known_limits=("External address-book synchronization remains a bounded connector slice rather than a supported provider profile.",),
supported_authority_modes=(
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
),
owned_concepts=("contact point", "address book", "contact consent", "recipient source"),
non_owned_concepts=("identity", "organization", "campaign recipient snapshot", "procedure party"),
target_tested_providers=(CARDDAV_PROVIDER_ID,),
security_docs=("docs/ADDRESS_MODULE_ARCHITECTURE.md",),
operations_docs=("README.md",),
), ),
) )
@@ -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,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
+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,
@@ -0,0 +1,39 @@
from __future__ import annotations
import unittest
from govoplan_addresses.backend.manifest import manifest
class AddressesInterfaceDocumentationContractTests(unittest.TestCase):
def test_route_and_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
self.assertEqual({"/address-book"}, {item.path for item in frontend.routes}) # type: ignore[union-attr]
self.assertEqual(
{
"addresses.page",
"addresses.sources",
"addresses.contacts",
"addresses.detail",
"addresses.governance",
"addresses.sync",
},
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
)
def test_help_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
boundary = topics["addresses.boundary"]
governance = topics["addresses.contact-point-resolution"]
reference = topics["addresses.reference.fields-and-consequences"]
self.assertIn("addresses.state.read-only", boundary.metadata["help_contexts"])
self.assertIn("addresses.field.communication-purpose", governance.metadata["help_contexts"])
self.assertIn("addresses.action.sync", reference.metadata["help_contexts"])
self.assertIn("merge", reference.metadata["consequence_classes"])
self.assertIn("governance_fact", reference.metadata["consequence_classes"])
if __name__ == "__main__":
unittest.main()
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_addresses.backend.db.models import AddressBook, AddressSyncSource, Contact
from govoplan_addresses.backend.ldap import (
AddressLdapClient,
AddressLdapEntry,
AddressLdapError,
AddressLdapSearchResult,
)
from govoplan_addresses.backend.ldap_schemas import AddressLdapSourceCreateRequest
from govoplan_addresses.backend.service import (
create_ldap_sync_source,
preview_sync_source,
run_sync_source,
)
from govoplan_core.db.base import Base
class Principal:
account_id = "account-1"
group_ids = frozenset()
@property
def tenant_id(self) -> str:
return "tenant-1"
def has(self, scope: str) -> bool:
return scope in {
"addresses:address_book:read",
"addresses:address_book:write",
"addresses:contact:read",
"addresses:contact:write",
"addresses:contact:delete",
"addresses:sync:read",
"addresses:sync:write",
}
class FakeLdapClient:
def __init__(self, entries: list[AddressLdapEntry], *, complete: bool = True) -> None:
self.entries = entries
self.complete = complete
def search(self, *, base_dn: str, search_filter: str, attributes: tuple[str, ...], page_size: int, max_entries: int) -> AddressLdapSearchResult:
del search_filter, attributes, max_entries
return AddressLdapSearchResult(
base_dn=base_dn,
entries=tuple(self.entries),
complete=self.complete,
page_size=page_size,
)
def ldap_entry(
key: str,
*,
revision: str = "20260802090000Z",
organization: str = "Analysis Office",
) -> AddressLdapEntry:
return AddressLdapEntry(
dn=f"uid={key},ou=people,dc=example,dc=test",
attributes={
"entryUUID": key,
"modifyTimestamp": revision,
"displayName": "Ada Lovelace",
"givenName": "Ada",
"sn": "Lovelace",
"mail": "ada@example.test",
"o": organization,
"memberOf": ["cn=analysts,ou=groups,dc=example,dc=test"],
},
)
class AddressLdapSyncTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
self.session = sessionmaker(bind=engine, expire_on_commit=False)()
self.principal = Principal()
self.book = AddressBook(
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Directory",
source_kind="local",
read_only=False,
)
self.session.add(self.book)
self.session.flush()
self.source = create_ldap_sync_source(
self.session,
self.principal,
self.book.id,
AddressLdapSourceCreateRequest(
url="ldaps://directory.example.test",
display_name="Corporate directory",
base_dn="ou=people,dc=example,dc=test",
),
)
self.session.flush()
def test_preview_is_nonmutating_and_full_sync_is_idempotent(self) -> None:
client = FakeLdapClient([ldap_entry("person-1")])
preview = preview_sync_source(self.session, self.principal, self.source.id, client=client)
self.assertEqual(1, preview.stats.created)
self.assertEqual(0, self.session.query(Contact).count())
first = run_sync_source(self.session, self.principal, self.source.id, client=client)
self.assertEqual(1, first.stats.created)
contact = self.session.query(Contact).one()
self.assertEqual("ldap", contact.source_kind)
self.assertEqual("person-1", contact.provenance["ldap"]["source_key"])
self.assertEqual("succeeded", self.source.status)
repeated = run_sync_source(self.session, self.principal, self.source.id, client=client)
self.assertEqual(1, repeated.stats.unchanged)
self.assertEqual(1, self.session.query(Contact).count())
changed_client = FakeLdapClient(
[ldap_entry("person-1", revision="20260802100000Z", organization="Computing Office")]
)
changed = run_sync_source(self.session, self.principal, self.source.id, client=changed_client)
self.assertEqual(1, changed.stats.updated)
self.assertEqual("Computing Office", self.session.query(Contact).one().organization)
def test_only_complete_scans_plan_authoritative_deletes(self) -> None:
run_sync_source(
self.session,
self.principal,
self.source.id,
client=FakeLdapClient([ldap_entry("person-1")]),
)
incomplete = preview_sync_source(
self.session,
self.principal,
self.source.id,
client=FakeLdapClient([], complete=False),
)
self.assertEqual(0, incomplete.stats.deleted)
self.assertEqual(1, incomplete.stats.errors)
self.assertIsNone(self.session.query(Contact).one().deleted_at)
complete = run_sync_source(
self.session,
self.principal,
self.source.id,
client=FakeLdapClient([], complete=True),
)
self.assertEqual(1, complete.stats.deleted)
self.assertIsNotNone(self.session.query(Contact).one().deleted_at)
def test_connector_requires_encrypted_transport(self) -> None:
with self.assertRaisesRegex(AddressLdapError, "require StartTLS"):
AddressLdapClient(url="ldap://directory.example.test", start_tls=False)
with self.assertRaisesRegex(AddressLdapError, "must not contain credentials"):
AddressLdapClient(url="ldaps://user:secret@directory.example.test")
def test_source_is_always_read_only(self) -> None:
source = self.session.get(AddressSyncSource, self.source.id)
self.assertTrue(source.read_only)
self.assertEqual("read_only", source.sync_direction)
self.assertTrue(source.address_book.read_only)
if __name__ == "__main__":
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_addresses.backend.db.models import Base
from govoplan_addresses.backend.manifest import get_manifest
from govoplan_core.db.migrations import migrate_database
class AddressesMigrationTests(unittest.TestCase):
def test_schema_identifiers_fit_postgresql_limit(self) -> None:
overlong_indexes = sorted(
index.name
for table in Base.metadata.tables.values()
if table.name.startswith("addresses_")
for index in table.indexes
if index.name and len(index.name) > 63
)
self.assertEqual([], overlong_indexes)
def test_fresh_database_reaches_import_profile_head(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-addresses-migration-") as directory:
url = f"sqlite:///{Path(directory) / 'addresses.db'}"
migrate_database(
database_url=url,
enabled_modules=("addresses",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
with engine.connect() as connection:
self.assertIn(
"c5d7e8f9a0b1",
set(MigrationContext.configure(connection).get_current_heads()),
)
tables = set(inspect(connection).get_table_names())
self.assertIn("addresses_import_profiles", tables)
self.assertIn("addresses_import_runs", tables)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from datetime import UTC, datetime
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressSyncConflict,
AddressSyncDiagnostic,
AddressSyncSource,
)
from govoplan_addresses.backend.manifest import manifest
from govoplan_addresses.backend.provider_state import (
CARDDAV_PROVIDER_ID,
LDAP_PROVIDER_ID,
carddav_provider_states,
ldap_provider_states,
)
from govoplan_core.core.provider_governance import ExternalProviderStateContext
from govoplan_core.db.base import Base
class AddressesProviderStateTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(
self.engine,
tables=(
AddressBook.__table__,
AddressSyncSource.__table__,
AddressSyncConflict.__table__,
AddressSyncDiagnostic.__table__,
),
)
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
book = AddressBook(
id="book-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Remote",
)
self.source = AddressSyncSource(
id="source-1",
tenant_id="tenant-1",
address_book_id=book.id,
connector_type="carddav",
display_name="CardDAV",
external_address_book_ref="https://dav.example.test/addressbook/",
sync_direction="two_way",
read_only=False,
enabled=True,
status="succeeded",
last_success_at=datetime.now(UTC),
)
self.session.add_all((book, self.source))
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_state_is_tenant_bounded_secret_free_and_reports_conflict(self) -> None:
healthy = carddav_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual("healthy", healthy.health)
self.assertEqual("current", healthy.freshness)
self.assertEqual("governed_sync", healthy.authority_mode)
self.assertNotIn("dav.example.test", str(healthy.to_dict()))
self.assertEqual(
(),
carddav_provider_states(
ExternalProviderStateContext(
session=self.session,
tenant_id="tenant-2",
)
),
)
self.session.add(
AddressSyncConflict(
tenant_id="tenant-1",
sync_source_id=self.source.id,
address_book_id="book-1",
field_path="email",
status="open",
)
)
self.session.flush()
conflicted = carddav_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual("pending", conflicted.conflict)
self.assertEqual("attention", conflicted.recovery)
def test_manifest_registers_carddav_declaration_and_state(self) -> None:
self.assertEqual(CARDDAV_PROVIDER_ID, manifest.external_providers[0].id)
self.assertEqual(
CARDDAV_PROVIDER_ID,
manifest.external_provider_state_providers[0].provider_id,
)
self.assertEqual(LDAP_PROVIDER_ID, manifest.external_providers[1].id)
self.assertEqual(
LDAP_PROVIDER_ID,
manifest.external_provider_state_providers[1].provider_id,
)
def test_failed_ldap_source_is_stale_without_exposing_endpoint(self) -> None:
self.source.connector_type = "ldap"
self.source.display_name = "Directory"
self.source.status = "failed"
self.source.last_error = "connection failed"
self.session.flush()
state = ldap_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual(LDAP_PROVIDER_ID, state.provider_id)
self.assertEqual("error", state.health)
self.assertEqual("current", state.freshness)
self.assertEqual("external_authoritative", state.authority_mode)
self.assertNotIn("dav.example.test", str(state.to_dict()))
if __name__ == "__main__":
unittest.main()
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
import base64
from io import BytesIO
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from openpyxl import Workbook
from govoplan_addresses.backend.db.models import AddressBook, Contact
from govoplan_addresses.backend.import_schemas import (
AddressImportConfiguration,
AddressImportPreviewRequest,
AddressImportProfileCreateRequest,
AddressImportProfileUpdateRequest,
AddressImportRollbackRequest,
)
from govoplan_addresses.backend.imports import (
apply_address_import,
create_import_profile,
import_run_payload,
preview_address_import,
rollback_address_import,
update_import_profile,
)
from govoplan_core.db.base import Base
class Principal:
account_id = "account-1"
group_ids = frozenset({"group-1"})
@property
def tenant_id(self) -> str:
return "tenant-1"
def has(self, scope: str) -> bool:
return scope in {
"addresses:address_book:read",
"addresses:address_book:write",
"addresses:contact:read",
"addresses:contact:write",
}
def encoded(value: str) -> str:
return base64.b64encode(value.encode()).decode()
class AddressTabularImportTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
self.session = sessionmaker(bind=engine, expire_on_commit=False)()
self.principal = Principal()
self.book = AddressBook(
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Imported contacts",
source_kind="local",
read_only=False,
)
self.session.add(self.book)
self.profile = create_import_profile(
self.session,
self.principal,
AddressImportProfileCreateRequest(
scope_type="tenant",
name="Monthly contacts",
source_format="csv",
configuration=AddressImportConfiguration(
delimiter=";",
field_mappings={
"source_key": "id",
"given_name": "first",
"family_name": "last",
"email": "email",
"organization": "organization",
},
),
),
)
self.session.flush()
def test_preview_apply_repeat_and_guarded_rollback(self) -> None:
payload = AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="contacts.csv",
content_base64=encoded(
"id;first;last;email;organization\n"
"1;Ada;Lovelace;ada@example.test;Analysis Office\n"
"2;Grace;Hopper;grace@example.test;Computing Office\n"
),
)
run = preview_address_import(self.session, self.principal, self.book.id, payload)
self.assertEqual(2, run.statistics["create"])
self.assertFalse(run.diagnostics)
applied = apply_address_import(
self.session,
self.principal,
run.id,
expected_plan_hash=run.plan_hash,
)
self.assertEqual("applied", applied.status)
self.assertEqual(2, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
self.assertIs(applied, apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash))
response_evidence = import_run_payload(applied)["result_evidence"]
self.assertEqual(2, response_evidence["created_contact_count"])
self.assertNotIn("created_contact_ids", response_evidence)
self.assertNotIn("updated_contacts", response_evidence)
repeated = preview_address_import(self.session, self.principal, self.book.id, payload)
self.assertEqual(2, repeated.statistics["unchanged"])
apply_address_import(self.session, self.principal, repeated.id, expected_plan_hash=repeated.plan_hash)
self.assertEqual(2, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
rolled_back = rollback_address_import(
self.session,
self.principal,
run.id,
AddressImportRollbackRequest(reason="The operator selected the wrong monthly file."),
)
self.assertEqual("rolled_back", rolled_back.status)
self.assertEqual(0, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
def test_duplicate_keys_and_changed_targets_block_apply(self) -> None:
duplicate = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="duplicates.csv",
content_base64=encoded(
"id;first;last;email;organization\n"
"1;Ada;Lovelace;ada@example.test;One\n"
"1;Ada;Lovelace;ada@example.test;Two\n"
),
),
)
self.assertEqual(2, duplicate.statistics["conflict"])
with self.assertRaisesRegex(ValueError, "error diagnostics"):
apply_address_import(self.session, self.principal, duplicate.id, expected_plan_hash=duplicate.plan_hash)
initial = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="one.csv",
content_base64=encoded("id;first;last;email;organization\n1;Ada;Lovelace;ada@example.test;One\n"),
),
)
apply_address_import(self.session, self.principal, initial.id, expected_plan_hash=initial.plan_hash)
changed = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=self.profile.id,
filename="one.csv",
content_base64=encoded("id;first;last;email;organization\n1;Ada;Lovelace;ada@example.test;Two\n"),
),
)
contact = self.session.query(Contact).filter(Contact.deleted_at.is_(None)).one()
contact.organization = "Concurrent edit"
self.session.flush()
with self.assertRaisesRegex(ValueError, "changed after preview"):
apply_address_import(self.session, self.principal, changed.id, expected_plan_hash=changed.plan_hash)
def test_profile_updates_create_immutable_versions(self) -> None:
next_profile = update_import_profile(
self.session,
self.principal,
self.profile.id,
payload=AddressImportProfileUpdateRequest(name="Monthly contacts v2"),
)
self.assertFalse(self.profile.is_current)
self.assertTrue(next_profile.is_current)
self.assertEqual(self.profile.profile_key, next_profile.profile_key)
self.assertEqual(2, next_profile.version)
def test_xlsx_sheet_selection_and_formula_rejection(self) -> None:
workbook = Workbook()
workbook.active.title = "Ignore"
sheet = workbook.create_sheet("Contacts")
sheet.append(["id", "first", "last", "email", "organization"])
sheet.append(["1", "Ada", "Lovelace", "ada@example.test", "Analysis Office"])
content = BytesIO()
workbook.save(content)
xlsx_profile = create_import_profile(
self.session,
self.principal,
AddressImportProfileCreateRequest(
scope_type="tenant",
name="Workbook contacts",
source_format="xlsx",
configuration=AddressImportConfiguration(
sheet_name="Contacts",
field_mappings={
"source_key": "id",
"given_name": "first",
"family_name": "last",
"email": "email",
"organization": "organization",
},
),
),
)
self.session.flush()
run = preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=xlsx_profile.id,
filename="contacts.xlsx",
content_base64=base64.b64encode(content.getvalue()).decode(),
),
)
self.assertEqual(1, run.statistics["create"])
sheet["E2"] = "=CONCAT(\"Analysis\", \" Office\")"
content = BytesIO()
workbook.save(content)
with self.assertRaisesRegex(ValueError, "formulas are never evaluated"):
preview_address_import(
self.session,
self.principal,
self.book.id,
AddressImportPreviewRequest(
profile_id=xlsx_profile.id,
filename="contacts.xlsx",
content_base64=base64.b64encode(content.getvalue()).decode(),
),
)
if __name__ == "__main__":
unittest.main()
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/addresses-webui", "name": "@govoplan/addresses-webui",
"version": "0.1.9", "version": "0.1.15",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -17,11 +17,11 @@
"test:ui-structure": "node scripts/test-selection-list-structure.mjs" "test:ui-structure": "node scripts/test-selection-list-structure.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.11", "@govoplan/core-webui": "^0.1.15",
"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": {
+533 -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 = {
@@ -216,6 +387,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 +484,92 @@ 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";
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";
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;
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 +578,10 @@ type AddressCardDavDiscoveryResponse = {
address_books: AddressCardDavAddressBook[]; address_books: AddressCardDavAddressBook[];
}; };
type AddressCredentialEnvelopeListResponse = {
credentials: AddressCredentialEnvelope[];
};
type AddressSyncDiagnosticListResponse = { type AddressSyncDiagnosticListResponse = {
diagnostics: AddressSyncDiagnostic[]; diagnostics: AddressSyncDiagnostic[];
}; };
@@ -317,6 +594,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 +747,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 +772,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 +786,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 +886,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 +920,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,6 +1056,64 @@ export function importAddressBookVcards(settings: ApiSettings, addressBookId: st
}); });
} }
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";
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 applyAddressImport(settings: ApiSettings, run: AddressImportRun): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${run.id}/apply`, {
method: "POST",
body: JSON.stringify({ expected_plan_hash: run.plan_hash })
});
}
export function rollbackAddressImport(settings: ApiSettings, runId: string, reason: string): Promise<AddressImportRun> {
return apiFetch<AddressImportRun>(settings, `/api/v1/addresses/imports/${runId}/rollback`, {
method: "POST",
body: JSON.stringify({ 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`);
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const ADDRESSES_DOCUMENTATION = {
topicId: "addresses.boundary",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const ADDRESS_FIELDS_DOCUMENTATION = {
topicId: "addresses.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const ADDRESS_GOVERNANCE_DOCUMENTATION = {
topicId: "addresses.contact-point-resolution",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const ADDRESSES_I18N = {
requiredAction: "i18n:govoplan-addresses.required_action",
actor: "i18n:govoplan-addresses.actor",
destination: "i18n:govoplan-addresses.destination",
permissionDetails: "i18n:govoplan-addresses.permission_details",
permissionAction: "i18n:govoplan-addresses.permission_action",
permissionActor: "i18n:govoplan-addresses.permission_actor",
permissionDestination: "i18n:govoplan-addresses.permission_destination"
} as const;
+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;
+238
View File
@@ -150,6 +150,60 @@
width: min(960px, calc(100vw - 36px)); width: min(960px, calc(100vw - 36px));
} }
.address-import-dialog {
max-width: min(1080px, calc(100vw - 36px));
width: min(1080px, calc(100vw - 36px));
}
.address-import-workspace,
.address-import-profile-editor,
.address-import-preview {
display: grid;
gap: 14px;
min-height: 0;
}
.address-import-profile-actions {
align-items: end;
justify-content: flex-start;
padding-bottom: 1px;
}
.address-import-mapping-grid {
display: grid;
gap: 10px 14px;
grid-template-columns: repeat(3, minmax(0, 1fr));
max-height: min(42vh, 440px);
overflow: auto;
padding-right: 4px;
}
.address-import-preview > .address-sync-plan-grid {
border: 0;
gap: 8px;
grid-template-columns: repeat(6, minmax(0, 1fr));
max-height: none;
overflow: visible;
}
.address-import-preview > .address-sync-plan-grid > div {
background: var(--panel-soft);
border: var(--border-line);
border-radius: 6px;
display: grid;
gap: 2px;
padding: 9px 10px;
}
.address-import-preview > .address-sync-plan-grid strong {
font-size: 1.05rem;
}
.address-import-preview > .address-sync-plan-grid small {
color: var(--muted);
text-transform: capitalize;
}
.address-sync-record-list, .address-sync-record-list,
.address-sync-plan-grid { .address-sync-plan-grid {
display: grid; display: grid;
@@ -308,6 +362,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;
} }
@@ -415,6 +474,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;
@@ -510,6 +586,16 @@
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: 6px;
@@ -534,6 +620,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: 6px;
max-height: 300px;
overflow: auto;
}
.address-quality-row {
align-items: center;
background: transparent;
border: 0;
border-bottom: var(--border-line);
color: inherit;
display: grid;
font: inherit;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 10px 12px;
text-align: left;
width: 100%;
}
.address-quality-row:last-child {
border-bottom: 0;
}
.address-quality-row-button {
cursor: pointer;
grid-template-columns: minmax(0, 1fr);
}
.address-quality-row-button:hover {
background: var(--panel-soft);
}
.address-quality-row-heading {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.address-quality-row-main small {
color: var(--muted);
}
.address-governance-layout,
.address-governance-list {
display: grid;
gap: 12px;
}
.address-governance-list {
max-height: 260px;
overflow: auto;
}
.address-governance-rule {
align-items: center;
border-bottom: var(--border-line);
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 8px 0;
}
.address-governance-rule:last-child {
border-bottom: 0;
}
.address-governance-rule-main,
.address-governance-rule-heading {
display: flex;
gap: 6px;
}
.address-governance-rule-main {
align-items: flex-start;
flex-direction: column;
min-width: 0;
}
.address-governance-rule-heading {
align-items: center;
flex-wrap: wrap;
}
.address-form-row-email, .address-form-row-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;
@@ -587,4 +816,13 @@
.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;
}
} }