Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa390a69b9 | ||
|
|
aab4019612 | ||
|
|
3b7f17f862 | ||
|
|
5eaaecff34 | ||
|
|
eba0441acb | ||
|
|
ed3e556156 | ||
|
|
f1a2952d83 | ||
|
|
b3ca069644 | ||
|
|
4f6b223e44 | ||
|
|
f9a7185ce3 | ||
|
|
2c421022d4 | ||
|
|
e60339a5bf | ||
|
|
85e0e31e3d | ||
|
|
19e9096572 | ||
|
|
2e78b9ae50 | ||
|
|
67392f620f | ||
|
|
41ccd4c807 | ||
|
|
90a507d9a4 | ||
|
|
0c3d4eecb6 | ||
|
|
4e149ee669 | ||
|
|
bf1d7c9678 | ||
|
|
1545ea711e | ||
|
|
eab24750f9 |
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/addresses-webui",
|
"name": "@govoplan/addresses-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,11 +18,11 @@
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.11",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
+4
-2
@@ -4,14 +4,16 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-addresses"
|
name = "govoplan-addresses"
|
||||||
version = "0.1.9"
|
version = "0.1.18"
|
||||||
description = "GovOPlaN reusable address and recipient-source module."
|
description = "GovOPlaN reusable address and recipient-source module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defusedxml>=0.7.1",
|
"defusedxml>=0.7.1",
|
||||||
"govoplan-core>=0.1.11",
|
"govoplan-core>=0.1.18",
|
||||||
|
"ldap3>=2.9.1,<3",
|
||||||
|
"openpyxl>=3.1.5,<4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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.18",
|
||||||
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",),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+63
@@ -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")
|
||||||
+281
@@ -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)
|
||||||
+95
@@ -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")
|
||||||
+58
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/addresses-webui",
|
"name": "@govoplan/addresses-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.18",
|
||||||
"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.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
+533
-5
@@ -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;
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user