Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f1ff79e87 | ||
|
|
5cdf0ae9ff | ||
|
|
3458306e04 | ||
|
|
8740fb33f8 | ||
|
|
42fe262376 | ||
|
|
147f34c5c1 | ||
|
|
ec32ad2c37 | ||
|
|
7e5c1cf10c | ||
|
|
63d59a7eaa | ||
|
|
e6165b4d26 | ||
|
|
aa390a69b9 | ||
|
|
aab4019612 | ||
|
|
3b7f17f862 | ||
|
|
5eaaecff34 | ||
|
|
eba0441acb | ||
|
|
ed3e556156 | ||
|
|
f1a2952d83 | ||
|
|
b3ca069644 | ||
|
|
4f6b223e44 | ||
|
|
f9a7185ce3 | ||
|
|
2c421022d4 | ||
|
|
e60339a5bf | ||
|
|
85e0e31e3d | ||
|
|
19e9096572 | ||
|
|
2e78b9ae50 | ||
|
|
67392f620f | ||
|
|
41ccd4c807 | ||
|
|
90a507d9a4 | ||
|
|
0c3d4eecb6 | ||
|
|
4e149ee669 | ||
|
|
bf1d7c9678 | ||
|
|
1545ea711e | ||
|
|
eab24750f9 | ||
|
|
93dddbb8c5 | ||
|
|
04accaa206 | ||
|
|
75c9ece709 | ||
|
|
d005040a8e | ||
|
|
613fb15a80 | ||
|
|
5dc9392290 | ||
|
|
7237679a85 | ||
|
|
5ff154bc64 | ||
|
|
3ec4b3c4ad | ||
|
|
70ee3c0148 | ||
|
|
5d560d4c58 | ||
|
|
8b4cf362ca | ||
|
|
b13e5760c8 | ||
|
|
22d12d674b | ||
|
|
3d52cc86f9 | ||
|
|
f19350e65d | ||
|
|
420120af2f | ||
|
|
29cb336736 | ||
|
|
a588b90565 |
@@ -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
|
||||||
+350
@@ -0,0 +1,350 @@
|
|||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# Local WebUI test/build scratch directories
|
||||||
|
.component-test-build/
|
||||||
|
.module-test-build/
|
||||||
|
.policy-test-build/
|
||||||
|
.template-preview-test-build/
|
||||||
|
.import-test-build/
|
||||||
|
.import-run-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.policy-test-build/
|
||||||
|
webui/.template-preview-test-build/
|
||||||
|
webui/.import-test-build/
|
||||||
|
webui/.import-run-test-build/
|
||||||
|
|
||||||
|
# ---> Python
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# ---> VisualStudioCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Built Visual Studio Code Extensions
|
||||||
|
*.vsix
|
||||||
|
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# GovOPlaN local runtime state
|
||||||
|
runtime/
|
||||||
|
|
||||||
|
# GovOPlaN WebUI test output
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# GovOPlaN Addresses
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (domain).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
`govoplan-addresses` is the reusable address and recipient-source module. It
|
||||||
|
owns long-lived address directories and contact points and makes them available
|
||||||
|
to consumers through platform capabilities.
|
||||||
|
|
||||||
|
The campaign module may import campaign-local recipient tables, but reusable
|
||||||
|
address management belongs here.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
Milestone 1 is implemented. The module now owns persistent local address books
|
||||||
|
and contact CRUD under `/api/v1/addresses`, contributes `/address-book` to the
|
||||||
|
WebUI, and registers address permissions, role templates, database migrations,
|
||||||
|
tenant summaries, and uninstall guards.
|
||||||
|
|
||||||
|
The first UI supports user, group, tenant, and system-scoped address books,
|
||||||
|
multi-value contact methods, soft deletion, restore, read-only lookup/search,
|
||||||
|
and vCard import/export for common contact fields. Multi-file vCard imports now
|
||||||
|
create a persisted preview before mutation, expose duplicate suggestions and
|
||||||
|
per-card create/update/ignore choices, reject stale plans, and make identical
|
||||||
|
commit retries idempotent. Pending batches can be reloaded or cancelled.
|
||||||
|
Address-book, address-list, and selected-contact exports explicitly support
|
||||||
|
vCard 3.0 or 4.0 with deterministic ordering and a recorded content hash.
|
||||||
|
Imported vCards preserve source payload and revision metadata for later
|
||||||
|
sync/conflict work, while batch diagnostics expose only bounded metadata.
|
||||||
|
|
||||||
|
The backend and WebUI also support classical address lists: reusable groupings
|
||||||
|
of contacts or specific contact methods within one address book. Campaigns can
|
||||||
|
import address books and address lists through the core-mediated
|
||||||
|
`addresses.recipient_source` capability without importing address-module
|
||||||
|
internals. Broader operational `Verteiler` with mixed users, identities,
|
||||||
|
groups, functions, raw recipients, and nested lists belong in
|
||||||
|
`govoplan-dist-lists`.
|
||||||
|
|
||||||
|
The backend now also contains connector-neutral sync infrastructure. Address
|
||||||
|
books can be bound to external sources, sync attempts can record status,
|
||||||
|
tokens, ETags, revisions, diagnostics, tombstones, and conflicts, and read-only
|
||||||
|
or one-way-import sources make the owning address book read-only for normal
|
||||||
|
write paths. CardDAV discovery, source binding, dry-run preview, inbound vCard
|
||||||
|
sync, outbound create/update/delete for writable CardDAV sources, diagnostics,
|
||||||
|
tombstones, conflict persistence, source disconnect/delete UX, and a first sync
|
||||||
|
inspection UI are implemented. The conflict review UI compares stored local and
|
||||||
|
remote field payloads, can apply a stored remote vCard payload, and supports
|
||||||
|
manual per-field local/remote merge choices.
|
||||||
|
|
||||||
|
Address quality and duplicate handling are implemented as an operator workflow.
|
||||||
|
Contact points retain both their original and normalized values, field-level
|
||||||
|
provenance is append-only, and current quality states can mark a point valid,
|
||||||
|
invalid, returned, stale, or undeliverable. Those states flow into recipient
|
||||||
|
resolution with stable reason codes. The quality dialog shows bounded,
|
||||||
|
explainable duplicate suggestions and a correction queue. Merges record explicit
|
||||||
|
survivorship decisions, repair address-list memberships, preserve redirects for
|
||||||
|
stored contact references, and can be undone or split while the post-merge
|
||||||
|
evidence hash still matches.
|
||||||
|
|
||||||
|
API-managed CardDAV credentials are encrypted inside the source record. Source
|
||||||
|
deletion physically removes that credential material and records a non-secret
|
||||||
|
audit event in the same database transaction; destructive module retirement
|
||||||
|
audits every remaining credential before the owning tables are dropped. Legacy
|
||||||
|
external references are detached but are never sent to a secret provider for
|
||||||
|
deletion because Addresses cannot prove that it owns them.
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
`govoplan-addresses` owns:
|
||||||
|
|
||||||
|
- scoped address books, vCard-compatible contacts, and postal/email/phone
|
||||||
|
contact points
|
||||||
|
- classical address-only lists and recipient-source views
|
||||||
|
- consent, legal-basis, and communication-preference metadata
|
||||||
|
- deduplication, merge, and address quality workflows
|
||||||
|
- import/export of reusable address directories
|
||||||
|
- source provenance and change history
|
||||||
|
|
||||||
|
It must not own:
|
||||||
|
|
||||||
|
- campaign-local recipient snapshots
|
||||||
|
- delivery queues or send attempts
|
||||||
|
- SMTP/IMAP transport
|
||||||
|
- file storage
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
The module exposes core-mediated capabilities for:
|
||||||
|
|
||||||
|
- `addresses.lookup`: read-only contact/recipient lookup for autocomplete.
|
||||||
|
- `addresses.recipient_source`: immutable recipient snapshots for campaign,
|
||||||
|
reporting, mail-build, forms, portal, and postbox workflows.
|
||||||
|
- `addresses.contact_writer`: address-book-scoped write decisions and contact
|
||||||
|
creation for local or otherwise writable sources.
|
||||||
|
- `addresses.contact_point_resolution`: purpose-aware, channel-neutral
|
||||||
|
resolution and immutable snapshots for email, postal, internal-mail, and
|
||||||
|
portal targets.
|
||||||
|
- `addresses.people_search`: privacy-aware contact candidates for shared people
|
||||||
|
pickers.
|
||||||
|
- `distribution.recipient_channel_facts`: current channel, governance, and
|
||||||
|
quality facts for distribution and Policy consumers.
|
||||||
|
- `privacy.dsar.addresses`: tenant-bounded, minimized data-subject discovery
|
||||||
|
across contacts, contact points, list use, governance, provenance,
|
||||||
|
synchronization evidence, and operator attribution.
|
||||||
|
|
||||||
|
The DSAR provider accepts corroborated email/account selectors and namespaced
|
||||||
|
Addresses references. It does not export connector state, raw import or sync
|
||||||
|
payloads, opaque metadata, snapshot payloads, or merge before/after payloads.
|
||||||
|
Reusable contacts are never deleted automatically: shared/synchronized contact
|
||||||
|
changes require an authorized dependency review through the ordinary Addresses
|
||||||
|
workflows, while governance, quality, merge, sync, import, and attribution
|
||||||
|
evidence is retained with an explicit reason.
|
||||||
|
|
||||||
|
`addresses.recipient_source` returns:
|
||||||
|
|
||||||
|
- source id and display label
|
||||||
|
- normalized recipient rows
|
||||||
|
- email recipient fields
|
||||||
|
- source update marker
|
||||||
|
- provenance fields suitable for audit and campaign reports
|
||||||
|
|
||||||
|
Recipient sources currently include complete address books and classical
|
||||||
|
address lists. Address-list source IDs use `addresses:address_list:<id>` and
|
||||||
|
preserve the address-list entry ID in recipient provenance. Address-list entries
|
||||||
|
may point at a whole contact, a concrete email address, or a concrete postal
|
||||||
|
address. Email-oriented consumers snapshot email targets and whole-contact
|
||||||
|
entries with a usable email address; postal-only entries remain valid list
|
||||||
|
members for later postal/document workflows.
|
||||||
|
|
||||||
|
Legacy `addresses.recipient_source` consumers must store their own immutable
|
||||||
|
snapshot when they need historical evidence. Channel-neutral consumers may use
|
||||||
|
the dedicated freeze operation described below. The addresses module remains
|
||||||
|
the owner of reusable sources; domain consumers remain responsible for linking
|
||||||
|
their own records to snapshot evidence. Consumers must resolve these
|
||||||
|
capabilities through the platform registry and must not import address
|
||||||
|
ORM/service internals.
|
||||||
|
|
||||||
|
`addresses.contact_writer` returns an explicit decision before a consumer shows
|
||||||
|
or executes write actions: allowed/blocked, reason, user-facing message,
|
||||||
|
required scopes, source kind, read-only state, and provenance. The decision is
|
||||||
|
address-book specific; broader policy modules may later contribute to the same
|
||||||
|
decision path, but consumers should not import or duplicate policy logic.
|
||||||
|
|
||||||
|
For channel-neutral consumers, `addresses.contact_point_resolution` supersedes
|
||||||
|
the email-only shape without removing it. It accepts local contact IDs and
|
||||||
|
stable provider references such as `idm:identity:<id>`, applies an effective
|
||||||
|
date, communication purpose, address purpose, fallback rule, locale, and
|
||||||
|
domestic/international postal formatting, and returns candidates plus excluded
|
||||||
|
targets with stable reason codes. Bounded previews are live. A frozen snapshot
|
||||||
|
stores the complete values, source and governance revisions, provenance, and a
|
||||||
|
deterministic hash in Addresses so later contact edits cannot rewrite evidence.
|
||||||
|
|
||||||
|
The corresponding HTTP API is available below `/api/v1/addresses`:
|
||||||
|
|
||||||
|
- `POST /contact-points/resolve`
|
||||||
|
- `POST /contact-point-sources/preview`
|
||||||
|
- `POST /contact-point-snapshots`
|
||||||
|
- `GET /contact-point-snapshots/{snapshot_id}`
|
||||||
|
|
||||||
|
Quality, provenance, and reversible merge operations are available through:
|
||||||
|
|
||||||
|
- `GET /address-books/{book_id}/quality-summary`
|
||||||
|
- `GET /address-books/{book_id}/duplicate-suggestions`
|
||||||
|
- `GET|POST /contacts/{contact_id}/quality-decisions`
|
||||||
|
- `GET /contacts/{contact_id}/provenance`
|
||||||
|
- `GET /contacts/{contact_id}/redirect`
|
||||||
|
- `GET|POST /contact-merges`
|
||||||
|
- `POST /contact-merges/{merge_id}/undo`
|
||||||
|
- `POST /contact-merges/{merge_id}/split`
|
||||||
|
|
||||||
|
## Design Documents
|
||||||
|
|
||||||
|
- [Address module architecture](docs/ADDRESS_MODULE_ARCHITECTURE.md)
|
||||||
|
- [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
|
||||||
|
- [Address quality and reversible merges](docs/QUALITY_AND_MERGE.md)
|
||||||
|
- [AdreMa capability assessment and Distribution Lists roadmap](https://git.add-ideas.de/GovOPlaN/govoplan-dist-lists/src/branch/main/docs/ADREMA_CAPABILITY_ASSESSMENT.md)
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# GovOPlaN Addresses Module Architecture
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`govoplan-addresses` owns reusable contact and recipient-source data. Campaigns,
|
||||||
|
mail, scheduling, portal, postbox, forms, reporting, and other modules consume
|
||||||
|
address data through core-mediated capabilities and APIs, not by importing
|
||||||
|
address-module internals.
|
||||||
|
|
||||||
|
The implementation reference for contact data is vCard. CardDAV is the primary
|
||||||
|
address-book sync protocol. LDAP/Active Directory, Exchange/Microsoft 365,
|
||||||
|
Google Contacts, CSV/XLSX, LDIF, and batch vCard import/export are connector
|
||||||
|
targets layered on top of the same local model and sync contracts.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
`govoplan-addresses` owns:
|
||||||
|
|
||||||
|
- scoped address books
|
||||||
|
- vCard-compatible contacts and postal/email/phone contact-point data
|
||||||
|
- vCard import/export and vCard-compatible field mapping
|
||||||
|
- reusable recipient sources and classical address lists
|
||||||
|
- contact tags, categories, communication preferences, consent, and legal basis
|
||||||
|
- deduplication, merge, address quality checks, and suppression lists
|
||||||
|
- contact provenance, audit history, soft delete, and restore
|
||||||
|
- external-source bindings, sync state, conflicts, and read-only source markers
|
||||||
|
|
||||||
|
It does not own:
|
||||||
|
|
||||||
|
- campaign-local recipient snapshots and evidence
|
||||||
|
- mail transport, mailbox access, or delivery queues
|
||||||
|
- calendar events or iCalendar event storage
|
||||||
|
- global identity authentication or authorization decisions
|
||||||
|
- IDM identities, typed groups, effective-dated relationships, or identity
|
||||||
|
lifecycle state
|
||||||
|
- organization structures, units, function definitions, or function
|
||||||
|
assignments
|
||||||
|
- operational distribution lists/`Verteiler` with mixed address contacts, IDM
|
||||||
|
identities/groups, functions, raw targets, Dataflow rows, and nested lists
|
||||||
|
|
||||||
|
## Scopes
|
||||||
|
|
||||||
|
Address books can live in these scopes:
|
||||||
|
|
||||||
|
- `user`: personal address books and remembered contacts
|
||||||
|
- `group`: team/shared address books
|
||||||
|
- `tenant`: tenant-wide directories and approved shared lists
|
||||||
|
- `system`: platform-wide public/shared directories where policy allows it
|
||||||
|
|
||||||
|
The scope determines visibility, default permissions, sync credentials, and
|
||||||
|
whether downstream modules may reuse or mutate entries.
|
||||||
|
|
||||||
|
## Data Model Principles
|
||||||
|
|
||||||
|
The canonical model should preserve enough vCard semantics to round-trip common
|
||||||
|
fields:
|
||||||
|
|
||||||
|
- name components and formatted names
|
||||||
|
- nicknames and display names
|
||||||
|
- email addresses, phone numbers, postal addresses, URLs, notes, categories
|
||||||
|
- organization, title, role, department, `KIND`, and `RELATED` values needed for
|
||||||
|
vCard round-trip compatibility
|
||||||
|
- birthday/anniversary where allowed by policy
|
||||||
|
- photos/avatars where storage and privacy policy allow them
|
||||||
|
- calendar or scheduling addresses where present
|
||||||
|
- source IDs, revisions, ETags, sync tokens, and provenance
|
||||||
|
|
||||||
|
The model should support both normalized query fields and a preserved original
|
||||||
|
representation for import/export and conflict handling.
|
||||||
|
|
||||||
|
The local baseline implements scoped address books, contacts, normalized
|
||||||
|
email/phone/postal-address tables, tags, source kind/reference fields,
|
||||||
|
first-class source payload/revision fields, preserved original contact-point
|
||||||
|
values, and append-only field provenance. Imported
|
||||||
|
vCards preserve raw source payload and revision metadata for audit/debugging.
|
||||||
|
Sync sources, attempt state, tombstones, conflicts, and diagnostics are now
|
||||||
|
first-class backend tables and API resources. Connector-specific diffing,
|
||||||
|
CardDAV discovery, and conflict-resolution UI remain part of the connector
|
||||||
|
milestones.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
The first stable capabilities are:
|
||||||
|
|
||||||
|
- `addresses.recipient_source`: return immutable recipient snapshots for
|
||||||
|
campaigns, forms, reporting, and other send/build workflows.
|
||||||
|
- `addresses.lookup`: provide read-only lookup and autocomplete for mail,
|
||||||
|
campaign, scheduling, postbox, portal, and case workflows.
|
||||||
|
- `addresses.contact_writer`: provide address-book-scoped write target decisions
|
||||||
|
and contact creation for local or otherwise writable sources.
|
||||||
|
- `addresses.contact_point_resolution` version 1.x: resolve channel-neutral
|
||||||
|
contact points and freeze immutable recipient evidence.
|
||||||
|
|
||||||
|
Capabilities use DTOs and source IDs. Consumers must not receive ORM objects or
|
||||||
|
write address tables directly. Consumers that need historical evidence must
|
||||||
|
store their own immutable snapshot with source ID, source revision, and
|
||||||
|
provenance; they must not treat live address records as historical evidence.
|
||||||
|
|
||||||
|
`addresses.recipient_source` exposes both complete address books and classical
|
||||||
|
address lists. Address-book sources use `addresses:address_book:<id>`.
|
||||||
|
Address-list sources use `addresses:address_list:<id>` and include the
|
||||||
|
address-list entry ID in each recipient's provenance. The legacy snapshot DTO
|
||||||
|
remains email-oriented for compatible campaign consumers.
|
||||||
|
|
||||||
|
Channel-neutral consumers use `addresses.contact_point_resolution`, which
|
||||||
|
supports email, postal, internal-mail, and portal targets, including postal-only
|
||||||
|
address-list entries. Requests make effective date, communication purpose,
|
||||||
|
address purpose, fallback behavior, locale, and domestic/international postal
|
||||||
|
formatting explicit. Results retain stable subject/contact/contact-point IDs,
|
||||||
|
source, preference and consent revisions, provenance, and reasons for excluded
|
||||||
|
or unresolved candidates.
|
||||||
|
|
||||||
|
Live previews are bounded to 500 rows per page and 20,000 source members per
|
||||||
|
request. Frozen snapshots persist resolved values and exclusions with a
|
||||||
|
deterministic hash; reading a snapshot never resolves the live contact again.
|
||||||
|
Mixed-audience expansion and final cross-provider Policy/channel decisions
|
||||||
|
remain owned by Distribution Lists and Policy. The contract is defined in Core,
|
||||||
|
and Addresses does not import IDM, Organizations, or Distribution Lists
|
||||||
|
implementations.
|
||||||
|
|
||||||
|
The writer capability is intentionally address-book specific. It answers
|
||||||
|
whether the current principal may perform an operation such as `create_contact`,
|
||||||
|
`update_contact`, or `delete_contact` against a concrete address book. The
|
||||||
|
decision payload includes:
|
||||||
|
|
||||||
|
- `allowed`
|
||||||
|
- stable `reason`
|
||||||
|
- user-facing `message`
|
||||||
|
- required scopes
|
||||||
|
- source kind
|
||||||
|
- read-only state
|
||||||
|
- scope and tenant provenance
|
||||||
|
|
||||||
|
Policy modules or connector sync state may later add inputs to this decision,
|
||||||
|
but consumers must continue to call the address capability/API instead of
|
||||||
|
importing policy logic or address services directly. Disabled or read-only UI
|
||||||
|
actions should surface the returned `message` on hover.
|
||||||
|
|
||||||
|
## Sync Model
|
||||||
|
|
||||||
|
Every synced address book tracks or can track:
|
||||||
|
|
||||||
|
- connector type and external account/source
|
||||||
|
- external address-book ID and display name
|
||||||
|
- local address-book scope
|
||||||
|
- sync direction: read-only, one-way import, one-way export, two-way
|
||||||
|
- sync token, ETag/revision, last successful sync, last attempted sync
|
||||||
|
- deleted markers/tombstones
|
||||||
|
- conflict status and resolution decision
|
||||||
|
- connector diagnostics and rate-limit/backoff state
|
||||||
|
|
||||||
|
Sync conflict UX must show the local value, remote value, source, timestamp, and
|
||||||
|
available action. Silent overwrite is not acceptable.
|
||||||
|
|
||||||
|
Sync infrastructure is intentionally connector-neutral. CardDAV, LDAP,
|
||||||
|
Exchange/Microsoft 365, Google Contacts, CSV/XLSX/LDIF import profiles, and
|
||||||
|
future connectors must write through `addresses_sync_sources` and related
|
||||||
|
records instead of inventing connector-specific status tables. Connector jobs
|
||||||
|
may mark a source `running`, `succeeded`, `failed`, or `conflict`; read-only and
|
||||||
|
one-way-import sources propagate a read-only decision to the owning address
|
||||||
|
book, which in turn blocks normal contact writes through the existing writer
|
||||||
|
capability/API.
|
||||||
|
|
||||||
|
The first CardDAV implementation supports discovery, source binding, dry-run
|
||||||
|
preview, inbound vCard sync, outbound create/update/delete for writable
|
||||||
|
sources, sync-token/full-sync fallback, tombstones, diagnostics, and persisted
|
||||||
|
conflicts. Outbound writes use ETag preconditions; stale local state must become
|
||||||
|
a conflict instead of silently overwriting remote data. The first conflict
|
||||||
|
review UI compares stored local and remote field payloads and can apply a
|
||||||
|
stored remote vCard payload or a manual per-field local/remote merge payload.
|
||||||
|
Source disconnect/delete removes the source binding and related sync records
|
||||||
|
while keeping local contacts. Because API-managed CardDAV credentials are
|
||||||
|
encrypted in the source row, the same transaction physically removes their
|
||||||
|
ciphertext and emits non-secret credential-deletion audit evidence. Destructive
|
||||||
|
module retirement audits all remaining owned credential material before table
|
||||||
|
removal. An unowned legacy reference is detached rather than passed to an
|
||||||
|
external secret provider.
|
||||||
|
|
||||||
|
LDAP and Active Directory use the same source, plan, diagnostic, tombstone, and
|
||||||
|
provider-health records. Endpoints must use LDAPS or StartTLS and may reference
|
||||||
|
only a visible reusable credential envelope; bind secrets are never copied into
|
||||||
|
source metadata. Root-DSE discovery returns candidate base DNs. A configured
|
||||||
|
source performs bounded paged searches and maps explicit attributes to contact
|
||||||
|
fields. Stable source keys plus `modifyTimestamp`, `uSNChanged`, `entryCSN`, or
|
||||||
|
a deterministic attribute digest make refreshes idempotent. Only a complete
|
||||||
|
successful search can infer deletion. A timeout, bind failure, malformed entry,
|
||||||
|
duplicate key, or configured entry limit retains existing contacts and reports
|
||||||
|
the source as failed/stale instead of creating tombstones.
|
||||||
|
|
||||||
|
## Static Mapped Imports
|
||||||
|
|
||||||
|
CSV, XLSX, and LDIF use versioned, scoped mapping profiles rather than live sync
|
||||||
|
sources. Profiles retain delimiter, encoding, header or worksheet selection,
|
||||||
|
stable source-key mapping, field mappings, locale and tags, row limits, and
|
||||||
|
explicit duplicate, blank-value, and existing-contact policies. Updating a
|
||||||
|
profile creates an immutable next version; prior import runs continue to point
|
||||||
|
at the reviewed version.
|
||||||
|
|
||||||
|
Preview decodes at most 10 MB and 10,000 rows, validates every referenced
|
||||||
|
column and source key, and returns an effect or diagnostic for every data row.
|
||||||
|
XLSX parsing is read-only; formulas are rejected and macros/legacy workbook
|
||||||
|
formats are not accepted. The input SHA-256 and deterministic plan hash are
|
||||||
|
stored with full effects. Apply uses exactly that plan, rejects changed target
|
||||||
|
contacts, and is idempotent. Created IDs and pre-update snapshots provide a
|
||||||
|
guarded rollback: rollback proceeds only while each imported contact still
|
||||||
|
matches its recorded post-apply hash. Arbitrary transforms remain Dataflow's
|
||||||
|
responsibility; Files and Datasources are optional origins, not prerequisites
|
||||||
|
for direct upload.
|
||||||
|
|
||||||
|
LDIF is unfolded and parsed as a bounded stream of entries. Attribute names are
|
||||||
|
case-insensitive; UTF-8 and base64-encoded text and repeated values are retained
|
||||||
|
for mapping, while binary and URL values produce diagnostics and are never
|
||||||
|
projected or fetched. The default change-record policy rejects change records.
|
||||||
|
A profile can instead ignore them, or treat `changetype: add` as a static entry;
|
||||||
|
modify and delete records are never translated into contact mutations. Entry
|
||||||
|
hashes, input/plan hashes, mapping-version provenance, and the same correction
|
||||||
|
and guarded rollback lifecycle apply as for CSV and XLSX.
|
||||||
|
|
||||||
|
Persisted import runs can be resumed through `/address-book?import_run=<id>`.
|
||||||
|
The WebUI reloads the bounded run projection, selects its address book and
|
||||||
|
mapping version, and restores statistics, diagnostics, effects, and lifecycle
|
||||||
|
state. Apply and rollback both carry the reviewed plan hash. The normal read
|
||||||
|
projection never includes uploaded bytes or private before-images, and an
|
||||||
|
unknown, expired, hidden, or cross-tenant id is presented as one unavailable
|
||||||
|
state so the deep link cannot enumerate another tenant's imports.
|
||||||
|
|
||||||
|
## Quality, Deduplication, And Recovery
|
||||||
|
|
||||||
|
Quality is evidence about a concrete contact point, separate from communication
|
||||||
|
consent or Policy. Effective decisions use one of `valid`, `invalid`,
|
||||||
|
`returned`, `stale`, or `undeliverable`, retain reason/evidence references, and
|
||||||
|
end an overlapping prior decision rather than rewriting history. Recipient
|
||||||
|
capabilities project the current decision into a stable status and reason code;
|
||||||
|
consumers can exclude invalid points or explicitly handle stale points without
|
||||||
|
copying Addresses rules.
|
||||||
|
|
||||||
|
Duplicate suggestions are bounded to 500 scanned contacts and 100 returned
|
||||||
|
pairs. Every score is composed from visible exact-match features such as a
|
||||||
|
normalized email, phone, postal address, or name/organization combination. A
|
||||||
|
suggestion does not mutate data.
|
||||||
|
|
||||||
|
A merge is an explicit, transactional decision. The caller selects a surviving
|
||||||
|
contact, scalar-field sources, source precedence, and either union or
|
||||||
|
survivor-only contact-point handling. The merge records before/after evidence
|
||||||
|
and hashes, field/contact-point decisions, copied quality/governance evidence,
|
||||||
|
and stable loser-to-winner redirects. Address-list entries are repointed in the
|
||||||
|
same transaction. Undo and split restore the recorded contacts and memberships
|
||||||
|
only when the current evidence still matches the post-merge hash; later edits
|
||||||
|
must be reconciled first. Core change-sequence evidence is always written. Core
|
||||||
|
audit entries are written by HTTP mutation routes without requiring the
|
||||||
|
optional Audit module.
|
||||||
|
|
||||||
|
## Connector Direction
|
||||||
|
|
||||||
|
Implement connectors in this order:
|
||||||
|
|
||||||
|
1. vCard import/export and batch import.
|
||||||
|
2. CardDAV address books.
|
||||||
|
3. LDAP/Active Directory read-only directories and reusable CSV/XLSX mapping
|
||||||
|
profiles (implemented).
|
||||||
|
4. [Microsoft Graph for Microsoft 365](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/16),
|
||||||
|
[explicit on-premises Exchange profiles](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/17),
|
||||||
|
and [Google People](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/18).
|
||||||
|
5. [LDIF import](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/20)
|
||||||
|
and [selective/large-batch vCard workflows](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/21).
|
||||||
|
|
||||||
|
The live connectors use the existing sync-source model. LDAP is read-only;
|
||||||
|
Microsoft Graph and Google start with read-only/import and gate two-way mode on
|
||||||
|
conditional-write and outcome-reconciliation tests. On-premises Exchange first
|
||||||
|
probes and records an explicit supported server/API profile. CSV/XLSX, LDIF,
|
||||||
|
and uploaded vCard are static one-way imports, not writable remote sources.
|
||||||
|
|
||||||
|
Connector runtime behavior should reuse shared connector concepts where useful:
|
||||||
|
configured endpoints, credentials, dry-run, diagnostics, rate limits, and audit
|
||||||
|
events. The shared contract work is tracked in
|
||||||
|
[`govoplan-connectors#8`](https://git.add-ideas.de/GovOPlaN/govoplan-connectors/issues/8);
|
||||||
|
Addresses remains the owner of contact mapping, provenance, quality, and sync
|
||||||
|
state.
|
||||||
|
|
||||||
|
## Cross-Module Integration
|
||||||
|
|
||||||
|
Campaigns should consume `addresses.recipient_source` through the platform
|
||||||
|
registry and freeze snapshots into campaign versions. Mail should consume
|
||||||
|
`addresses.lookup` for autocomplete and `addresses.contact_writer` for "add
|
||||||
|
contact" workflows. Scheduling should use lookup for attendees and organizers.
|
||||||
|
Portal, postbox, cases, forms, and reporting should link to contact records by
|
||||||
|
stable IDs while keeping their own domain evidence. Cross-module UI must hide
|
||||||
|
write actions when no writable target exists, or show the writer decision
|
||||||
|
message when a disabled action remains visible for context.
|
||||||
|
|
||||||
|
Operational distribution lists and reusable dynamic segments belong in
|
||||||
|
`govoplan-dist-lists`. They may consume address lists as one entry type, but
|
||||||
|
they own mixed recipient expansion for address contacts, IDM identities and
|
||||||
|
typed groups, organization units, functions/effective incumbents, raw targets,
|
||||||
|
Dataflow-backed rows, and nested lists. Workflow owns `Umlauf` execution state;
|
||||||
|
distribution lists define who is included, not how work circulates.
|
||||||
|
|
||||||
|
Organizations owns unit and function definitions. IDM owns effective-dated
|
||||||
|
identity-to-function assignments and typed group relationships. Identity
|
||||||
|
lifecycle status is not a business audience status; a selectable business
|
||||||
|
status is represented by a group, function, or effective-dated relationship.
|
||||||
|
Addresses may link contact points to stable provider references without copying
|
||||||
|
those provider-owned facts.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
The following are valuable but not required for the first functional milestone:
|
||||||
|
|
||||||
|
- two-way sync conflict UI
|
||||||
|
- Microsoft/Google connectors
|
||||||
|
- richer vCard `KIND`/`RELATED` round-trip and provider-reference linking
|
||||||
|
- advanced consent-policy automation
|
||||||
|
- contact activity timeline across all modules
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# GovOPlaN Addresses Implementation Plan
|
||||||
|
|
||||||
|
## Milestone 1: Functional Local Address Books
|
||||||
|
|
||||||
|
Goal: make `govoplan-addresses` useful without external sync.
|
||||||
|
|
||||||
|
Primary issue: `govoplan-addresses#3`.
|
||||||
|
|
||||||
|
Status: implemented. The persistent backend, router, migration, permissions,
|
||||||
|
role templates, lookup endpoint, restore support, multi-value contact editor,
|
||||||
|
and first WebUI are in place.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- [x] add backend tables and migrations for address books, contacts, contact
|
||||||
|
methods, postal addresses, tags, and provenance
|
||||||
|
- [x] add permissions and role templates for viewing and managing address books
|
||||||
|
- [x] implement address-book CRUD API
|
||||||
|
- [x] implement contact CRUD API
|
||||||
|
- [x] implement scoped WebUI views for user, group, tenant, and system books
|
||||||
|
- [x] support soft delete
|
||||||
|
- [x] add restore API/UI for soft-deleted address books and contacts
|
||||||
|
- [x] expose read-only contact lookup for the WebUI
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- [x] an admin can create tenant/system books
|
||||||
|
- [x] a user can create a personal book and contacts
|
||||||
|
- [x] address data survives restart and appears in the address-book UI
|
||||||
|
- [x] disabled or read-only actions explain why they are unavailable
|
||||||
|
|
||||||
|
## Milestone 2: vCard Foundation
|
||||||
|
|
||||||
|
Goal: make the model standards-based before adding sync.
|
||||||
|
|
||||||
|
Primary issue: `govoplan-addresses#4`.
|
||||||
|
|
||||||
|
Status: implemented. The module can import and export common vCard fields for
|
||||||
|
local books, preserves imported raw vCard payload and revision metadata in
|
||||||
|
first-class source fields, reports field/card import issues, and has round-trip
|
||||||
|
and partial-import tests. Full two-way connector conflict resolution remains in
|
||||||
|
the sync milestones.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- [x] define vCard-compatible DTOs
|
||||||
|
- [x] preserve original imported vCard data and normalized query fields
|
||||||
|
- [x] import `.vcf` files into a selected address book
|
||||||
|
- [x] export contacts/address books as vCard
|
||||||
|
- [x] add validation for common vCard fields
|
||||||
|
- [x] add tests for round-trip import/export of names, emails, phones, postal
|
||||||
|
addresses, organization fields, notes, categories, and URLs
|
||||||
|
- [x] add field/card validation messages for batch import
|
||||||
|
- [x] promote original payload and revision handling into first-class contact
|
||||||
|
source fields
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- [x] common vCard files can be imported and exported without losing core fields
|
||||||
|
- [x] normalized search fields are populated during import
|
||||||
|
- [x] invalid entries produce field-level actionable validation messages
|
||||||
|
|
||||||
|
## Milestone 3: Core Capabilities
|
||||||
|
|
||||||
|
Goal: allow other modules to use addresses without dependencies.
|
||||||
|
|
||||||
|
Primary issue: `govoplan-addresses#5`.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- [x] implement `addresses.lookup`
|
||||||
|
- [x] implement `addresses.recipient_source`
|
||||||
|
- [x] define immutable recipient snapshot DTOs
|
||||||
|
- [x] expose source provenance in capability responses
|
||||||
|
- [x] expose classical address lists as `addresses.recipient_source` sources
|
||||||
|
- [x] expose versioned channel-neutral contact-point resolution for local and
|
||||||
|
stable provider subject references
|
||||||
|
- [x] support purpose/address-purpose selection, deterministic fallback,
|
||||||
|
locale, and domestic/international postal rendering
|
||||||
|
- [x] add bounded source previews and immutable postal/email snapshots
|
||||||
|
- [x] add module presence/capability tests
|
||||||
|
- [x] document consumer rules for campaign, mail, scheduling, portal, postbox, and
|
||||||
|
reporting
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- [x] campaign can request a recipient source via core-mediated capability
|
||||||
|
- [x] mail/scheduling can request autocomplete candidates via core-mediated lookup
|
||||||
|
- [x] consumers do not import `govoplan_addresses`
|
||||||
|
- [x] postal-only contacts/list entries can be resolved without changing the
|
||||||
|
legacy email recipient-source contract
|
||||||
|
|
||||||
|
## Milestone 4: Campaign Integration
|
||||||
|
|
||||||
|
Goal: replace campaign-local reusable address concepts with address-module
|
||||||
|
sources.
|
||||||
|
|
||||||
|
Primary issue: `govoplan-campaign#55`.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- [x] add campaign recipient-source picker when `addresses.recipient_source` exists
|
||||||
|
- [x] snapshot selected address-source rows into the campaign version
|
||||||
|
- [x] store source ID, revision, and provenance in campaign evidence
|
||||||
|
- [x] show stale-source warnings when an address source changed after selection
|
||||||
|
- [x] keep campaign import for one-off local recipient data
|
||||||
|
- [x] define classical address lists as reusable address-domain sources
|
||||||
|
- [x] add address-list selection and management UI in addresses/campaign
|
||||||
|
- [ ] define segments/dynamic filters before exposing them as campaign sources
|
||||||
|
Deferred deliberately: classical address lists are stable now; dynamic
|
||||||
|
segments need their own filter model, stale-source semantics, and audit
|
||||||
|
evidence before campaign can snapshot them safely.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- [x] campaign works without addresses installed
|
||||||
|
- [x] campaign offers address-source selection when addresses is installed
|
||||||
|
- [x] built campaigns remain auditable after source contacts change
|
||||||
|
|
||||||
|
## Milestone 5: Mail And Scheduling Integration
|
||||||
|
|
||||||
|
Goal: make contacts visible where users naturally need them.
|
||||||
|
|
||||||
|
Primary issues: `govoplan-mail#13` and `govoplan-scheduling#2`.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- [x] add mail recipient/autocomplete integration through `addresses.lookup`
|
||||||
|
at API/capability level; visible compose UI reuse follows when mail
|
||||||
|
compose exists
|
||||||
|
- [x] expose `addresses.contact_writer` for explicit address-book write
|
||||||
|
decisions, required scopes, read-only/source reasons, and provenance
|
||||||
|
- [ ] add "add sender/contact" actions in consuming UIs by using
|
||||||
|
`addresses.contact_writer`
|
||||||
|
- [x] add scheduling attendee/organizer lookup integration
|
||||||
|
- [x] preserve module independence when addresses is absent
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- [x] mail and scheduling build without addresses installed
|
||||||
|
- [x] when addresses is installed, lookup improves recipient/attendee entry
|
||||||
|
- [x] address write decisions expose why actions are unavailable for read-only
|
||||||
|
sources, deleted books, missing scopes, or unsupported operations
|
||||||
|
- [ ] consuming UI write actions are hidden or explained with the
|
||||||
|
`addresses.contact_writer` decision payload
|
||||||
|
|
||||||
|
## Milestone 6: Sync Infrastructure
|
||||||
|
|
||||||
|
Goal: prepare external address books without committing to all connectors at
|
||||||
|
once.
|
||||||
|
|
||||||
|
Primary issue: `govoplan-addresses#6`.
|
||||||
|
|
||||||
|
Status: implemented for the first connector path. The connector-neutral backend
|
||||||
|
substrate is in place: sync sources, source status, read-only propagation,
|
||||||
|
tombstones, conflicts, diagnostics, attempt transitions, dry-run preview,
|
||||||
|
audit-event emission, and a first sync inspection UI.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- [x] add address-source connector configuration
|
||||||
|
- [x] add sync state, ETags, revisions, tokens, tombstones, and conflict records
|
||||||
|
- [x] add sync diagnostics
|
||||||
|
- [x] add audit events for sync attempts, conflicts, previews, completions, and
|
||||||
|
resolutions
|
||||||
|
- [x] support read-only and writable source flags
|
||||||
|
- [x] add dry-run and preview for connector changes
|
||||||
|
- [x] add admin/user UI for source status, diagnostics, tombstones, and conflicts
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- [x] a connector can report planned creates/updates/deletes before applying them
|
||||||
|
- [x] sync failures are visible in the UI
|
||||||
|
- [x] conflicts are persisted and do not silently overwrite data
|
||||||
|
|
||||||
|
## Milestone 7: CardDAV
|
||||||
|
|
||||||
|
Goal: implement the first real standards-based sync connector.
|
||||||
|
|
||||||
|
Primary issue: `govoplan-addresses#7`.
|
||||||
|
|
||||||
|
Status: implemented for the first standards-based sync path. Discovery, source
|
||||||
|
binding, full/sync-token inbound sync, outbound create/update/delete for
|
||||||
|
writable sources, dry-run preview, diagnostics, tombstones, and persisted
|
||||||
|
conflict records are implemented. Source disconnect/delete UX and conflict
|
||||||
|
review are implemented; stored remote vCard payloads can be applied from the
|
||||||
|
review UI, and manual per-field local/remote merge choices are supported.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- [x] add CardDAV account/address-book discovery
|
||||||
|
- [x] sync vCard resources into scoped address books
|
||||||
|
- [x] support read-only and writable source flags
|
||||||
|
- [x] push local creates, updates, and deletes to writable CardDAV sources with
|
||||||
|
ETag preconditions
|
||||||
|
- [x] handle ETag changes, deletes, and conflicts
|
||||||
|
- [x] add connection discovery and diagnostics
|
||||||
|
- [x] add source disconnect/delete UX that keeps local contacts
|
||||||
|
- [x] add conflict review UI with local/remote field comparison
|
||||||
|
- [x] apply stored remote vCard payloads from conflict resolution
|
||||||
|
- [x] support manual per-field local/remote merge choices for stored vCard payloads
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- [x] a CardDAV source can be connected, synced, and inspected
|
||||||
|
- [x] CardDAV source disconnect/delete UI
|
||||||
|
- [x] contacts can be refreshed without duplicating entries
|
||||||
|
- [x] conflict and permission states are visible to the user
|
||||||
|
- [x] outbound CardDAV writes for writable remote books
|
||||||
|
- [x] field-level conflict review UI
|
||||||
|
- [x] manual per-field merge editing
|
||||||
|
|
||||||
|
## Milestone 8: Additional Connectors And Advanced Address Features
|
||||||
|
|
||||||
|
Goal: expand beyond CardDAV after the model and sync engine are stable.
|
||||||
|
|
||||||
|
Primary issues: `govoplan-addresses#8`, `govoplan-addresses#9`,
|
||||||
|
`govoplan-addresses#10`, and `govoplan-connectors#8`.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- [ ] [LDAP/Active Directory read-only directory connector](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/15)
|
||||||
|
- [ ] [Microsoft Graph contacts connector for Microsoft 365](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/16)
|
||||||
|
- [ ] [On-premises Exchange connector profile](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/17)
|
||||||
|
- [ ] [Google People contacts connector](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/18)
|
||||||
|
- [ ] [Reusable CSV/XLSX import mapping profiles](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/19)
|
||||||
|
- [x] [Bounded LDIF import profile](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/20)
|
||||||
|
- [ ] [Selective and large-batch vCard workflows](https://git.add-ideas.de/GovOPlaN/govoplan-addresses/issues/21)
|
||||||
|
- [x] classical address-list UI; reusable static/dynamic operational segments move
|
||||||
|
to `govoplan-dist-lists`
|
||||||
|
- [x] operational distribution lists move to `govoplan-dist-lists`
|
||||||
|
- [x] consent, legal-basis, suppression, and communication preferences
|
||||||
|
- [x] bounded, explainable deduplication and reversible merge/split workflow
|
||||||
|
- [x] contact-point quality states, normalization, original-value preservation,
|
||||||
|
field provenance, and correction dashboard
|
||||||
|
- [x] stable redirect resolution for merged contact references
|
||||||
|
- [ ] richer vCard `KIND`/`RELATED` round-trip and stable links to IDM/Organizations
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- each connector or advanced feature can be enabled independently
|
||||||
|
- users can understand where data came from and whether they may edit it
|
||||||
|
- downstream modules can safely use contacts without owning them
|
||||||
|
|
||||||
|
Issues #9 and #10 are implemented. Issue #8 is complete as a portfolio split:
|
||||||
|
issues #15-#21 independently track each connector/import profile with explicit
|
||||||
|
direction, dry-run, diagnostics, provenance, recovery, and module-independence
|
||||||
|
requirements.
|
||||||
|
|
||||||
|
## First Implementation Recommendation
|
||||||
|
|
||||||
|
Start with Milestone 1 and enough of Milestone 2 to define the data model
|
||||||
|
correctly. Do not start CardDAV before the local vCard-compatible storage and
|
||||||
|
API are stable.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Addresses Interface Pattern Migration
|
||||||
|
|
||||||
|
This migration applies the GovOPlaN interface pattern language to the Address
|
||||||
|
Book route, source tree, contact directory/detail workspace, governance facts,
|
||||||
|
imports, synchronization, quality review, and reversible merge operations.
|
||||||
|
|
||||||
|
## Surface Inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Consequence class | Contract |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/address-book` source tree | Governed directory | Select, create, archive, restore, import, or connect source | Shared loading/empty/error, permission, read-only, help, and destructive confirmation states |
|
||||||
|
| Contact list/detail | Directory/list-detail | Inspect, create, revise, archive, restore, export, or add to list | Server pagination, stable selection, contextual contact semantics, and guarded drafts |
|
||||||
|
| Address-list editor/membership | Reference collection editor | Group reusable contact points | Same-book constraint, duplicate explanation, read-only provenance, and retained references |
|
||||||
|
| Communication governance | Effective-policy fact editor | Allow, suppress, prefer, or end a channel fact | Effective dates, legal/evidence context, required permissions, and retained history |
|
||||||
|
| CardDAV/LDAP/import/sync | External-provider operation | Preview and apply bounded external change | Explicit authority/direction, credentials, diagnostics, conflicts, stale state, and outcome evidence |
|
||||||
|
| Quality and merge | Governed correction workflow | Record quality, merge, undo, or split | Required reason, chosen survivor/field provenance, confirmation, redirects, and reversible evidence |
|
||||||
|
|
||||||
|
## Consequence And Availability Rules
|
||||||
|
|
||||||
|
- User, group, tenant, and authorized system scopes determine discovery and
|
||||||
|
management authority. Visible inherited or external sources can be read-only.
|
||||||
|
- Address lists group contact points in one address book. Cross-module reusable
|
||||||
|
recipient expansion remains owned by Distribution Lists.
|
||||||
|
- Archive/delete actions are confirmed and preserve governed history according
|
||||||
|
to retention. External-source disconnect retains local contacts but removes
|
||||||
|
source diagnostics and conflict state as explicitly stated.
|
||||||
|
- Imports and synchronization separate preview from apply; incomplete external
|
||||||
|
reads never infer deletions.
|
||||||
|
- vCard batch upload accepts multiple files, persists a non-mutating preview,
|
||||||
|
and requires an explicit create/update/ignore choice for every reviewed card.
|
||||||
|
Reload and cancellation preserve the pending plan; only applying the matching
|
||||||
|
plan hash mutates contacts. Scoped exports name the selected vCard version and
|
||||||
|
use deterministic ordering.
|
||||||
|
- Merge and communication-governance operations append auditable evidence and
|
||||||
|
never silently erase prior state.
|
||||||
|
- Request feedback is rendered as a compact shared alert over the full-height
|
||||||
|
workspace. It does not become a grid row or displace the source, contact, and
|
||||||
|
detail columns.
|
||||||
|
|
||||||
|
Backend and WebUI manifests publish matching route/section/action surfaces.
|
||||||
|
English and German catalogues include the owned interaction vocabulary; major
|
||||||
|
object drafts are guarded, and optional modules remain behind declared
|
||||||
|
capabilities rather than private imports.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/addresses-webui",
|
||||||
|
"version": "0.1.21",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/addresses.css": "./webui/src/styles/addresses.css"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-addresses"
|
||||||
|
version = "0.1.21"
|
||||||
|
description = "GovOPlaN reusable address and recipient-source module."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = [
|
||||||
|
"defusedxml>=0.7.1",
|
||||||
|
"govoplan-core>=0.1.18",
|
||||||
|
"ldap3>=2.9.1,<3",
|
||||||
|
"openpyxl>=3.1.5,<4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
govoplan_addresses = ["py.typed"]
|
||||||
|
|
||||||
|
[project.entry-points."govoplan.modules"]
|
||||||
|
addresses = "govoplan_addresses.backend.manifest:get_manifest"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""GovOPlaN addresses module."""
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Backend integration for the GovOPlaN addresses module."""
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,581 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import posixpath
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
|
from defusedxml import ElementTree as SafeElementTree
|
||||||
|
from govoplan_core.security.outbound_http import (
|
||||||
|
OutboundHttpError,
|
||||||
|
bounded_response_bytes,
|
||||||
|
build_outbound_http_opener,
|
||||||
|
validate_outbound_http_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCardDAVError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCardDAVSyncUnsupported(AddressCardDAVError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCardDAVNotFound(AddressCardDAVError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCardDAVPreconditionFailed(AddressCardDAVError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCardDAVTransport(Protocol):
|
||||||
|
def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AddressCardDAVObject:
|
||||||
|
href: str
|
||||||
|
etag: str | None = None
|
||||||
|
address_data: str | None = None
|
||||||
|
deleted: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AddressCardDAVReportResult:
|
||||||
|
objects: list[AddressCardDAVObject] = field(default_factory=list)
|
||||||
|
sync_token: str | None = None
|
||||||
|
ctag: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AddressCardDAVAddressBook:
|
||||||
|
collection_url: str
|
||||||
|
href: str
|
||||||
|
display_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
ctag: str | None = None
|
||||||
|
sync_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AddressCardDAVWriteResult:
|
||||||
|
href: str
|
||||||
|
etag: str | None = None
|
||||||
|
status: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _DiscoveryResponse:
|
||||||
|
href: str
|
||||||
|
display_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
ctag: str | None = None
|
||||||
|
sync_token: str | None = None
|
||||||
|
is_addressbook: bool = False
|
||||||
|
principal_hrefs: tuple[str, ...] = ()
|
||||||
|
addressbook_home_set_hrefs: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _DiscoveryDraft:
|
||||||
|
display_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
ctag: str | None = None
|
||||||
|
sync_token: str | None = None
|
||||||
|
is_addressbook: bool = False
|
||||||
|
principal_hrefs: list[str] = field(default_factory=list)
|
||||||
|
addressbook_home_set_hrefs: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCardDAVClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
collection_url: str,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
bearer_token: str | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
transport: AddressCardDAVTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.collection_url = ensure_collection_url(collection_url)
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.bearer_token = bearer_token
|
||||||
|
self.timeout = timeout
|
||||||
|
self.transport = transport or urllib_transport
|
||||||
|
|
||||||
|
def propfind_collection(self) -> AddressCardDAVReportResult:
|
||||||
|
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:CS="http://calendarserver.org/ns/">
|
||||||
|
<D:prop>
|
||||||
|
<D:displayname/>
|
||||||
|
<D:sync-token/>
|
||||||
|
<CS:getctag/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>"""
|
||||||
|
payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207})
|
||||||
|
return parse_multistatus(payload)
|
||||||
|
|
||||||
|
def discover_addressbooks(self) -> list[AddressCardDAVAddressBook]:
|
||||||
|
start_url = self.collection_url
|
||||||
|
addressbooks: dict[str, AddressCardDAVAddressBook] = {}
|
||||||
|
home_urls: list[str] = []
|
||||||
|
principal_urls: list[str] = []
|
||||||
|
visited_urls: set[tuple[str, str]] = set()
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
def add_home_href(base_url: str, href: str) -> None:
|
||||||
|
url = ensure_collection_url(absolute_dav_url(base_url, href))
|
||||||
|
if url not in home_urls:
|
||||||
|
home_urls.append(url)
|
||||||
|
|
||||||
|
def add_principal_href(base_url: str, href: str) -> None:
|
||||||
|
url = ensure_collection_url(absolute_dav_url(base_url, href))
|
||||||
|
if url not in principal_urls:
|
||||||
|
principal_urls.append(url)
|
||||||
|
|
||||||
|
def add_addressbook(base_url: str, response: _DiscoveryResponse) -> None:
|
||||||
|
if not response.is_addressbook:
|
||||||
|
return
|
||||||
|
url = ensure_collection_url(absolute_dav_url(base_url, response.href or base_url))
|
||||||
|
addressbooks[url] = AddressCardDAVAddressBook(
|
||||||
|
collection_url=url,
|
||||||
|
href=response.href,
|
||||||
|
display_name=response.display_name,
|
||||||
|
description=response.description,
|
||||||
|
ctag=response.ctag,
|
||||||
|
sync_token=response.sync_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def propfind(url: str, depth: str) -> list[_DiscoveryResponse]:
|
||||||
|
key = (url, depth)
|
||||||
|
if key in visited_urls:
|
||||||
|
return []
|
||||||
|
visited_urls.add(key)
|
||||||
|
return self.propfind_discovery(url, depth=depth)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for response in propfind(start_url, "0"):
|
||||||
|
add_addressbook(start_url, response)
|
||||||
|
for href in response.addressbook_home_set_hrefs:
|
||||||
|
add_home_href(start_url, href)
|
||||||
|
for href in response.principal_hrefs:
|
||||||
|
add_principal_href(start_url, href)
|
||||||
|
except AddressCardDAVError as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
|
||||||
|
for principal_url in principal_urls[:6]:
|
||||||
|
try:
|
||||||
|
for response in propfind(principal_url, "0"):
|
||||||
|
for href in response.addressbook_home_set_hrefs:
|
||||||
|
add_home_href(principal_url, href)
|
||||||
|
except AddressCardDAVError as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
|
||||||
|
if not home_urls:
|
||||||
|
home_urls.append(start_url)
|
||||||
|
|
||||||
|
for home_url in home_urls:
|
||||||
|
try:
|
||||||
|
for response in propfind(home_url, "1"):
|
||||||
|
add_addressbook(home_url, response)
|
||||||
|
except AddressCardDAVError as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
|
||||||
|
if not addressbooks and errors:
|
||||||
|
raise AddressCardDAVError(f"CardDAV discovery did not find any address books: {errors[0]}")
|
||||||
|
return sorted(addressbooks.values(), key=lambda item: ((item.display_name or item.collection_url).lower(), item.collection_url))
|
||||||
|
|
||||||
|
def propfind_discovery(self, url: str, *, depth: str) -> list[_DiscoveryResponse]:
|
||||||
|
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/">
|
||||||
|
<D:prop>
|
||||||
|
<D:displayname/>
|
||||||
|
<D:current-user-principal/>
|
||||||
|
<D:principal-URL/>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:sync-token/>
|
||||||
|
<CARD:addressbook-home-set/>
|
||||||
|
<CARD:addressbook-description/>
|
||||||
|
<CS:getctag/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>"""
|
||||||
|
payload = self.request("PROPFIND", ensure_collection_url(url), body=body, depth=depth, expected={207})
|
||||||
|
return parse_discovery_multistatus(payload)
|
||||||
|
|
||||||
|
def list_objects(self) -> AddressCardDAVReportResult:
|
||||||
|
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<CARD:addressbook-query xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
|
||||||
|
<D:prop>
|
||||||
|
<D:getetag/>
|
||||||
|
<CARD:address-data/>
|
||||||
|
</D:prop>
|
||||||
|
</CARD:addressbook-query>"""
|
||||||
|
payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207})
|
||||||
|
return parse_multistatus(payload)
|
||||||
|
|
||||||
|
def sync_collection(self, sync_token: str) -> AddressCardDAVReportResult:
|
||||||
|
body = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:sync-collection xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
|
||||||
|
<D:sync-token>{xml_escape(sync_token)}</D:sync-token>
|
||||||
|
<D:sync-level>1</D:sync-level>
|
||||||
|
<D:prop>
|
||||||
|
<D:getetag/>
|
||||||
|
<CARD:address-data/>
|
||||||
|
</D:prop>
|
||||||
|
</D:sync-collection>""".encode("utf-8")
|
||||||
|
try:
|
||||||
|
payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207})
|
||||||
|
except AddressCardDAVError as exc:
|
||||||
|
raise AddressCardDAVSyncUnsupported(str(exc)) from exc
|
||||||
|
return parse_multistatus(payload)
|
||||||
|
|
||||||
|
def fetch_object(self, href: str) -> str:
|
||||||
|
payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200})
|
||||||
|
return payload.decode("utf-8")
|
||||||
|
|
||||||
|
def put_object(self, href: str, vcard: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> AddressCardDAVWriteResult:
|
||||||
|
headers = {"Content-Type": "text/vcard; charset=utf-8"}
|
||||||
|
if create:
|
||||||
|
headers["If-None-Match"] = "*"
|
||||||
|
elif etag and not overwrite:
|
||||||
|
headers["If-Match"] = etag
|
||||||
|
elif not overwrite:
|
||||||
|
raise AddressCardDAVPreconditionFailed(f"PUT {href} requires an ETag or explicit overwrite.")
|
||||||
|
status, response_headers, _payload = self.request_raw(
|
||||||
|
"PUT",
|
||||||
|
self.object_url(href),
|
||||||
|
body=vcard.encode("utf-8"),
|
||||||
|
depth=None,
|
||||||
|
expected={200, 201, 204},
|
||||||
|
extra_headers=headers,
|
||||||
|
)
|
||||||
|
return AddressCardDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
|
||||||
|
|
||||||
|
def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> AddressCardDAVWriteResult:
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if etag and not overwrite:
|
||||||
|
headers["If-Match"] = etag
|
||||||
|
elif not overwrite:
|
||||||
|
raise AddressCardDAVPreconditionFailed(f"DELETE {href} requires an ETag or explicit overwrite.")
|
||||||
|
status, response_headers, _payload = self.request_raw(
|
||||||
|
"DELETE",
|
||||||
|
self.object_url(href),
|
||||||
|
body=None,
|
||||||
|
depth=None,
|
||||||
|
expected={200, 202, 204, 404},
|
||||||
|
extra_headers=headers,
|
||||||
|
)
|
||||||
|
return AddressCardDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
|
||||||
|
|
||||||
|
def object_url(self, href: str) -> str:
|
||||||
|
candidate = same_origin_dav_url(
|
||||||
|
self.collection_url,
|
||||||
|
href,
|
||||||
|
label="CardDAV object href",
|
||||||
|
)
|
||||||
|
collection_parts = urllib.parse.urlparse(self.collection_url)
|
||||||
|
candidate_parts = urllib.parse.urlparse(candidate)
|
||||||
|
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
|
||||||
|
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
|
||||||
|
collection_prefix = collection_path.rstrip("/") + "/"
|
||||||
|
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
|
||||||
|
raise AddressCardDAVError("CardDAV object href must remain inside the configured collection path")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
|
||||||
|
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def request_raw(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
body: bytes | None,
|
||||||
|
depth: str | None,
|
||||||
|
expected: set[int],
|
||||||
|
extra_headers: Mapping[str, str] | None = None,
|
||||||
|
) -> tuple[int, Mapping[str, str], bytes]:
|
||||||
|
headers: dict[str, str] = {
|
||||||
|
"Accept": "application/xml,text/vcard,*/*",
|
||||||
|
"User-Agent": "govoplan-addresses-carddav/0.1",
|
||||||
|
}
|
||||||
|
if body is not None:
|
||||||
|
headers["Content-Type"] = "application/xml; charset=utf-8"
|
||||||
|
if depth is not None:
|
||||||
|
headers["Depth"] = depth
|
||||||
|
if self.bearer_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
||||||
|
elif self.username and self.password:
|
||||||
|
token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii")
|
||||||
|
headers["Authorization"] = f"Basic {token}"
|
||||||
|
if extra_headers:
|
||||||
|
headers.update(dict(extra_headers))
|
||||||
|
status, response_headers, payload = self.transport(method, url, headers, body, self.timeout)
|
||||||
|
if status not in expected:
|
||||||
|
if status == 412:
|
||||||
|
raise AddressCardDAVPreconditionFailed(f"{method} {url} returned HTTP {status}")
|
||||||
|
if status == 404:
|
||||||
|
raise AddressCardDAVNotFound(f"{method} {url} returned HTTP {status}")
|
||||||
|
raise AddressCardDAVError(f"{method} {url} returned HTTP {status}")
|
||||||
|
return status, response_headers, payload
|
||||||
|
|
||||||
|
|
||||||
|
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
||||||
|
url = validate_http_url(url)
|
||||||
|
try:
|
||||||
|
url = validate_outbound_http_url(url, label="CardDAV URL")
|
||||||
|
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
|
||||||
|
url,
|
||||||
|
data=body,
|
||||||
|
headers=dict(headers),
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
|
||||||
|
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CardDAV URL; redirects remain on origin. # nosec B310
|
||||||
|
response_headers = dict(response.headers.items())
|
||||||
|
return response.status, response_headers, bounded_response_bytes(
|
||||||
|
response,
|
||||||
|
headers=response_headers,
|
||||||
|
label="CardDAV response",
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
response_headers = dict(exc.headers.items())
|
||||||
|
try:
|
||||||
|
payload = bounded_response_bytes(exc, headers=response_headers, label="CardDAV error response")
|
||||||
|
except OutboundHttpError as policy_exc:
|
||||||
|
raise AddressCardDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
|
||||||
|
return exc.code, response_headers, payload
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise AddressCardDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
||||||
|
except (OutboundHttpError, ValueError) as exc:
|
||||||
|
raise AddressCardDAVError(f"{method} {url} failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_multistatus(payload: bytes) -> AddressCardDAVReportResult:
|
||||||
|
try:
|
||||||
|
root = SafeElementTree.fromstring(payload)
|
||||||
|
except SafeElementTree.ParseError as exc:
|
||||||
|
raise AddressCardDAVError(f"Invalid CardDAV XML response: {exc}") from exc
|
||||||
|
objects: list[AddressCardDAVObject] = []
|
||||||
|
sync_token = first_child_text(root, "sync-token")
|
||||||
|
ctag = first_child_text(root, "getctag")
|
||||||
|
for response in child_elements(root, "response"):
|
||||||
|
href = first_child_text(response, "href")
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
deleted = False
|
||||||
|
etag = None
|
||||||
|
address_data = None
|
||||||
|
for propstat in child_elements(response, "propstat"):
|
||||||
|
status = first_child_text(propstat, "status") or ""
|
||||||
|
prop = first_child(propstat, "prop")
|
||||||
|
if prop is None:
|
||||||
|
continue
|
||||||
|
if " 404 " in status or status.endswith(" 404"):
|
||||||
|
deleted = True
|
||||||
|
continue
|
||||||
|
if " 200 " not in status and not status.endswith(" 200"):
|
||||||
|
continue
|
||||||
|
etag = first_child_text(prop, "getetag") or etag
|
||||||
|
address_data = first_child_text(prop, "address-data") or address_data
|
||||||
|
sync_token = first_child_text(prop, "sync-token") or sync_token
|
||||||
|
ctag = first_child_text(prop, "getctag") or ctag
|
||||||
|
objects.append(AddressCardDAVObject(href=href, etag=strip_weak_etag(etag), address_data=address_data, deleted=deleted))
|
||||||
|
return AddressCardDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_discovery_multistatus(payload: bytes) -> list[_DiscoveryResponse]:
|
||||||
|
try:
|
||||||
|
root = SafeElementTree.fromstring(payload)
|
||||||
|
except SafeElementTree.ParseError as exc:
|
||||||
|
raise AddressCardDAVError(f"Invalid CardDAV XML response: {exc}") from exc
|
||||||
|
return [parsed for response in child_elements(root, "response") if (parsed := _parse_discovery_response(response)) is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_discovery_response(response: Any) -> _DiscoveryResponse | None:
|
||||||
|
href = first_child_text(response, "href")
|
||||||
|
if not href:
|
||||||
|
return None
|
||||||
|
draft = _DiscoveryDraft()
|
||||||
|
for propstat in child_elements(response, "propstat"):
|
||||||
|
_apply_discovery_propstat(draft, propstat)
|
||||||
|
return _DiscoveryResponse(
|
||||||
|
href=href,
|
||||||
|
display_name=draft.display_name,
|
||||||
|
description=draft.description,
|
||||||
|
ctag=draft.ctag,
|
||||||
|
sync_token=draft.sync_token,
|
||||||
|
is_addressbook=draft.is_addressbook,
|
||||||
|
principal_hrefs=dedupe_tuple(draft.principal_hrefs),
|
||||||
|
addressbook_home_set_hrefs=dedupe_tuple(draft.addressbook_home_set_hrefs),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_discovery_propstat(draft: _DiscoveryDraft, propstat: Any) -> None:
|
||||||
|
if not discovery_propstat_is_success(propstat):
|
||||||
|
return
|
||||||
|
prop = first_child(propstat, "prop")
|
||||||
|
if prop is None:
|
||||||
|
return
|
||||||
|
for item in prop:
|
||||||
|
_apply_discovery_property(draft, item)
|
||||||
|
|
||||||
|
|
||||||
|
def discovery_propstat_is_success(propstat: Any) -> bool:
|
||||||
|
status = first_child_text(propstat, "status") or ""
|
||||||
|
return not status or " 200 " in status or status.endswith(" 200") or " 207 " in status
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_discovery_property(draft: _DiscoveryDraft, item: Any) -> None:
|
||||||
|
name = local_name(item.tag)
|
||||||
|
text = item.text.strip() if item.text else ""
|
||||||
|
if name == "displayname" and text:
|
||||||
|
draft.display_name = text
|
||||||
|
elif name == "addressbook-description" and text:
|
||||||
|
draft.description = text
|
||||||
|
elif name == "getctag" and text:
|
||||||
|
draft.ctag = text
|
||||||
|
elif name == "sync-token" and text:
|
||||||
|
draft.sync_token = text
|
||||||
|
elif name == "resourcetype":
|
||||||
|
draft.is_addressbook = draft.is_addressbook or any(local_name(child.tag) == "addressbook" for child in item)
|
||||||
|
elif name in {"current-user-principal", "principal-URL"}:
|
||||||
|
draft.principal_hrefs.extend(nested_href_texts(item))
|
||||||
|
elif name == "addressbook-home-set":
|
||||||
|
draft.addressbook_home_set_hrefs.extend(nested_href_texts(item))
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_tuple(values: list[str]) -> tuple[str, ...]:
|
||||||
|
return tuple(dict.fromkeys(values))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_collection_url(value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if value and "://" not in value and not value.startswith("/"):
|
||||||
|
value = f"https://{value}"
|
||||||
|
url = validate_http_url(value)
|
||||||
|
return url if url.endswith("/") else f"{url}/"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_http_url(value: str) -> str:
|
||||||
|
parsed = urllib.parse.urlparse(value.strip())
|
||||||
|
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
|
||||||
|
raise AddressCardDAVError("CardDAV URL must be an absolute HTTP(S) URL")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise AddressCardDAVError("CardDAV URL must not include embedded credentials")
|
||||||
|
if parsed.query or parsed.fragment:
|
||||||
|
raise AddressCardDAVError("CardDAV URL must not include a query or fragment")
|
||||||
|
_url_origin(parsed)
|
||||||
|
return urllib.parse.urlunparse(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def absolute_dav_url(base_url: str, href: str) -> str:
|
||||||
|
return same_origin_dav_url(base_url, href, label="CardDAV discovery href")
|
||||||
|
|
||||||
|
|
||||||
|
def same_origin_dav_url(base_url: str, href: str, *, label: str) -> str:
|
||||||
|
base = ensure_collection_url(base_url)
|
||||||
|
candidate = validate_http_url(urllib.parse.urljoin(base, href))
|
||||||
|
if _url_origin(urllib.parse.urlparse(candidate)) != _url_origin(urllib.parse.urlparse(base)):
|
||||||
|
raise AddressCardDAVError(f"{label} must use the configured collection origin")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _url_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
|
||||||
|
try:
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AddressCardDAVError("CardDAV URL has an invalid port") from exc
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
if port is None:
|
||||||
|
port = 443 if scheme == "https" else 80
|
||||||
|
return scheme, (parsed.hostname or "").lower(), port
|
||||||
|
|
||||||
|
|
||||||
|
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
def __init__(self, source_url: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._source_origin = _url_origin(urllib.parse.urlparse(validate_http_url(source_url)))
|
||||||
|
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
||||||
|
del fp, msg, headers
|
||||||
|
try:
|
||||||
|
candidate = validate_http_url(newurl)
|
||||||
|
candidate = validate_outbound_http_url(candidate, label="CardDAV redirect URL")
|
||||||
|
except (AddressCardDAVError, OutboundHttpError):
|
||||||
|
return None
|
||||||
|
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
|
||||||
|
return None
|
||||||
|
method = req.get_method()
|
||||||
|
data = req.data
|
||||||
|
if code == 303 and method != "HEAD":
|
||||||
|
method, data = "GET", None
|
||||||
|
elif code in {301, 302} and method == "POST":
|
||||||
|
method, data = "GET", None
|
||||||
|
forwarded_headers = {
|
||||||
|
key: value
|
||||||
|
for key, value in req.header_items()
|
||||||
|
if key.casefold() not in {"host", "content-length"}
|
||||||
|
}
|
||||||
|
return urllib.request.Request( # noqa: S310 - candidate is validated and same-origin.
|
||||||
|
candidate,
|
||||||
|
data=data,
|
||||||
|
headers=forwarded_headers,
|
||||||
|
origin_req_host=req.origin_req_host,
|
||||||
|
unverifiable=True,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_weak_etag(value: str | None) -> str | None:
|
||||||
|
return value.strip() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def response_etag(headers: Mapping[str, str]) -> str | None:
|
||||||
|
for key, value in headers.items():
|
||||||
|
if key.lower() == "etag":
|
||||||
|
return strip_weak_etag(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def xml_escape(value: str) -> str:
|
||||||
|
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
def first_child(element: Any, name: str) -> Any | None:
|
||||||
|
for child in element:
|
||||||
|
if local_name(child.tag) == name:
|
||||||
|
return child
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def first_child_text(element: Any, name: str) -> str | None:
|
||||||
|
found = first_child(element, name)
|
||||||
|
if found is None or found.text is None:
|
||||||
|
return None
|
||||||
|
return found.text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def child_elements(element: Any, name: str) -> list[Any]:
|
||||||
|
return [child for child in element if local_name(child.tag) == name]
|
||||||
|
|
||||||
|
|
||||||
|
def nested_href_texts(element: Any) -> list[str]:
|
||||||
|
hrefs: list[str] = []
|
||||||
|
for child in element.iter():
|
||||||
|
if local_name(child.tag) == "href" and child.text and child.text.strip():
|
||||||
|
hrefs.append(child.text.strip())
|
||||||
|
return hrefs
|
||||||
|
|
||||||
|
|
||||||
|
def local_name(tag: str) -> str:
|
||||||
|
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Address module database models."""
|
||||||
|
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class AddressBook(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_address_books"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_address_books_scope", "tenant_id", "scope_type", "scope_id"),
|
||||||
|
Index(
|
||||||
|
"uq_addresses_address_books_active_name",
|
||||||
|
"tenant_id",
|
||||||
|
"scope_type",
|
||||||
|
"scope_id",
|
||||||
|
"name",
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=text("deleted_at IS NULL"),
|
||||||
|
postgresql_where=text("deleted_at IS NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
source_kind: Mapped[str] = mapped_column(String(30), default="local", nullable=False, index=True)
|
||||||
|
source_ref: Mapped[str | None] = mapped_column(String(1000))
|
||||||
|
read_only: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
sync_status: Mapped[str | None] = mapped_column(String(30))
|
||||||
|
sync_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
contacts: Mapped[list["Contact"]] = relationship(back_populates="address_book", cascade="all, delete-orphan")
|
||||||
|
address_lists: Mapped[list["AddressList"]] = relationship(back_populates="address_book", cascade="all, delete-orphan")
|
||||||
|
sync_sources: Mapped[list["AddressSyncSource"]] = relationship(back_populates="address_book", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_contacts"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_contacts_book_name", "address_book_id", "display_name"),
|
||||||
|
Index("ix_addresses_contacts_tenant_name", "tenant_id", "display_name"),
|
||||||
|
Index(
|
||||||
|
"ix_addresses_contacts_source_ref",
|
||||||
|
"source_ref",
|
||||||
|
postgresql_using="hash",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
address_book_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("addresses_address_books.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
given_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
family_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
organization: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||||
|
role_title: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
note: Mapped[str | None] = mapped_column(Text)
|
||||||
|
tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
source_kind: Mapped[str] = mapped_column(String(30), default="local", nullable=False, index=True)
|
||||||
|
source_ref: Mapped[str | None] = mapped_column(String(1000))
|
||||||
|
source_payload_kind: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||||
|
source_payload_raw: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
address_book: Mapped[AddressBook] = relationship(back_populates="contacts")
|
||||||
|
emails: Mapped[list["ContactEmail"]] = relationship(back_populates="contact", cascade="all, delete-orphan", order_by="ContactEmail.order_index")
|
||||||
|
phones: Mapped[list["ContactPhone"]] = relationship(back_populates="contact", cascade="all, delete-orphan", order_by="ContactPhone.order_index")
|
||||||
|
postal_addresses: Mapped[list["ContactPostalAddress"]] = relationship(
|
||||||
|
back_populates="contact",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ContactPostalAddress.order_index",
|
||||||
|
)
|
||||||
|
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact", cascade="all, delete-orphan")
|
||||||
|
channel_rules: Mapped[list["ContactChannelRule"]] = relationship(
|
||||||
|
back_populates="contact",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ContactChannelRule.created_at",
|
||||||
|
)
|
||||||
|
quality_decisions: Mapped[list["ContactPointQualityDecision"]] = relationship(
|
||||||
|
back_populates="contact",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ContactPointQualityDecision.created_at",
|
||||||
|
)
|
||||||
|
field_provenance: Mapped[list["ContactFieldProvenance"]] = relationship(
|
||||||
|
back_populates="contact",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ContactFieldProvenance.created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactEmail(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_contact_emails"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_contact_emails_lookup", "email"),
|
||||||
|
Index("ix_addresses_contact_emails_contact_primary", "contact_id", "is_primary"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
label: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
|
||||||
|
original_email: Mapped[str] = mapped_column(String(320), nullable=False, default="")
|
||||||
|
normalized_email: Mapped[str] = mapped_column(String(320), nullable=False, default="", index=True)
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
contact: Mapped[Contact] = relationship(back_populates="emails")
|
||||||
|
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_email")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPhone(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_contact_phones"
|
||||||
|
__table_args__ = (Index("ix_addresses_contact_phones_contact_primary", "contact_id", "is_primary"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
label: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
phone: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
original_phone: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
normalized_phone: Mapped[str] = mapped_column(String(100), nullable=False, default="", index=True)
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
contact: Mapped[Contact] = relationship(back_populates="phones")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPostalAddress(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_contact_postal_addresses"
|
||||||
|
__table_args__ = (Index("ix_addresses_contact_postal_addresses_contact_primary", "contact_id", "is_primary"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
label: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
street: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
postal_code: Mapped[str | None] = mapped_column(String(40))
|
||||||
|
locality: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
region: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
country: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
original_value: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
normalized_value: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
contact: Mapped[Contact] = relationship(back_populates="postal_addresses")
|
||||||
|
address_list_entries: Mapped[list["AddressListEntry"]] = relationship(back_populates="contact_postal_address")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactChannelRule(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_contact_channel_rules"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_addresses_channel_rules_resolution",
|
||||||
|
"tenant_id",
|
||||||
|
"contact_id",
|
||||||
|
"channel",
|
||||||
|
"purpose",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_addresses_channel_rules_effective",
|
||||||
|
"effective_from",
|
||||||
|
"effective_until",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
contact_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
channel: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
purpose: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||||
|
contact_point_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
decision: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
legal_basis: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
evidence_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
preference_rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
locale: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
effective_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
effective_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
contact: Mapped[Contact] = relationship(back_populates="channel_rules")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointSnapshot(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_contact_point_snapshots"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_contact_point_snapshots_source", "tenant_id", "source_id", "created_at"),
|
||||||
|
Index("ix_addresses_contact_point_snapshots_hash", "tenant_id", "snapshot_hash"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
source_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
contract_version: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
source_revision: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
source_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
purpose: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||||
|
effective_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
request_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
resolution_payload: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False)
|
||||||
|
recipient_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
excluded_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointQualityDecision(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_contact_point_quality_decisions"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_addresses_quality_current",
|
||||||
|
"tenant_id",
|
||||||
|
"contact_id",
|
||||||
|
"channel",
|
||||||
|
"contact_point_id",
|
||||||
|
"effective_until",
|
||||||
|
),
|
||||||
|
Index("ix_addresses_quality_state", "tenant_id", "state", "effective_until"),
|
||||||
|
Index("ix_addresses_quality_created_by", "created_by_account_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
contact_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
channel: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
contact_point_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
reason_code: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
evidence_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
effective_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
effective_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
contact: Mapped[Contact] = relationship(back_populates="quality_decisions")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactMergeRecord(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_contact_merge_records"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_merge_winner", "tenant_id", "winner_contact_id", "created_at"),
|
||||||
|
Index("ix_addresses_merge_status", "tenant_id", "status", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
address_book_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("addresses_address_books.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
winner_contact_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("addresses_contacts.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
loser_contact_ids: Mapped[list[str]] = mapped_column(JSON, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
|
||||||
|
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
survivorship: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
before_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
after_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
before_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
after_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
recovered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
recovered_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
recovery_action: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
recovery_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactRedirect(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_contact_redirects"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"uq_addresses_contact_redirects_active_source",
|
||||||
|
"tenant_id",
|
||||||
|
"source_contact_id",
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=text("ended_at IS NULL"),
|
||||||
|
postgresql_where=text("ended_at IS NULL"),
|
||||||
|
),
|
||||||
|
Index("ix_addresses_contact_redirects_target", "tenant_id", "target_contact_id", "ended_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
source_contact_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
target_contact_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("addresses_contacts.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
merge_record_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("addresses_contact_merge_records.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactFieldProvenance(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_contact_field_provenance"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_field_provenance_contact", "contact_id", "field_path", "created_at"),
|
||||||
|
Index("ix_addresses_field_provenance_selected", "tenant_id", "contact_id", "selected"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
contact_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("addresses_contacts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
field_path: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
value: Mapped[Any] = mapped_column(JSON, nullable=True)
|
||||||
|
source_kind: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
source_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
precedence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
selected: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
reason_code: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
explanation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
visibility: Mapped[str] = mapped_column(String(30), nullable=False, default="inherit")
|
||||||
|
merge_record_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("addresses_contact_merge_records.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
contact: Mapped[Contact] = relationship(back_populates="field_provenance")
|
||||||
|
|
||||||
|
|
||||||
|
class AddressList(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_address_lists"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_address_lists_book_name", "address_book_id", "name"),
|
||||||
|
Index(
|
||||||
|
"uq_addresses_address_lists_active_name",
|
||||||
|
"address_book_id",
|
||||||
|
"name",
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=text("deleted_at IS NULL"),
|
||||||
|
postgresql_where=text("deleted_at IS NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
source_kind: Mapped[str] = mapped_column(String(30), default="local", nullable=False, index=True)
|
||||||
|
source_ref: Mapped[str | None] = mapped_column(String(1000))
|
||||||
|
read_only: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
address_book: Mapped[AddressBook] = relationship(back_populates="address_lists")
|
||||||
|
entries: Mapped[list["AddressListEntry"]] = relationship(
|
||||||
|
back_populates="address_list",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="AddressListEntry.order_index",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressListEntry(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_address_list_entries"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_address_list_entries_list_order", "address_list_id", "order_index"),
|
||||||
|
Index("ix_addresses_address_list_entries_contact", "contact_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
address_list_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_lists.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
contact_id: Mapped[str] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
contact_email_id: Mapped[str | None] = mapped_column(ForeignKey("addresses_contact_emails.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
contact_postal_address_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("addresses_contact_postal_addresses.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
target_kind: Mapped[str] = mapped_column(String(30), default="contact", nullable=False, index=True)
|
||||||
|
label: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
address_list: Mapped[AddressList] = relationship(back_populates="entries")
|
||||||
|
contact: Mapped[Contact] = relationship(back_populates="address_list_entries")
|
||||||
|
contact_email: Mapped[ContactEmail | None] = relationship(back_populates="address_list_entries")
|
||||||
|
contact_postal_address: Mapped[ContactPostalAddress | None] = relationship(back_populates="address_list_entries")
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncSource(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_sync_sources"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_sync_sources_book_status", "address_book_id", "status"),
|
||||||
|
Index("ix_addresses_sync_sources_connector", "tenant_id", "connector_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
connector_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
external_account_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
external_address_book_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
sync_direction: Mapped[str] = mapped_column(String(30), default="read_only", nullable=False, index=True)
|
||||||
|
read_only: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="idle", nullable=False, index=True)
|
||||||
|
sync_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
etag: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
remote_revision: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
last_attempted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
last_diagnostic: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
updated_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
address_book: Mapped[AddressBook] = relationship(back_populates="sync_sources")
|
||||||
|
tombstones: Mapped[list["AddressSyncTombstone"]] = relationship(back_populates="sync_source", cascade="all, delete-orphan")
|
||||||
|
conflicts: Mapped[list["AddressSyncConflict"]] = relationship(back_populates="sync_source", cascade="all, delete-orphan")
|
||||||
|
diagnostics: Mapped[list["AddressSyncDiagnostic"]] = relationship(back_populates="sync_source", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncTombstone(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_sync_tombstones"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_sync_tombstones_source_remote", "sync_source_id", "remote_uid"),
|
||||||
|
Index("ix_addresses_sync_tombstones_source_href", "sync_source_id", "resource_href"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
sync_source_id: Mapped[str] = mapped_column(ForeignKey("addresses_sync_sources.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
contact_id: Mapped[str | None] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
remote_uid: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
resource_href: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
local_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
sync_source: Mapped[AddressSyncSource] = relationship(back_populates="tombstones")
|
||||||
|
address_book: Mapped[AddressBook] = relationship()
|
||||||
|
contact: Mapped[Contact | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncConflict(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_sync_conflicts"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_sync_conflicts_source_status", "sync_source_id", "status"),
|
||||||
|
Index("ix_addresses_sync_conflicts_contact_status", "contact_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
sync_source_id: Mapped[str] = mapped_column(ForeignKey("addresses_sync_sources.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
address_book_id: Mapped[str] = mapped_column(ForeignKey("addresses_address_books.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
contact_id: Mapped[str | None] = mapped_column(ForeignKey("addresses_contacts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
remote_uid: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
resource_href: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
field_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
local_value: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
remote_value: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
local_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
remote_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="open", nullable=False, index=True)
|
||||||
|
resolution: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
resolved_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
sync_source: Mapped[AddressSyncSource] = relationship(back_populates="conflicts")
|
||||||
|
address_book: Mapped[AddressBook] = relationship()
|
||||||
|
contact: Mapped[Contact | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncDiagnostic(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_sync_diagnostics"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_sync_diagnostics_source_created", "sync_source_id", "created_at"),
|
||||||
|
Index("ix_addresses_sync_diagnostics_source_severity", "sync_source_id", "severity"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
sync_source_id: Mapped[str] = mapped_column(ForeignKey("addresses_sync_sources.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
severity: Mapped[str] = mapped_column(String(30), default="info", nullable=False, index=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
|
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
details: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
sync_source: Mapped[AddressSyncSource] = relationship(back_populates="diagnostics")
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportProfile(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_import_profiles"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("profile_key", "version", name="uq_addresses_import_profile_version"),
|
||||||
|
Index("ix_addresses_import_profiles_scope", "tenant_id", "scope_type", "scope_id", "is_current"),
|
||||||
|
Index("ix_addresses_import_profiles_format", "tenant_id", "source_format"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
profile_key: Mapped[str] = mapped_column(String(36), nullable=False, default=new_uuid, index=True)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source_format: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
configuration: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportRun(Base, TimestampMixin):
|
||||||
|
__tablename__ = "addresses_import_runs"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_addresses_import_runs_book_status", "address_book_id", "status", "created_at"),
|
||||||
|
Index("ix_addresses_import_runs_tenant_hash", "tenant_id", "input_hash"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
address_book_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("addresses_address_books.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
profile_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("addresses_import_profiles.id", ondelete="RESTRICT"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
source_format: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
input_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
plan_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, default="previewed", index=True)
|
||||||
|
row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
statistics: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
diagnostics: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
|
||||||
|
plan_data: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
|
||||||
|
result_evidence: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
rolled_back_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
|
||||||
|
address_book: Mapped[AddressBook] = relationship()
|
||||||
|
profile: Mapped[AddressImportProfile | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AddressBook",
|
||||||
|
"AddressImportProfile",
|
||||||
|
"AddressImportRun",
|
||||||
|
"AddressList",
|
||||||
|
"AddressListEntry",
|
||||||
|
"AddressSyncConflict",
|
||||||
|
"AddressSyncDiagnostic",
|
||||||
|
"AddressSyncSource",
|
||||||
|
"AddressSyncTombstone",
|
||||||
|
"Contact",
|
||||||
|
"ContactEmail",
|
||||||
|
"ContactPhone",
|
||||||
|
"ContactFieldProvenance",
|
||||||
|
"ContactMergeRecord",
|
||||||
|
"ContactPointQualityDecision",
|
||||||
|
"ContactPointSnapshot",
|
||||||
|
"ContactPostalAddress",
|
||||||
|
"ContactRedirect",
|
||||||
|
"new_uuid",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'addresses.reference.fields-and-consequences': {'consequence_classes': {'archive': 'Entfernt das '
|
||||||
|
'Objekt aus '
|
||||||
|
'der '
|
||||||
|
'gewöhnlichen '
|
||||||
|
'Auswahl, '
|
||||||
|
'während die '
|
||||||
|
'verwaltete '
|
||||||
|
'Geschichte '
|
||||||
|
'und '
|
||||||
|
'Referenzen '
|
||||||
|
'beibehalten '
|
||||||
|
'werden.',
|
||||||
|
'governance_fact': 'Hinzufügen '
|
||||||
|
'oder '
|
||||||
|
'Beenden '
|
||||||
|
'einer '
|
||||||
|
'effektiv '
|
||||||
|
'datierten '
|
||||||
|
'Kommunikationsentscheidung '
|
||||||
|
'ohne '
|
||||||
|
'vorherige '
|
||||||
|
'Fakten '
|
||||||
|
'zu '
|
||||||
|
'löschen.',
|
||||||
|
'import_or_sync': 'Wendet '
|
||||||
|
'nur '
|
||||||
|
'einen '
|
||||||
|
'überprüften '
|
||||||
|
'Bounded '
|
||||||
|
'Plan '
|
||||||
|
'an und '
|
||||||
|
'behält '
|
||||||
|
'die '
|
||||||
|
'Quellenrevision, '
|
||||||
|
'Diagnose '
|
||||||
|
'und '
|
||||||
|
'Herkunft '
|
||||||
|
'bei.',
|
||||||
|
'merge': 'Repoints '
|
||||||
|
'verwaltet '
|
||||||
|
'Verweise auf '
|
||||||
|
'einen '
|
||||||
|
'Überlebenden '
|
||||||
|
'und behält '
|
||||||
|
'reversible '
|
||||||
|
'Redirect und '
|
||||||
|
'Provenienz '
|
||||||
|
'Nachweise.'}}}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
AddressImportFormat = Literal["csv", "xlsx", "ldif"]
|
||||||
|
AddressImportScope = Literal["user", "group", "tenant", "system"]
|
||||||
|
|
||||||
|
IMPORT_TARGET_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"source_key",
|
||||||
|
"display_name",
|
||||||
|
"given_name",
|
||||||
|
"family_name",
|
||||||
|
"organization",
|
||||||
|
"role_title",
|
||||||
|
"note",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"street",
|
||||||
|
"postal_code",
|
||||||
|
"locality",
|
||||||
|
"region",
|
||||||
|
"country",
|
||||||
|
"tags",
|
||||||
|
"visibility",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportConfiguration(BaseModel):
|
||||||
|
field_mappings: dict[str, str] = Field(default_factory=dict, max_length=40)
|
||||||
|
delimiter: Literal[",", ";", "\t", "|"] = ","
|
||||||
|
encoding: Literal["utf-8", "utf-8-sig", "cp1252", "latin-1"] = "utf-8-sig"
|
||||||
|
header_row: int = Field(default=1, ge=1, le=100)
|
||||||
|
sheet_name: str | None = Field(default=None, max_length=255)
|
||||||
|
source_key_column: str | None = Field(default=None, max_length=255)
|
||||||
|
duplicate_source_key_policy: Literal["reject", "first", "last"] = "reject"
|
||||||
|
existing_contact_policy: Literal["update", "ignore", "reject"] = "update"
|
||||||
|
blank_value_policy: Literal["ignore", "clear", "reject"] = "ignore"
|
||||||
|
ldif_change_record_policy: Literal["reject", "ignore", "treat_add_as_entry"] = "reject"
|
||||||
|
locale: str | None = Field(default=None, max_length=35)
|
||||||
|
default_tags: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
max_rows: int = Field(default=10_000, ge=1, le=10_000)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_mappings(self) -> "AddressImportConfiguration":
|
||||||
|
invalid = sorted(set(self.field_mappings).difference(IMPORT_TARGET_FIELDS))
|
||||||
|
if invalid:
|
||||||
|
raise ValueError(f"Unsupported address import target fields: {', '.join(invalid)}")
|
||||||
|
for target, column in self.field_mappings.items():
|
||||||
|
if not target.strip() or not column.strip():
|
||||||
|
raise ValueError("Import mapping targets and source columns cannot be blank.")
|
||||||
|
if "source_key" not in self.field_mappings and not self.source_key_column:
|
||||||
|
raise ValueError("Address import profiles require a stable source-key column.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportProfileCreateRequest(BaseModel):
|
||||||
|
scope_type: AddressImportScope = "tenant"
|
||||||
|
scope_id: str | None = Field(default=None, max_length=36)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=4000)
|
||||||
|
source_format: AddressImportFormat
|
||||||
|
configuration: AddressImportConfiguration
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportProfileUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=4000)
|
||||||
|
configuration: AddressImportConfiguration | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportProfileResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
profile_key: str
|
||||||
|
version: int
|
||||||
|
tenant_id: str | None = None
|
||||||
|
scope_type: str
|
||||||
|
scope_id: str | None = None
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
source_format: str
|
||||||
|
configuration: AddressImportConfiguration
|
||||||
|
is_current: bool
|
||||||
|
created_by_account_id: str | None = None
|
||||||
|
superseded_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportProfileListResponse(BaseModel):
|
||||||
|
profiles: list[AddressImportProfileResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportFilePayload(BaseModel):
|
||||||
|
filename: str = Field(min_length=1, max_length=500)
|
||||||
|
content_base64: str = Field(min_length=1, max_length=14_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportPreviewRequest(AddressImportFilePayload):
|
||||||
|
profile_id: str = Field(min_length=1, max_length=36)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportEffectResponse(BaseModel):
|
||||||
|
row_number: int
|
||||||
|
action: Literal["create", "update", "conflict", "unchanged", "ignored"]
|
||||||
|
source_key: str | None = None
|
||||||
|
contact_id: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
changed_fields: list[str] = Field(default_factory=list)
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportDiagnosticResponse(BaseModel):
|
||||||
|
severity: Literal["info", "warning", "error"]
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
row_number: int | None = None
|
||||||
|
field: str | None = None
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportRunResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
address_book_id: str
|
||||||
|
profile_id: str | None
|
||||||
|
source_filename: str
|
||||||
|
source_format: str
|
||||||
|
input_hash: str
|
||||||
|
plan_hash: str
|
||||||
|
status: str
|
||||||
|
row_count: int
|
||||||
|
statistics: dict[str, int] = Field(default_factory=dict)
|
||||||
|
diagnostics: list[AddressImportDiagnosticResponse] = Field(default_factory=list)
|
||||||
|
effects: list[AddressImportEffectResponse] = Field(default_factory=list)
|
||||||
|
can_apply: bool
|
||||||
|
result_evidence: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
applied_at: datetime | None = None
|
||||||
|
rolled_back_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportCommitRequest(BaseModel):
|
||||||
|
expected_plan_hash: str = Field(min_length=64, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressImportRollbackRequest(BaseModel):
|
||||||
|
expected_plan_hash: str = Field(min_length=64, max_length=64)
|
||||||
|
reason: str = Field(min_length=3, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AddressImportCommitRequest",
|
||||||
|
"AddressImportConfiguration",
|
||||||
|
"AddressImportDiagnosticResponse",
|
||||||
|
"AddressImportEffectResponse",
|
||||||
|
"AddressImportFilePayload",
|
||||||
|
"AddressImportFormat",
|
||||||
|
"AddressImportPreviewRequest",
|
||||||
|
"AddressImportProfileCreateRequest",
|
||||||
|
"AddressImportProfileListResponse",
|
||||||
|
"AddressImportProfileResponse",
|
||||||
|
"AddressImportProfileUpdateRequest",
|
||||||
|
"AddressImportRollbackRequest",
|
||||||
|
"AddressImportRunResponse",
|
||||||
|
"IMPORT_TARGET_FIELDS",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
|
||||||
|
LdifChangeRecordPolicy = Literal["reject", "ignore", "treat_add_as_entry"]
|
||||||
|
|
||||||
|
MAX_LDIF_ATTRIBUTES = 500
|
||||||
|
MAX_LDIF_VALUES_PER_ATTRIBUTE = 100
|
||||||
|
MAX_LDIF_LOGICAL_LINE_BYTES = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ldif_rows(
|
||||||
|
raw: bytes,
|
||||||
|
*,
|
||||||
|
max_entries: int,
|
||||||
|
change_record_policy: LdifChangeRecordPolicy = "reject",
|
||||||
|
) -> tuple[list[tuple[int, dict[str, Any]]], list[dict[str, Any]]]:
|
||||||
|
"""Parse bounded LDIF entries without fetching URL or decoding binary values."""
|
||||||
|
|
||||||
|
rows: list[tuple[int, dict[str, Any]]] = []
|
||||||
|
diagnostics: list[dict[str, Any]] = []
|
||||||
|
record_lines: list[tuple[int, bytes]] = []
|
||||||
|
|
||||||
|
def finish_record() -> None:
|
||||||
|
if not record_lines:
|
||||||
|
return
|
||||||
|
row_number = record_lines[0][0]
|
||||||
|
row, record_diagnostics = _parse_record(record_lines)
|
||||||
|
diagnostics.extend(record_diagnostics)
|
||||||
|
record_lines.clear()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
if set(row) == {"version"} and _first(row.get("version")) == "1":
|
||||||
|
return
|
||||||
|
change_type = _first(row.get("changetype")).casefold()
|
||||||
|
if change_type:
|
||||||
|
if change_record_policy == "ignore":
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"ldif_change_record_ignored",
|
||||||
|
f"LDIF change record {change_type!r} was ignored by profile policy.",
|
||||||
|
row_number=row_number,
|
||||||
|
field="changetype",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if change_record_policy != "treat_add_as_entry" or change_type != "add":
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_change_record_rejected",
|
||||||
|
f"LDIF change record {change_type!r} is not permitted by the profile policy.",
|
||||||
|
row_number=row_number,
|
||||||
|
field="changetype",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"info",
|
||||||
|
"ldif_add_record_imported",
|
||||||
|
"LDIF add change record is treated as a static contact entry by profile policy.",
|
||||||
|
row_number=row_number,
|
||||||
|
field="changetype",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row.pop("changetype", None)
|
||||||
|
row["__ldif_record_hash"] = hashlib.sha256(_canonical_record(row)).hexdigest()
|
||||||
|
rows.append((row_number, row))
|
||||||
|
if len(rows) > max_entries:
|
||||||
|
raise ValueError(f"LDIF exceeds the configured {max_entries}-entry limit.")
|
||||||
|
|
||||||
|
for line_number, logical_line in _logical_lines(raw):
|
||||||
|
if not logical_line:
|
||||||
|
finish_record()
|
||||||
|
continue
|
||||||
|
if logical_line.startswith(b"#"):
|
||||||
|
continue
|
||||||
|
record_lines.append((line_number, logical_line))
|
||||||
|
finish_record()
|
||||||
|
if not rows and not any(item["severity"] == "error" for item in diagnostics):
|
||||||
|
diagnostics.append(_diagnostic("warning", "ldif_no_entries", "No importable LDIF entries were found."))
|
||||||
|
return rows, diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _logical_lines(raw: bytes) -> Iterable[tuple[int, bytes]]:
|
||||||
|
current: bytearray | None = None
|
||||||
|
start_line = 0
|
||||||
|
for line_number, physical_with_ending in enumerate(BytesIO(raw), start=1):
|
||||||
|
physical = physical_with_ending.rstrip(b"\r\n")
|
||||||
|
if physical.startswith(b" "):
|
||||||
|
if current is None:
|
||||||
|
yield line_number, b"!invalid-fold-without-preceding-line"
|
||||||
|
continue
|
||||||
|
current.extend(physical[1:])
|
||||||
|
if len(current) > MAX_LDIF_LOGICAL_LINE_BYTES:
|
||||||
|
raise ValueError(f"LDIF logical line starting at {start_line} exceeds the size limit.")
|
||||||
|
continue
|
||||||
|
if current is not None:
|
||||||
|
yield start_line, bytes(current)
|
||||||
|
current = bytearray(physical)
|
||||||
|
start_line = line_number
|
||||||
|
if len(current) > MAX_LDIF_LOGICAL_LINE_BYTES:
|
||||||
|
raise ValueError(f"LDIF logical line {line_number} exceeds the size limit.")
|
||||||
|
if current is not None:
|
||||||
|
yield start_line, bytes(current)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_record(
|
||||||
|
lines: list[tuple[int, bytes]],
|
||||||
|
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||||
|
row: dict[str, list[str]] = {}
|
||||||
|
diagnostics: list[dict[str, Any]] = []
|
||||||
|
for line_number, line in lines:
|
||||||
|
if line == b"!invalid-fold-without-preceding-line":
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_invalid_fold",
|
||||||
|
"LDIF continuation line has no preceding attribute.",
|
||||||
|
row_number=line_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if b":" not in line:
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_invalid_line",
|
||||||
|
"LDIF line is missing the attribute separator.",
|
||||||
|
row_number=line_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if line == b"-":
|
||||||
|
# Attribute-operation separators are meaningful only inside change
|
||||||
|
# records, whose enclosing policy is evaluated after the record.
|
||||||
|
continue
|
||||||
|
raw_name, raw_value = line.split(b":", 1)
|
||||||
|
try:
|
||||||
|
name_parts = [part.strip().casefold() for part in raw_name.decode("ascii").split(";")]
|
||||||
|
name = name_parts[0]
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
name_parts = []
|
||||||
|
name = ""
|
||||||
|
if not name or any(character.isspace() for character in name):
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_invalid_attribute",
|
||||||
|
"LDIF attribute name is invalid.",
|
||||||
|
row_number=line_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if "binary" in name_parts[1:]:
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"ldif_binary_value_ignored",
|
||||||
|
f"Binary LDIF attribute {name!r} was ignored; binary data is never projected into contacts.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if name not in row and len(row) >= MAX_LDIF_ATTRIBUTES:
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_too_many_attributes",
|
||||||
|
f"LDIF entry exceeds the {MAX_LDIF_ATTRIBUTES}-attribute limit.",
|
||||||
|
row_number=line_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
value, value_diagnostic = _decode_value(raw_value, attribute=name, line_number=line_number)
|
||||||
|
if value_diagnostic is not None:
|
||||||
|
diagnostics.append(value_diagnostic)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
values = row.setdefault(name, [])
|
||||||
|
if len(values) >= MAX_LDIF_VALUES_PER_ATTRIBUTE:
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_too_many_values",
|
||||||
|
f"LDIF attribute {name!r} exceeds the value limit.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
values.append(value)
|
||||||
|
return row, diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_value(
|
||||||
|
raw_value: bytes,
|
||||||
|
*,
|
||||||
|
attribute: str,
|
||||||
|
line_number: int,
|
||||||
|
) -> tuple[str | None, dict[str, Any] | None]:
|
||||||
|
if raw_value.startswith(b":"):
|
||||||
|
encoded = raw_value[1:].lstrip(b" ")
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(encoded, validate=True)
|
||||||
|
except (binascii.Error, ValueError):
|
||||||
|
return None, _diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_invalid_base64",
|
||||||
|
f"LDIF attribute {attribute!r} contains invalid base64.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=attribute,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return decoded.decode("utf-8"), None
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None, _diagnostic(
|
||||||
|
"warning",
|
||||||
|
"ldif_binary_value_ignored",
|
||||||
|
f"Binary LDIF attribute {attribute!r} was ignored; binary data is never projected into contacts.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=attribute,
|
||||||
|
)
|
||||||
|
if raw_value.startswith(b"<"):
|
||||||
|
return None, _diagnostic(
|
||||||
|
"warning",
|
||||||
|
"ldif_url_value_ignored",
|
||||||
|
f"External LDIF URL value for {attribute!r} was ignored; imports never fetch referenced content.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=attribute,
|
||||||
|
)
|
||||||
|
value_bytes = raw_value[1:] if raw_value.startswith(b" ") else raw_value
|
||||||
|
try:
|
||||||
|
return value_bytes.decode("utf-8"), None
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None, _diagnostic(
|
||||||
|
"error",
|
||||||
|
"ldif_invalid_utf8",
|
||||||
|
f"LDIF attribute {attribute!r} is not valid UTF-8.",
|
||||||
|
row_number=line_number,
|
||||||
|
field=attribute,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_record(row: dict[str, Any]) -> bytes:
|
||||||
|
lines = []
|
||||||
|
for name in sorted(key for key in row if not key.startswith("__")):
|
||||||
|
values = row[name] if isinstance(row[name], list) else [row[name]]
|
||||||
|
lines.extend(f"{name}:{value}" for value in values)
|
||||||
|
return "\n".join(lines).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _first(value: object) -> str:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return str(value[0]) if value else ""
|
||||||
|
return str(value or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnostic(
|
||||||
|
severity: str,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
row_number: int | None = None,
|
||||||
|
field: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"severity": severity,
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"row_number": row_number,
|
||||||
|
"field": field,
|
||||||
|
"details": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["LdifChangeRecordPolicy", "parse_ldif_rows"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
"""Address module migration package."""
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Address module Alembic revisions."""
|
||||||
|
|
||||||
+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)
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
"""v0.1.8 addresses baseline
|
||||||
|
|
||||||
|
Revision ID: b8c9d0e1f2a3
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-07-13 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b8c9d0e1f2a3"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = "4f2a9c8e7b6d"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"addresses_address_books",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("scope_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_kind", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("source_ref", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("read_only", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("sync_status", sa.String(length=30), nullable=True),
|
||||||
|
sa.Column("sync_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_address_books")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_addresses_address_books_created_by_account_id"), "addresses_address_books", ["created_by_account_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_books_deleted_at"), "addresses_address_books", ["deleted_at"], unique=False)
|
||||||
|
op.create_index("ix_addresses_address_books_scope", "addresses_address_books", ["tenant_id", "scope_type", "scope_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_books_scope_id"), "addresses_address_books", ["scope_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_books_scope_type"), "addresses_address_books", ["scope_type"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_books_source_kind"), "addresses_address_books", ["source_kind"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_books_tenant_id"), "addresses_address_books", ["tenant_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_books_updated_by_account_id"), "addresses_address_books", ["updated_by_account_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"uq_addresses_address_books_active_name",
|
||||||
|
"addresses_address_books",
|
||||||
|
["tenant_id", "scope_type", "scope_id", "name"],
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=sa.text("deleted_at IS NULL"),
|
||||||
|
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"addresses_contacts",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("address_book_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("given_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("family_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("organization", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("role_title", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
|
sa.Column("tags", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("source_kind", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("source_ref", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["address_book_id"],
|
||||||
|
["addresses_address_books.id"],
|
||||||
|
name=op.f("fk_addresses_contacts_address_book_id_addresses_address_books"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contacts")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_addresses_contacts_address_book_id"), "addresses_contacts", ["address_book_id"], unique=False)
|
||||||
|
op.create_index("ix_addresses_contacts_book_name", "addresses_contacts", ["address_book_id", "display_name"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_contacts_created_by_account_id"), "addresses_contacts", ["created_by_account_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_contacts_deleted_at"), "addresses_contacts", ["deleted_at"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_contacts_display_name"), "addresses_contacts", ["display_name"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_contacts_organization"), "addresses_contacts", ["organization"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_contacts_source_kind"), "addresses_contacts", ["source_kind"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_contacts_tenant_id"), "addresses_contacts", ["tenant_id"], unique=False)
|
||||||
|
op.create_index("ix_addresses_contacts_tenant_name", "addresses_contacts", ["tenant_id", "display_name"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_contacts_updated_by_account_id"), "addresses_contacts", ["updated_by_account_id"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"addresses_contact_emails",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("contact_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("email", sa.String(length=320), nullable=False),
|
||||||
|
sa.Column("is_primary", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["contact_id"], ["addresses_contacts.id"], name=op.f("fk_addresses_contact_emails_contact_id_addresses_contacts"), ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contact_emails")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_addresses_contact_emails_contact_id"), "addresses_contact_emails", ["contact_id"], unique=False)
|
||||||
|
op.create_index("ix_addresses_contact_emails_contact_primary", "addresses_contact_emails", ["contact_id", "is_primary"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_contact_emails_email"), "addresses_contact_emails", ["email"], unique=False)
|
||||||
|
op.create_index("ix_addresses_contact_emails_lookup", "addresses_contact_emails", ["email"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"addresses_contact_phones",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("contact_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("phone", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("is_primary", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["contact_id"], ["addresses_contacts.id"], name=op.f("fk_addresses_contact_phones_contact_id_addresses_contacts"), ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contact_phones")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_addresses_contact_phones_contact_id"), "addresses_contact_phones", ["contact_id"], unique=False)
|
||||||
|
op.create_index("ix_addresses_contact_phones_contact_primary", "addresses_contact_phones", ["contact_id", "is_primary"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"addresses_contact_postal_addresses",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("contact_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("street", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("postal_code", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("locality", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("region", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("country", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("is_primary", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["contact_id"],
|
||||||
|
["addresses_contacts.id"],
|
||||||
|
name=op.f("fk_addresses_contact_postal_addresses_contact_id_addresses_contacts"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_contact_postal_addresses")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_addresses_contact_postal_addresses_contact_id"), "addresses_contact_postal_addresses", ["contact_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"ix_addresses_contact_postal_addresses_contact_primary",
|
||||||
|
"addresses_contact_postal_addresses",
|
||||||
|
["contact_id", "is_primary"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("addresses_contact_postal_addresses")
|
||||||
|
op.drop_table("addresses_contact_phones")
|
||||||
|
op.drop_table("addresses_contact_emails")
|
||||||
|
op.drop_table("addresses_contacts")
|
||||||
|
op.drop_table("addresses_address_books")
|
||||||
|
|
||||||
+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")
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
"""v0.1.9 addresses source payload metadata
|
||||||
|
|
||||||
|
Revision ID: c9d0e1f2a4b
|
||||||
|
Revises: b8c9d0e1f2a3
|
||||||
|
Create Date: 2026-07-13 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "c9d0e1f2a4b"
|
||||||
|
down_revision = "b8c9d0e1f2a3"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("addresses_contacts", sa.Column("source_payload_kind", sa.String(length=40), nullable=True))
|
||||||
|
op.add_column("addresses_contacts", sa.Column("source_payload_raw", sa.Text(), nullable=True))
|
||||||
|
op.add_column("addresses_contacts", sa.Column("source_revision", sa.String(length=255), nullable=True))
|
||||||
|
op.create_index(op.f("ix_addresses_contacts_source_payload_kind"), "addresses_contacts", ["source_payload_kind"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_contacts_source_revision"), "addresses_contacts", ["source_revision"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_addresses_contacts_source_revision"), table_name="addresses_contacts")
|
||||||
|
op.drop_index(op.f("ix_addresses_contacts_source_payload_kind"), table_name="addresses_contacts")
|
||||||
|
op.drop_column("addresses_contacts", "source_revision")
|
||||||
|
op.drop_column("addresses_contacts", "source_payload_raw")
|
||||||
|
op.drop_column("addresses_contacts", "source_payload_kind")
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
"""addresses address lists
|
||||||
|
|
||||||
|
Revision ID: d0e1f2a4b5c
|
||||||
|
Revises: c9d0e1f2a4b
|
||||||
|
Create Date: 2026-07-13 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "d0e1f2a4b5c"
|
||||||
|
down_revision = "c9d0e1f2a4b"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"addresses_address_lists",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("address_book_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_kind", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("source_ref", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("read_only", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["address_book_id"],
|
||||||
|
["addresses_address_books.id"],
|
||||||
|
name=op.f("fk_addresses_address_lists_address_book_id_addresses_address_books"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_address_lists")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_addresses_address_lists_address_book_id"), "addresses_address_lists", ["address_book_id"], unique=False)
|
||||||
|
op.create_index("ix_addresses_address_lists_book_name", "addresses_address_lists", ["address_book_id", "name"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_lists_created_by_account_id"), "addresses_address_lists", ["created_by_account_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_lists_deleted_at"), "addresses_address_lists", ["deleted_at"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_lists_source_kind"), "addresses_address_lists", ["source_kind"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_lists_tenant_id"), "addresses_address_lists", ["tenant_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_lists_updated_by_account_id"), "addresses_address_lists", ["updated_by_account_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"uq_addresses_address_lists_active_name",
|
||||||
|
"addresses_address_lists",
|
||||||
|
["address_book_id", "name"],
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=sa.text("deleted_at IS NULL"),
|
||||||
|
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"addresses_address_list_entries",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("address_list_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("contact_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("contact_email_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("contact_postal_address_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("target_kind", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["address_list_id"],
|
||||||
|
["addresses_address_lists.id"],
|
||||||
|
name=op.f("fk_addresses_address_list_entries_address_list_id_addresses_address_lists"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["contact_email_id"],
|
||||||
|
["addresses_contact_emails.id"],
|
||||||
|
name=op.f("fk_addresses_address_list_entries_contact_email_id_addresses_contact_emails"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["contact_id"],
|
||||||
|
["addresses_contacts.id"],
|
||||||
|
name=op.f("fk_addresses_address_list_entries_contact_id_addresses_contacts"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["contact_postal_address_id"],
|
||||||
|
["addresses_contact_postal_addresses.id"],
|
||||||
|
name=op.f("fk_addresses_address_list_entries_contact_postal_address_id_addresses_contact_postal_addresses"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_address_list_entries")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_addresses_address_list_entries_address_list_id"), "addresses_address_list_entries", ["address_list_id"], unique=False)
|
||||||
|
op.create_index("ix_addresses_address_list_entries_contact", "addresses_address_list_entries", ["contact_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_list_entries_contact_email_id"), "addresses_address_list_entries", ["contact_email_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_list_entries_contact_id"), "addresses_address_list_entries", ["contact_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_addresses_address_list_entries_contact_postal_address_id"),
|
||||||
|
"addresses_address_list_entries",
|
||||||
|
["contact_postal_address_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index("ix_addresses_address_list_entries_list_order", "addresses_address_list_entries", ["address_list_id", "order_index"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_address_list_entries_target_kind"), "addresses_address_list_entries", ["target_kind"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("addresses_address_list_entries")
|
||||||
|
op.drop_table("addresses_address_lists")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Allow profile-free persisted vCard batch runs.
|
||||||
|
|
||||||
|
Revision ID: d6e8f9a0b1c2
|
||||||
|
Revises: c5d7e8f9a0b1
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "d6e8f9a0b1c2"
|
||||||
|
down_revision = "c5d7e8f9a0b1"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("addresses_import_runs") as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
"profile_id",
|
||||||
|
existing_type=sa.String(length=36),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("addresses_import_runs") as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
"profile_id",
|
||||||
|
existing_type=sa.String(length=36),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
"""addresses sync infrastructure
|
||||||
|
|
||||||
|
Revision ID: e1f2a4b5c6d
|
||||||
|
Revises: d0e1f2a4b5c
|
||||||
|
Create Date: 2026-07-14 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "e1f2a4b5c6d"
|
||||||
|
down_revision = "d0e1f2a4b5c"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"addresses_sync_sources",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("address_book_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("connector_type", sa.String(length=60), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("external_account_ref", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("external_address_book_ref", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("sync_direction", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("read_only", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("sync_token", sa.Text(), nullable=True),
|
||||||
|
sa.Column("etag", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("remote_revision", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("last_attempted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("last_diagnostic", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["address_book_id"],
|
||||||
|
["addresses_address_books.id"],
|
||||||
|
name=op.f("fk_addresses_sync_sources_address_book_id_addresses_address_books"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_sources")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_sources_address_book_id"), "addresses_sync_sources", ["address_book_id"], unique=False)
|
||||||
|
op.create_index("ix_addresses_sync_sources_book_status", "addresses_sync_sources", ["address_book_id", "status"], unique=False)
|
||||||
|
op.create_index("ix_addresses_sync_sources_connector", "addresses_sync_sources", ["tenant_id", "connector_type"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_sources_connector_type"), "addresses_sync_sources", ["connector_type"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_sources_created_by_account_id"), "addresses_sync_sources", ["created_by_account_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_sources_enabled"), "addresses_sync_sources", ["enabled"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_sources_last_attempted_at"), "addresses_sync_sources", ["last_attempted_at"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_sources_last_success_at"), "addresses_sync_sources", ["last_success_at"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_sources_status"), "addresses_sync_sources", ["status"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_sources_sync_direction"), "addresses_sync_sources", ["sync_direction"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_sources_tenant_id"), "addresses_sync_sources", ["tenant_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_sources_updated_by_account_id"), "addresses_sync_sources", ["updated_by_account_id"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"addresses_sync_tombstones",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("sync_source_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("address_book_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("contact_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("remote_uid", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("resource_href", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("local_deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("remote_deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["address_book_id"],
|
||||||
|
["addresses_address_books.id"],
|
||||||
|
name=op.f("fk_addresses_sync_tombstones_address_book_id_addresses_address_books"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["contact_id"],
|
||||||
|
["addresses_contacts.id"],
|
||||||
|
name=op.f("fk_addresses_sync_tombstones_contact_id_addresses_contacts"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["sync_source_id"],
|
||||||
|
["addresses_sync_sources.id"],
|
||||||
|
name=op.f("fk_addresses_sync_tombstones_sync_source_id_addresses_sync_sources"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_tombstones")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_tombstones_address_book_id"), "addresses_sync_tombstones", ["address_book_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_tombstones_contact_id"), "addresses_sync_tombstones", ["contact_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_tombstones_local_deleted_at"), "addresses_sync_tombstones", ["local_deleted_at"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_tombstones_remote_deleted_at"), "addresses_sync_tombstones", ["remote_deleted_at"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_tombstones_synced_at"), "addresses_sync_tombstones", ["synced_at"], unique=False)
|
||||||
|
op.create_index("ix_addresses_sync_tombstones_source_href", "addresses_sync_tombstones", ["sync_source_id", "resource_href"], unique=False)
|
||||||
|
op.create_index("ix_addresses_sync_tombstones_source_remote", "addresses_sync_tombstones", ["sync_source_id", "remote_uid"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_tombstones_sync_source_id"), "addresses_sync_tombstones", ["sync_source_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_tombstones_tenant_id"), "addresses_sync_tombstones", ["tenant_id"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"addresses_sync_conflicts",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("sync_source_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("address_book_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("contact_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("remote_uid", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("resource_href", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("field_path", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("local_value", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("remote_value", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("local_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("remote_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("resolution", sa.String(length=60), nullable=True),
|
||||||
|
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("resolved_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["address_book_id"],
|
||||||
|
["addresses_address_books.id"],
|
||||||
|
name=op.f("fk_addresses_sync_conflicts_address_book_id_addresses_address_books"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["contact_id"],
|
||||||
|
["addresses_contacts.id"],
|
||||||
|
name=op.f("fk_addresses_sync_conflicts_contact_id_addresses_contacts"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["sync_source_id"],
|
||||||
|
["addresses_sync_sources.id"],
|
||||||
|
name=op.f("fk_addresses_sync_conflicts_sync_source_id_addresses_sync_sources"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_conflicts")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_conflicts_address_book_id"), "addresses_sync_conflicts", ["address_book_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_conflicts_contact_id"), "addresses_sync_conflicts", ["contact_id"], unique=False)
|
||||||
|
op.create_index("ix_addresses_sync_conflicts_contact_status", "addresses_sync_conflicts", ["contact_id", "status"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_conflicts_resolved_at"), "addresses_sync_conflicts", ["resolved_at"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_conflicts_resolved_by_account_id"), "addresses_sync_conflicts", ["resolved_by_account_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_conflicts_status"), "addresses_sync_conflicts", ["status"], unique=False)
|
||||||
|
op.create_index("ix_addresses_sync_conflicts_source_status", "addresses_sync_conflicts", ["sync_source_id", "status"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_conflicts_sync_source_id"), "addresses_sync_conflicts", ["sync_source_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_conflicts_tenant_id"), "addresses_sync_conflicts", ["tenant_id"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"addresses_sync_diagnostics",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("sync_source_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("code", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("message", sa.Text(), nullable=False),
|
||||||
|
sa.Column("details", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["sync_source_id"],
|
||||||
|
["addresses_sync_sources.id"],
|
||||||
|
name=op.f("fk_addresses_sync_diagnostics_sync_source_id_addresses_sync_sources"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_addresses_sync_diagnostics")),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_diagnostics_code"), "addresses_sync_diagnostics", ["code"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_diagnostics_severity"), "addresses_sync_diagnostics", ["severity"], unique=False)
|
||||||
|
op.create_index("ix_addresses_sync_diagnostics_source_created", "addresses_sync_diagnostics", ["sync_source_id", "created_at"], unique=False)
|
||||||
|
op.create_index("ix_addresses_sync_diagnostics_source_severity", "addresses_sync_diagnostics", ["sync_source_id", "severity"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_diagnostics_sync_source_id"), "addresses_sync_diagnostics", ["sync_source_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_addresses_sync_diagnostics_tenant_id"), "addresses_sync_diagnostics", ["tenant_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("addresses_sync_diagnostics")
|
||||||
|
op.drop_table("addresses_sync_conflicts")
|
||||||
|
op.drop_table("addresses_sync_tombstones")
|
||||||
|
op.drop_table("addresses_sync_sources")
|
||||||
+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
@@ -0,0 +1,906 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||||
|
|
||||||
|
|
||||||
|
AddressBookScope = Literal["user", "group", "tenant", "system"]
|
||||||
|
AddressSyncDirection = Literal["read_only", "import", "export", "two_way"]
|
||||||
|
AddressSyncStatus = Literal["idle", "running", "succeeded", "failed", "conflict", "disabled"]
|
||||||
|
AddressSyncDiagnosticSeverity = Literal["debug", "info", "warning", "error"]
|
||||||
|
AddressSyncConflictStatus = Literal["open", "resolved", "ignored"]
|
||||||
|
AddressSyncConflictResolution = Literal["keep_local", "use_remote", "merge", "manual", "ignored"]
|
||||||
|
AddressCardDavAuthType = Literal["none", "basic", "bearer"]
|
||||||
|
AddressSyncPlanAction = Literal["create", "update", "delete", "remote_create", "remote_update", "remote_delete", "conflict", "unchanged", "error"]
|
||||||
|
AddressDistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
|
||||||
|
AddressContactPointChannel = Literal[
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"postal",
|
||||||
|
"internal_mail",
|
||||||
|
"portal",
|
||||||
|
]
|
||||||
|
AddressChannelDecision = Literal[
|
||||||
|
"allowed",
|
||||||
|
"opted_in",
|
||||||
|
"preferred",
|
||||||
|
"opted_out",
|
||||||
|
"suppressed",
|
||||||
|
"invalid",
|
||||||
|
"returned",
|
||||||
|
"temporarily_unavailable",
|
||||||
|
]
|
||||||
|
AddressDistributionOutcome = Literal[
|
||||||
|
"usable",
|
||||||
|
"unresolved",
|
||||||
|
"invalid",
|
||||||
|
"suppressed",
|
||||||
|
"ambiguous",
|
||||||
|
"duplicate",
|
||||||
|
"policy_blocked",
|
||||||
|
"provider_unavailable",
|
||||||
|
"stale",
|
||||||
|
]
|
||||||
|
AddressContactPointFallbackRule = Literal["none", "primary", "any"]
|
||||||
|
AddressPostalFormat = Literal["domestic", "international"]
|
||||||
|
ContactPointQualityState = Literal[
|
||||||
|
"valid",
|
||||||
|
"invalid",
|
||||||
|
"returned",
|
||||||
|
"stale",
|
||||||
|
"undeliverable",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ContactEmailPayload(BaseModel):
|
||||||
|
label: str | None = Field(default=None, max_length=80)
|
||||||
|
email: str = Field(max_length=320)
|
||||||
|
is_primary: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPhonePayload(BaseModel):
|
||||||
|
label: str | None = Field(default=None, max_length=80)
|
||||||
|
phone: str = Field(max_length=100)
|
||||||
|
is_primary: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPostalAddressPayload(BaseModel):
|
||||||
|
label: str | None = Field(default=None, max_length=80)
|
||||||
|
street: str | None = Field(default=None, max_length=500)
|
||||||
|
postal_code: str | None = Field(default=None, max_length=40)
|
||||||
|
locality: str | None = Field(default=None, max_length=255)
|
||||||
|
region: str | None = Field(default=None, max_length=255)
|
||||||
|
country: str | None = Field(default=None, max_length=255)
|
||||||
|
is_primary: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AddressBookCreateRequest(BaseModel):
|
||||||
|
scope_type: AddressBookScope = "user"
|
||||||
|
group_id: str | None = Field(default=None, max_length=36)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressBookUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressBookResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
scope_type: AddressBookScope
|
||||||
|
scope_id: str | None = None
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
source_kind: str
|
||||||
|
source_ref: str | None = None
|
||||||
|
read_only: bool
|
||||||
|
sync_status: str | None = None
|
||||||
|
sync_error: str | None = None
|
||||||
|
contact_count: int = 0
|
||||||
|
deleted_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AddressBookListResponse(BaseModel):
|
||||||
|
address_books: list[AddressBookResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class AddressListCreateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressListUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressListEntryCreateRequest(BaseModel):
|
||||||
|
contact_id: str = Field(max_length=36)
|
||||||
|
contact_email_id: str | None = Field(default=None, max_length=36)
|
||||||
|
contact_postal_address_id: str | None = Field(default=None, max_length=36)
|
||||||
|
label: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressListResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
address_book_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
source_kind: str
|
||||||
|
source_ref: str | None = None
|
||||||
|
read_only: bool
|
||||||
|
entry_count: int = 0
|
||||||
|
deleted_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AddressListListResponse(BaseModel):
|
||||||
|
address_lists: list[AddressListResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class AddressListEntryResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
address_list_id: str
|
||||||
|
contact_id: str
|
||||||
|
contact_email_id: str | None = None
|
||||||
|
contact_postal_address_id: str | None = None
|
||||||
|
target_kind: str
|
||||||
|
label: str | None = None
|
||||||
|
order_index: int
|
||||||
|
contact_display_name: str
|
||||||
|
email: str | None = None
|
||||||
|
postal_address: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AddressListEntryListResponse(BaseModel):
|
||||||
|
entries: list[AddressListEntryResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ContactCreateRequest(BaseModel):
|
||||||
|
display_name: str | None = Field(default=None, max_length=255)
|
||||||
|
given_name: str | None = Field(default=None, max_length=255)
|
||||||
|
family_name: str | None = Field(default=None, max_length=255)
|
||||||
|
organization: str | None = Field(default=None, max_length=255)
|
||||||
|
role_title: str | None = Field(default=None, max_length=255)
|
||||||
|
note: str | None = None
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
emails: list[ContactEmailPayload] = Field(default_factory=list)
|
||||||
|
phones: list[ContactPhonePayload] = Field(default_factory=list)
|
||||||
|
postal_addresses: list[ContactPostalAddressPayload] = Field(default_factory=list)
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUpdateRequest(BaseModel):
|
||||||
|
display_name: str | None = Field(default=None, max_length=255)
|
||||||
|
given_name: str | None = Field(default=None, max_length=255)
|
||||||
|
family_name: str | None = Field(default=None, max_length=255)
|
||||||
|
organization: str | None = Field(default=None, max_length=255)
|
||||||
|
role_title: str | None = Field(default=None, max_length=255)
|
||||||
|
note: str | None = None
|
||||||
|
tags: list[str] | None = None
|
||||||
|
emails: list[ContactEmailPayload] | None = None
|
||||||
|
phones: list[ContactPhonePayload] | None = None
|
||||||
|
postal_addresses: list[ContactPostalAddressPayload] | None = None
|
||||||
|
provenance: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactFieldProvenanceResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
contact_id: str
|
||||||
|
field_path: str
|
||||||
|
value: Any = None
|
||||||
|
source_kind: str
|
||||||
|
source_ref: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
precedence: int
|
||||||
|
selected: bool
|
||||||
|
reason_code: str
|
||||||
|
explanation: str | None = None
|
||||||
|
visibility: str
|
||||||
|
merge_record_id: str | None = None
|
||||||
|
created_by_account_id: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointQualityDecisionCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
channel: AddressContactPointChannel
|
||||||
|
contact_point_id: str | None = Field(default=None, max_length=36)
|
||||||
|
state: ContactPointQualityState
|
||||||
|
reason_code: str | None = Field(default=None, max_length=120)
|
||||||
|
reason: str | None = None
|
||||||
|
evidence_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
effective_from: datetime | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointQualityDecisionResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
contact_id: str
|
||||||
|
channel: AddressContactPointChannel
|
||||||
|
contact_point_id: str | None = None
|
||||||
|
state: ContactPointQualityState
|
||||||
|
reason_code: str
|
||||||
|
reason: str | None = None
|
||||||
|
evidence_ref: str | None = None
|
||||||
|
effective_from: datetime
|
||||||
|
effective_until: datetime | None = None
|
||||||
|
created_by_account_id: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointQualityDecisionListResponse(BaseModel):
|
||||||
|
decisions: list[ContactPointQualityDecisionResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactEmailResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str | None = None
|
||||||
|
email: str
|
||||||
|
original_email: str = ""
|
||||||
|
normalized_email: str = ""
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
quality_state: ContactPointQualityState = "valid"
|
||||||
|
quality_reason_code: str | None = None
|
||||||
|
is_primary: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPhoneResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str | None = None
|
||||||
|
phone: str
|
||||||
|
original_phone: str = ""
|
||||||
|
normalized_phone: str = ""
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
quality_state: ContactPointQualityState = "valid"
|
||||||
|
quality_reason_code: str | None = None
|
||||||
|
is_primary: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPostalAddressResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str | None = None
|
||||||
|
street: str | None = None
|
||||||
|
postal_code: str | None = None
|
||||||
|
locality: str | None = None
|
||||||
|
region: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
original_value: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
normalized_value: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
quality_state: ContactPointQualityState = "valid"
|
||||||
|
quality_reason_code: str | None = None
|
||||||
|
is_primary: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ContactResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
address_book_id: str
|
||||||
|
display_name: str
|
||||||
|
given_name: str | None = None
|
||||||
|
family_name: str | None = None
|
||||||
|
organization: str | None = None
|
||||||
|
role_title: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
tags: list[str]
|
||||||
|
source_kind: str
|
||||||
|
source_ref: str | None = None
|
||||||
|
source_payload_kind: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
emails: list[ContactEmailResponse]
|
||||||
|
phones: list[ContactPhoneResponse]
|
||||||
|
postal_addresses: list[ContactPostalAddressResponse]
|
||||||
|
field_provenance: list[ContactFieldProvenanceResponse] = Field(default_factory=list)
|
||||||
|
deleted_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ContactListResponse(BaseModel):
|
||||||
|
contacts: list[ContactResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ContactDuplicateFeatureResponse(BaseModel):
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
weight: int
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class ContactDuplicateSuggestionResponse(BaseModel):
|
||||||
|
left: ContactResponse
|
||||||
|
right: ContactResponse
|
||||||
|
score: int
|
||||||
|
confidence: Literal["possible", "likely", "strong"]
|
||||||
|
features: list[ContactDuplicateFeatureResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ContactDuplicateSuggestionListResponse(BaseModel):
|
||||||
|
suggestions: list[ContactDuplicateSuggestionResponse] = Field(default_factory=list)
|
||||||
|
scanned_contacts: int
|
||||||
|
candidate_pairs: int
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ContactMergeRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
winner_contact_id: str = Field(max_length=36)
|
||||||
|
duplicate_contact_ids: list[str] = Field(min_length=1, max_length=20)
|
||||||
|
reason: str = Field(min_length=3)
|
||||||
|
field_sources: dict[str, str] = Field(default_factory=dict)
|
||||||
|
contact_point_strategy: Literal["union", "winner_only"] = "union"
|
||||||
|
source_precedence: list[str] = Field(default_factory=list, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactMergeRecoveryRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
reason: str = Field(min_length=3)
|
||||||
|
expected_after_hash: str = Field(min_length=64, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactMergeRecordResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
address_book_id: str
|
||||||
|
winner_contact_id: str
|
||||||
|
loser_contact_ids: list[str]
|
||||||
|
status: str
|
||||||
|
reason: str
|
||||||
|
survivorship: dict[str, Any]
|
||||||
|
decisions: list[dict[str, Any]]
|
||||||
|
before_hash: str
|
||||||
|
after_hash: str
|
||||||
|
created_by_account_id: str | None = None
|
||||||
|
recovered_at: datetime | None = None
|
||||||
|
recovered_by_account_id: str | None = None
|
||||||
|
recovery_action: str | None = None
|
||||||
|
recovery_reason: str | None = None
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ContactMergeRecordListResponse(BaseModel):
|
||||||
|
merges: list[ContactMergeRecordResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactRedirectResponse(BaseModel):
|
||||||
|
requested_contact_id: str
|
||||||
|
resolved_contact_id: str
|
||||||
|
redirected: bool
|
||||||
|
redirect_chain: list[str] = Field(default_factory=list)
|
||||||
|
merge_record_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressQualityCorrectionResponse(BaseModel):
|
||||||
|
contact_id: str
|
||||||
|
display_name: str
|
||||||
|
channel: AddressContactPointChannel
|
||||||
|
contact_point_id: str | None = None
|
||||||
|
state: ContactPointQualityState
|
||||||
|
reason_code: str
|
||||||
|
reason: str | None = None
|
||||||
|
effective_from: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AddressQualitySummaryResponse(BaseModel):
|
||||||
|
contact_count: int
|
||||||
|
contact_point_count: int
|
||||||
|
quality_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
duplicate_suggestion_count: int
|
||||||
|
correction_count: int
|
||||||
|
corrections: list[AddressQualityCorrectionResponse] = Field(default_factory=list)
|
||||||
|
truncated: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ContactChannelRuleCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
channel: AddressDistributionChannel
|
||||||
|
purpose: str | None = Field(default=None, max_length=120)
|
||||||
|
contact_point_id: str | None = Field(default=None, max_length=36)
|
||||||
|
decision: AddressChannelDecision
|
||||||
|
legal_basis: str | None = Field(default=None, max_length=255)
|
||||||
|
evidence_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
reason: str | None = None
|
||||||
|
preference_rank: int | None = Field(default=None, ge=0, le=10000)
|
||||||
|
locale: str | None = Field(default=None, max_length=20)
|
||||||
|
effective_from: datetime | None = None
|
||||||
|
effective_until: datetime | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactChannelRuleResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
contact_id: str
|
||||||
|
channel: AddressDistributionChannel
|
||||||
|
purpose: str | None = None
|
||||||
|
contact_point_id: str | None = None
|
||||||
|
decision: AddressChannelDecision
|
||||||
|
legal_basis: str | None = None
|
||||||
|
evidence_ref: str | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
preference_rank: int | None = None
|
||||||
|
locale: str | None = None
|
||||||
|
effective_from: datetime | None = None
|
||||||
|
effective_until: datetime | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict, validation_alias="metadata_")
|
||||||
|
created_by_account_id: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ContactChannelRuleListResponse(BaseModel):
|
||||||
|
rules: list[ContactChannelRuleResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSourceReferencePayload(BaseModel):
|
||||||
|
provider: str = Field(min_length=1, max_length=120)
|
||||||
|
resource_type: str = Field(min_length=1, max_length=120)
|
||||||
|
resource_id: str = Field(min_length=1, max_length=1000)
|
||||||
|
revision: str | None = Field(default=None, max_length=1000)
|
||||||
|
fingerprint: str | None = Field(default=None, max_length=255)
|
||||||
|
label: str | None = Field(default=None, max_length=500)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointResolveRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
subject: AddressSourceReferencePayload
|
||||||
|
effective_at: datetime
|
||||||
|
purpose: str | None = Field(default=None, max_length=120)
|
||||||
|
requested_channels: list[AddressDistributionChannel] = Field(default_factory=list)
|
||||||
|
address_purpose: str | None = Field(default=None, max_length=80)
|
||||||
|
fallback_rule: AddressContactPointFallbackRule = "primary"
|
||||||
|
locale: str | None = Field(default=None, max_length=20)
|
||||||
|
postal_format: AddressPostalFormat = "domestic"
|
||||||
|
context: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointSourceRequestPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
source_id: str = Field(min_length=1, max_length=1000)
|
||||||
|
effective_at: datetime
|
||||||
|
purpose: str | None = Field(default=None, max_length=120)
|
||||||
|
requested_channels: list[AddressDistributionChannel] = Field(default_factory=list)
|
||||||
|
address_purpose: str | None = Field(default=None, max_length=80)
|
||||||
|
fallback_rule: AddressContactPointFallbackRule = "primary"
|
||||||
|
locale: str | None = Field(default=None, max_length=20)
|
||||||
|
postal_format: AddressPostalFormat = "domestic"
|
||||||
|
max_items: int = Field(default=5000, ge=1, le=20000)
|
||||||
|
context: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointSourceRequestResponse(ContactPointSourceRequestPayload):
|
||||||
|
tenant_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointCandidateResponse(BaseModel):
|
||||||
|
channel: AddressDistributionChannel
|
||||||
|
target: str
|
||||||
|
target_key: str
|
||||||
|
status: AddressDistributionOutcome
|
||||||
|
contact_point_id: str | None = None
|
||||||
|
address_purpose: str | None = None
|
||||||
|
locale: str | None = None
|
||||||
|
preferred: bool = False
|
||||||
|
preference_rank: int | None = None
|
||||||
|
reason_code: str | None = None
|
||||||
|
explanation: str | None = None
|
||||||
|
source: AddressSourceReferencePayload | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
preference_revision: str | None = None
|
||||||
|
consent_revision: str | None = None
|
||||||
|
value: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionExplanationResponse(BaseModel):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
severity: Literal["info", "warning", "error"]
|
||||||
|
provider: str | None = None
|
||||||
|
source: AddressSourceReferencePayload | None = None
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointResolutionResponse(BaseModel):
|
||||||
|
contract_version: str
|
||||||
|
subject: AddressSourceReferencePayload
|
||||||
|
status: AddressDistributionOutcome
|
||||||
|
contact_id: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
candidates: list[ContactPointCandidateResponse] = Field(default_factory=list)
|
||||||
|
excluded: list[ContactPointCandidateResponse] = Field(default_factory=list)
|
||||||
|
explanations: list[DistributionExplanationResponse] = Field(default_factory=list)
|
||||||
|
source_revision: str | None = None
|
||||||
|
source_fingerprint: str | None = None
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointSourcePreviewResponse(BaseModel):
|
||||||
|
contract_version: str
|
||||||
|
source: AddressSourceReferencePayload
|
||||||
|
request: ContactPointSourceRequestResponse
|
||||||
|
resolutions: list[ContactPointResolutionResponse]
|
||||||
|
total_count: int
|
||||||
|
usable_count: int
|
||||||
|
excluded_count: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
has_more: bool
|
||||||
|
source_revision: str
|
||||||
|
source_fingerprint: str
|
||||||
|
generated_at: datetime
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPointSnapshotResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
contract_version: str
|
||||||
|
source: AddressSourceReferencePayload
|
||||||
|
request: ContactPointSourceRequestResponse
|
||||||
|
resolutions: list[ContactPointResolutionResponse]
|
||||||
|
recipient_count: int
|
||||||
|
excluded_count: int
|
||||||
|
source_revision: str
|
||||||
|
source_fingerprint: str
|
||||||
|
snapshot_hash: str
|
||||||
|
generated_at: datetime
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressLookupResponse(BaseModel):
|
||||||
|
contacts: list[ContactResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class AddressBookWriteDecisionResponse(BaseModel):
|
||||||
|
address_book_id: str
|
||||||
|
address_book_label: str | None = None
|
||||||
|
operation: str
|
||||||
|
allowed: bool
|
||||||
|
reason: str
|
||||||
|
message: str
|
||||||
|
scope_type: AddressBookScope | None = None
|
||||||
|
scope_id: str | None = None
|
||||||
|
tenant_id: str | None = None
|
||||||
|
source_kind: str | None = None
|
||||||
|
read_only: bool = False
|
||||||
|
required_scopes: list[str] = Field(default_factory=list)
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressBookWriteTargetsResponse(BaseModel):
|
||||||
|
targets: list[AddressBookWriteDecisionResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncSourceCreateRequest(BaseModel):
|
||||||
|
connector_type: str = Field(min_length=1, max_length=60)
|
||||||
|
display_name: str = Field(min_length=1, max_length=255)
|
||||||
|
external_account_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
external_address_book_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
sync_direction: AddressSyncDirection = "read_only"
|
||||||
|
read_only: bool | None = None
|
||||||
|
enabled: bool = True
|
||||||
|
sync_token: str | None = None
|
||||||
|
etag: str | None = Field(default=None, max_length=1000)
|
||||||
|
remote_revision: str | None = Field(default=None, max_length=1000)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncSourceUpdateRequest(BaseModel):
|
||||||
|
display_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
external_account_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
external_address_book_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
sync_direction: AddressSyncDirection | None = None
|
||||||
|
read_only: bool | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
sync_token: str | None = None
|
||||||
|
etag: str | None = Field(default=None, max_length=1000)
|
||||||
|
remote_revision: str | None = Field(default=None, max_length=1000)
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncAttemptFinishRequest(BaseModel):
|
||||||
|
status: Literal["succeeded", "failed", "conflict"]
|
||||||
|
sync_token: str | None = None
|
||||||
|
etag: str | None = Field(default=None, max_length=1000)
|
||||||
|
remote_revision: str | None = Field(default=None, max_length=1000)
|
||||||
|
error: str | None = None
|
||||||
|
diagnostic: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncSourceResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
address_book_id: str
|
||||||
|
connector_type: str
|
||||||
|
display_name: str
|
||||||
|
external_account_ref: str | None = None
|
||||||
|
external_address_book_ref: str | None = None
|
||||||
|
sync_direction: str
|
||||||
|
read_only: bool
|
||||||
|
enabled: bool
|
||||||
|
status: str
|
||||||
|
sync_token: str | None = None
|
||||||
|
etag: str | None = None
|
||||||
|
remote_revision: str | None = None
|
||||||
|
last_attempted_at: datetime | None = None
|
||||||
|
last_success_at: datetime | None = None
|
||||||
|
last_error: str | None = None
|
||||||
|
last_diagnostic: dict[str, Any] | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncSourceListResponse(BaseModel):
|
||||||
|
sync_sources: list[AddressSyncSourceResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncDiagnosticCreateRequest(BaseModel):
|
||||||
|
severity: AddressSyncDiagnosticSeverity = "info"
|
||||||
|
code: str = Field(min_length=1, max_length=120)
|
||||||
|
message: str = Field(min_length=1)
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncDiagnosticResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
sync_source_id: str
|
||||||
|
severity: str
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncDiagnosticListResponse(BaseModel):
|
||||||
|
diagnostics: list[AddressSyncDiagnosticResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncTombstoneCreateRequest(BaseModel):
|
||||||
|
contact_id: str | None = Field(default=None, max_length=36)
|
||||||
|
remote_uid: str | None = Field(default=None, max_length=1000)
|
||||||
|
resource_href: str | None = Field(default=None, max_length=1000)
|
||||||
|
local_deleted_at: datetime | None = None
|
||||||
|
remote_deleted_at: datetime | None = None
|
||||||
|
synced_at: datetime | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncTombstoneResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
sync_source_id: str
|
||||||
|
address_book_id: str
|
||||||
|
contact_id: str | None = None
|
||||||
|
remote_uid: str | None = None
|
||||||
|
resource_href: str | None = None
|
||||||
|
local_deleted_at: datetime | None = None
|
||||||
|
remote_deleted_at: datetime | None = None
|
||||||
|
synced_at: datetime | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncTombstoneListResponse(BaseModel):
|
||||||
|
tombstones: list[AddressSyncTombstoneResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncConflictCreateRequest(BaseModel):
|
||||||
|
contact_id: str | None = Field(default=None, max_length=36)
|
||||||
|
remote_uid: str | None = Field(default=None, max_length=1000)
|
||||||
|
resource_href: str | None = Field(default=None, max_length=1000)
|
||||||
|
field_path: str = Field(min_length=1, max_length=500)
|
||||||
|
local_value: dict[str, Any] | None = None
|
||||||
|
remote_value: dict[str, Any] | None = None
|
||||||
|
local_updated_at: datetime | None = None
|
||||||
|
remote_updated_at: datetime | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncConflictResolveRequest(BaseModel):
|
||||||
|
resolution: AddressSyncConflictResolution
|
||||||
|
status: Literal["resolved", "ignored"] = "resolved"
|
||||||
|
merged_payload: ContactCreateRequest | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncConflictResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
sync_source_id: str
|
||||||
|
address_book_id: str
|
||||||
|
contact_id: str | None = None
|
||||||
|
remote_uid: str | None = None
|
||||||
|
resource_href: str | None = None
|
||||||
|
field_path: str
|
||||||
|
local_value: dict[str, Any] | None = None
|
||||||
|
remote_value: dict[str, Any] | None = None
|
||||||
|
local_updated_at: datetime | None = None
|
||||||
|
remote_updated_at: datetime | None = None
|
||||||
|
status: str
|
||||||
|
resolution: str | None = None
|
||||||
|
resolved_at: datetime | None = None
|
||||||
|
resolved_by_account_id: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncConflictListResponse(BaseModel):
|
||||||
|
conflicts: list[AddressSyncConflictResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCardDavDiscoveryRequest(BaseModel):
|
||||||
|
url: str = Field(min_length=1, max_length=2000)
|
||||||
|
auth_type: AddressCardDavAuthType = "none"
|
||||||
|
username: str | None = Field(default=None, max_length=320)
|
||||||
|
password: SecretStr | None = None
|
||||||
|
bearer_token: SecretStr | None = None
|
||||||
|
credential_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
source_id: str | None = Field(default=None, max_length=36)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCardDavAddressBookResponse(BaseModel):
|
||||||
|
collection_url: str
|
||||||
|
href: str
|
||||||
|
display_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
ctag: str | None = None
|
||||||
|
sync_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCardDavDiscoveryResponse(BaseModel):
|
||||||
|
address_books: list[AddressCardDavAddressBookResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCredentialEnvelopeResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
scope_type: str
|
||||||
|
scope_id: str | None = None
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
credential_kind: str
|
||||||
|
public_data: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
secret_keys: list[str] = Field(default_factory=list)
|
||||||
|
secret_configured: bool = False
|
||||||
|
allowed_modules: list[str] = Field(default_factory=list)
|
||||||
|
inherit_to_lower_scopes: bool = False
|
||||||
|
is_active: bool = True
|
||||||
|
revision: str
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCredentialEnvelopeListResponse(BaseModel):
|
||||||
|
credentials: list[AddressCredentialEnvelopeResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCardDavSourceCreateRequest(BaseModel):
|
||||||
|
collection_url: str = Field(min_length=1, max_length=2000)
|
||||||
|
display_name: str | None = Field(default=None, max_length=255)
|
||||||
|
auth_type: AddressCardDavAuthType = "none"
|
||||||
|
username: str | None = Field(default=None, max_length=320)
|
||||||
|
password: SecretStr | None = None
|
||||||
|
bearer_token: SecretStr | None = None
|
||||||
|
credential_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
sync_direction: AddressSyncDirection = "read_only"
|
||||||
|
read_only: bool | None = None
|
||||||
|
sync_token: str | None = None
|
||||||
|
etag: str | None = Field(default=None, max_length=1000)
|
||||||
|
remote_revision: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncRunRequest(BaseModel):
|
||||||
|
force_full: bool = False
|
||||||
|
password: SecretStr | None = None
|
||||||
|
bearer_token: SecretStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncPlanStats(BaseModel):
|
||||||
|
created: int = 0
|
||||||
|
updated: int = 0
|
||||||
|
deleted: int = 0
|
||||||
|
conflicts: int = 0
|
||||||
|
unchanged: int = 0
|
||||||
|
errors: int = 0
|
||||||
|
fetched: int = 0
|
||||||
|
full_sync: bool = False
|
||||||
|
used_sync_token: bool = False
|
||||||
|
sync_token: str | None = None
|
||||||
|
etag: str | None = None
|
||||||
|
remote_revision: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncPlanItemResponse(BaseModel):
|
||||||
|
action: AddressSyncPlanAction
|
||||||
|
href: str | None = None
|
||||||
|
remote_uid: str | None = None
|
||||||
|
contact_id: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
etag: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddressSyncPlanResponse(BaseModel):
|
||||||
|
sync_source: AddressSyncSourceResponse
|
||||||
|
stats: AddressSyncPlanStats
|
||||||
|
items: list[AddressSyncPlanItemResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class VCardImportRequest(BaseModel):
|
||||||
|
content: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class VCardImportIssue(BaseModel):
|
||||||
|
index: int
|
||||||
|
message: str
|
||||||
|
severity: Literal["warning", "error"] = "error"
|
||||||
|
field: str | None = None
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VCardImportResponse(BaseModel):
|
||||||
|
imported: int
|
||||||
|
skipped: int
|
||||||
|
contacts: list[ContactResponse]
|
||||||
|
issues: list[VCardImportIssue] = Field(default_factory=list)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,547 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from govoplan_addresses.backend.db.models import Contact
|
||||||
|
from govoplan_addresses.backend.schemas import (
|
||||||
|
ContactCreateRequest,
|
||||||
|
ContactEmailPayload,
|
||||||
|
ContactPhonePayload,
|
||||||
|
ContactPostalAddressPayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VCardError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
VCARD_PARSER_VERSION = "govoplan-vcard/2"
|
||||||
|
MAX_VCARD_CARDS = 10_000
|
||||||
|
MAX_VCARD_LINES = 200_000
|
||||||
|
MAX_VCARD_UNFOLDED_LINE_CHARS = 16_384
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedVCard:
|
||||||
|
payload: ContactCreateRequest
|
||||||
|
raw: str
|
||||||
|
source_ref: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedVCardIssue:
|
||||||
|
index: int
|
||||||
|
message: str
|
||||||
|
severity: Literal["warning", "error"] = "error"
|
||||||
|
field: str | None = None
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VCardParseResult:
|
||||||
|
cards: list[ParsedVCard]
|
||||||
|
issues: list[ParsedVCardIssue]
|
||||||
|
skipped: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _VCardDraft:
|
||||||
|
fn: str | None = None
|
||||||
|
given_name: str | None = None
|
||||||
|
family_name: str | None = None
|
||||||
|
organization: str | None = None
|
||||||
|
role_title: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
uid: str | None = None
|
||||||
|
revision: str | None = None
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
emails: list[ContactEmailPayload] = field(default_factory=list)
|
||||||
|
phones: list[ContactPhonePayload] = field(default_factory=list)
|
||||||
|
addresses: list[ContactPostalAddressPayload] = field(default_factory=list)
|
||||||
|
urls: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_lines(content: str) -> list[str]:
|
||||||
|
raw_lines = content.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||||
|
if len(raw_lines) > MAX_VCARD_LINES:
|
||||||
|
raise VCardError(f"vCard input exceeds the {MAX_VCARD_LINES}-line parser limit.")
|
||||||
|
lines: list[str] = []
|
||||||
|
for line in raw_lines:
|
||||||
|
if line.startswith((" ", "\t")) and lines:
|
||||||
|
lines[-1] += line[1:]
|
||||||
|
elif line:
|
||||||
|
lines.append(line)
|
||||||
|
if lines and len(lines[-1]) > MAX_VCARD_UNFOLDED_LINE_CHARS:
|
||||||
|
raise VCardError(f"vCard unfolded lines are limited to {MAX_VCARD_UNFOLDED_LINE_CHARS} characters.")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _split_unescaped(value: str, separator: str) -> list[str]:
|
||||||
|
parts: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
escaped = False
|
||||||
|
for char in value:
|
||||||
|
if escaped:
|
||||||
|
current.append(char)
|
||||||
|
escaped = False
|
||||||
|
elif char == "\\":
|
||||||
|
current.append(char)
|
||||||
|
escaped = True
|
||||||
|
elif char == separator:
|
||||||
|
parts.append("".join(current))
|
||||||
|
current = []
|
||||||
|
else:
|
||||||
|
current.append(char)
|
||||||
|
parts.append("".join(current))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _unescape_text(value: str) -> str:
|
||||||
|
return value.replace("\\n", "\n").replace("\\N", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_text(value: str | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return value.replace("\\", "\\\\").replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\\n").replace(";", "\\;").replace(",", "\\,")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_head(head: str) -> tuple[str, dict[str, list[str]]]:
|
||||||
|
parts = head.split(";")
|
||||||
|
name = parts[0].split(".")[-1].upper()
|
||||||
|
params: dict[str, list[str]] = {}
|
||||||
|
for part in parts[1:]:
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if "=" in part:
|
||||||
|
key, raw_value = part.split("=", 1)
|
||||||
|
values = [item.strip().strip('"') for item in raw_value.split(",") if item.strip()]
|
||||||
|
else:
|
||||||
|
key = "TYPE"
|
||||||
|
values = [part.strip().strip('"')]
|
||||||
|
params.setdefault(key.upper(), []).extend(values)
|
||||||
|
return name, params
|
||||||
|
|
||||||
|
|
||||||
|
def _label_from_params(params: dict[str, list[str]]) -> str | None:
|
||||||
|
ignored = {"INTERNET", "VOICE", "PREF"}
|
||||||
|
for value in params.get("TYPE", []):
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized and normalized.upper() not in ignored:
|
||||||
|
return normalized
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _line_value(line: str, *, card_index: int) -> tuple[str, dict[str, list[str]], str]:
|
||||||
|
if ":" not in line:
|
||||||
|
raise VCardError(f"vCard {card_index}: line is missing ':' separator.")
|
||||||
|
head, value = line.split(":", 1)
|
||||||
|
name, params = _parse_head(head)
|
||||||
|
return name, params, value
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pref(params: dict[str, list[str]]) -> bool:
|
||||||
|
values = [value.strip().upper() for value in params.get("TYPE", [])]
|
||||||
|
values.extend(value.strip().upper() for value in params.get("PREF", []))
|
||||||
|
return "PREF" in values or "1" in values
|
||||||
|
|
||||||
|
|
||||||
|
def _card_blocks(content: str) -> list[list[str]]:
|
||||||
|
result = _card_blocks_with_issues(content, max_cards=MAX_VCARD_CARDS)
|
||||||
|
if result.issues:
|
||||||
|
raise VCardError(result.issues[0].message)
|
||||||
|
return result.cards
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CardBlockResult:
|
||||||
|
cards: list[list[str]]
|
||||||
|
issues: list[ParsedVCardIssue]
|
||||||
|
|
||||||
|
|
||||||
|
def _card_blocks_with_issues(content: str, *, max_cards: int) -> _CardBlockResult:
|
||||||
|
try:
|
||||||
|
lines = _normalize_lines(content)
|
||||||
|
except VCardError as exc:
|
||||||
|
return _CardBlockResult(
|
||||||
|
cards=[],
|
||||||
|
issues=[ParsedVCardIssue(index=0, message=str(exc))],
|
||||||
|
)
|
||||||
|
blocks: list[list[str]] = []
|
||||||
|
issues: list[ParsedVCardIssue] = []
|
||||||
|
current: list[str] | None = None
|
||||||
|
for line in lines:
|
||||||
|
card_index = len(blocks) + 1
|
||||||
|
try:
|
||||||
|
name, _params, value = _line_value(line, card_index=card_index)
|
||||||
|
except VCardError as exc:
|
||||||
|
issues.append(ParsedVCardIssue(index=card_index, message=str(exc), field="line"))
|
||||||
|
continue
|
||||||
|
if name == "BEGIN" and line.split(":", 1)[1].upper() == "VCARD":
|
||||||
|
if current is not None:
|
||||||
|
issues.append(ParsedVCardIssue(index=card_index, message="Nested vCard BEGIN is not supported."))
|
||||||
|
current = None
|
||||||
|
continue
|
||||||
|
current = [line]
|
||||||
|
elif name == "END" and value.upper() == "VCARD":
|
||||||
|
if current is None:
|
||||||
|
issues.append(ParsedVCardIssue(index=card_index, message="vCard END appears before BEGIN."))
|
||||||
|
continue
|
||||||
|
current.append(line)
|
||||||
|
if len(blocks) >= max_cards:
|
||||||
|
issues.append(
|
||||||
|
ParsedVCardIssue(
|
||||||
|
index=len(blocks) + 1,
|
||||||
|
message=f"vCard input exceeds the configured {max_cards}-card limit.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
current = None
|
||||||
|
break
|
||||||
|
blocks.append(current)
|
||||||
|
current = None
|
||||||
|
elif current is not None:
|
||||||
|
current.append(line)
|
||||||
|
if current is not None:
|
||||||
|
issues.append(ParsedVCardIssue(index=len(blocks) + 1, message="vCard BEGIN has no matching END."))
|
||||||
|
if not blocks:
|
||||||
|
issues.append(ParsedVCardIssue(index=0, message="No vCard entries found."))
|
||||||
|
return _CardBlockResult(cards=blocks, issues=issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_card(index: int, block: list[str]) -> tuple[ParsedVCard | None, list[ParsedVCardIssue]]:
|
||||||
|
draft = _VCardDraft()
|
||||||
|
issues: list[ParsedVCardIssue] = []
|
||||||
|
|
||||||
|
for line in block:
|
||||||
|
parsed = _parse_card_line(index, line, issues)
|
||||||
|
if parsed is not None:
|
||||||
|
name, params, value = parsed
|
||||||
|
_apply_card_property(index, draft, name, params, value, issues)
|
||||||
|
|
||||||
|
if not _draft_has_identity(draft):
|
||||||
|
issues.append(ParsedVCardIssue(index=index, message=f"vCard {index}: contact has no name or email."))
|
||||||
|
return None, issues
|
||||||
|
|
||||||
|
raw = "\n".join(block)
|
||||||
|
payload = _draft_contact_payload(draft)
|
||||||
|
return ParsedVCard(payload=payload, raw=raw, source_ref=draft.uid, source_revision=draft.revision), issues
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_card_line(
|
||||||
|
index: int,
|
||||||
|
line: str,
|
||||||
|
issues: list[ParsedVCardIssue],
|
||||||
|
) -> tuple[str, dict[str, list[str]], str] | None:
|
||||||
|
try:
|
||||||
|
return _line_value(line, card_index=index)
|
||||||
|
except VCardError as exc:
|
||||||
|
issues.append(ParsedVCardIssue(index=index, message=str(exc), field="line"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_card_property(
|
||||||
|
index: int,
|
||||||
|
draft: _VCardDraft,
|
||||||
|
name: str,
|
||||||
|
params: dict[str, list[str]],
|
||||||
|
value: str,
|
||||||
|
issues: list[ParsedVCardIssue],
|
||||||
|
) -> None:
|
||||||
|
if name in {"BEGIN", "END"}:
|
||||||
|
return
|
||||||
|
if _apply_card_metadata(index, draft, name, value, issues):
|
||||||
|
return
|
||||||
|
if _apply_card_identity(draft, name, value):
|
||||||
|
return
|
||||||
|
_apply_card_contact_detail(index, draft, name, params, value, issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_card_metadata(
|
||||||
|
index: int,
|
||||||
|
draft: _VCardDraft,
|
||||||
|
name: str,
|
||||||
|
value: str,
|
||||||
|
issues: list[ParsedVCardIssue],
|
||||||
|
) -> bool:
|
||||||
|
if name == "VERSION":
|
||||||
|
draft.version = value.strip()
|
||||||
|
if draft.version and draft.version not in {"3.0", "4.0"}:
|
||||||
|
issues.append(
|
||||||
|
ParsedVCardIssue(
|
||||||
|
index=index,
|
||||||
|
severity="warning",
|
||||||
|
field="VERSION",
|
||||||
|
message=f"vCard version {draft.version} is not fully supported.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
if name == "UID":
|
||||||
|
draft.uid = _unescape_text(value) or draft.uid
|
||||||
|
return True
|
||||||
|
if name == "REV":
|
||||||
|
draft.revision = _unescape_text(value) or draft.revision
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_card_identity(draft: _VCardDraft, name: str, value: str) -> bool:
|
||||||
|
if name == "FN":
|
||||||
|
draft.fn = _unescape_text(value)
|
||||||
|
return True
|
||||||
|
if name == "N":
|
||||||
|
parts = [_unescape_text(item) for item in _split_unescaped(value, ";")]
|
||||||
|
draft.family_name = parts[0] if len(parts) > 0 and parts[0] else draft.family_name
|
||||||
|
draft.given_name = parts[1] if len(parts) > 1 and parts[1] else draft.given_name
|
||||||
|
return True
|
||||||
|
if name == "ORG":
|
||||||
|
organization_parts = _unescaped_nonempty_values(value, ";")
|
||||||
|
draft.organization = " / ".join(organization_parts) or draft.organization
|
||||||
|
return True
|
||||||
|
if name in {"TITLE", "ROLE"}:
|
||||||
|
draft.role_title = _unescape_text(value) or draft.role_title
|
||||||
|
return True
|
||||||
|
if name == "NOTE":
|
||||||
|
draft.note = _unescape_text(value) or draft.note
|
||||||
|
return True
|
||||||
|
if name == "CATEGORIES":
|
||||||
|
draft.tags.extend(_unescaped_nonempty_values(value, ","))
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_card_contact_detail(
|
||||||
|
index: int,
|
||||||
|
draft: _VCardDraft,
|
||||||
|
name: str,
|
||||||
|
params: dict[str, list[str]],
|
||||||
|
value: str,
|
||||||
|
issues: list[ParsedVCardIssue],
|
||||||
|
) -> None:
|
||||||
|
if name == "EMAIL":
|
||||||
|
_append_card_email(index, draft, params, value, issues)
|
||||||
|
elif name == "TEL":
|
||||||
|
_append_card_phone(draft, params, value)
|
||||||
|
elif name == "ADR":
|
||||||
|
_append_card_address(draft, params, value)
|
||||||
|
elif name == "URL":
|
||||||
|
url = _unescape_text(value)
|
||||||
|
if url:
|
||||||
|
draft.urls.append(url)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_card_email(
|
||||||
|
index: int,
|
||||||
|
draft: _VCardDraft,
|
||||||
|
params: dict[str, list[str]],
|
||||||
|
value: str,
|
||||||
|
issues: list[ParsedVCardIssue],
|
||||||
|
) -> None:
|
||||||
|
email = _unescape_text(value)
|
||||||
|
if not email:
|
||||||
|
return
|
||||||
|
if "@" not in email:
|
||||||
|
issues.append(
|
||||||
|
ParsedVCardIssue(
|
||||||
|
index=index,
|
||||||
|
severity="warning",
|
||||||
|
field="EMAIL",
|
||||||
|
message=f"Skipped invalid email address: {email}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
draft.emails.append(
|
||||||
|
ContactEmailPayload(
|
||||||
|
label=_label_from_params(params),
|
||||||
|
email=email,
|
||||||
|
is_primary=_is_pref(params) or not draft.emails,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_card_phone(draft: _VCardDraft, params: dict[str, list[str]], value: str) -> None:
|
||||||
|
phone = _unescape_text(value)
|
||||||
|
if phone:
|
||||||
|
draft.phones.append(
|
||||||
|
ContactPhonePayload(
|
||||||
|
label=_label_from_params(params),
|
||||||
|
phone=phone,
|
||||||
|
is_primary=_is_pref(params) or not draft.phones,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_card_address(draft: _VCardDraft, params: dict[str, list[str]], value: str) -> None:
|
||||||
|
parts = [_unescape_text(item) for item in _split_unescaped(value, ";")]
|
||||||
|
while len(parts) < 7:
|
||||||
|
parts.append("")
|
||||||
|
if not any(parts):
|
||||||
|
return
|
||||||
|
street = "\n".join(part for part in (parts[1], parts[2]) if part)
|
||||||
|
draft.addresses.append(
|
||||||
|
ContactPostalAddressPayload(
|
||||||
|
label=_label_from_params(params),
|
||||||
|
street=street or None,
|
||||||
|
locality=parts[3] or None,
|
||||||
|
region=parts[4] or None,
|
||||||
|
postal_code=parts[5] or None,
|
||||||
|
country=parts[6] or None,
|
||||||
|
is_primary=_is_pref(params) or not draft.addresses,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unescaped_nonempty_values(value: str, separator: str) -> list[str]:
|
||||||
|
return [text for text in (_unescape_text(item) for item in _split_unescaped(value, separator)) if text]
|
||||||
|
|
||||||
|
|
||||||
|
def _draft_has_identity(draft: _VCardDraft) -> bool:
|
||||||
|
return bool(draft.fn or draft.given_name or draft.family_name or draft.emails)
|
||||||
|
|
||||||
|
|
||||||
|
def _draft_contact_payload(draft: _VCardDraft) -> ContactCreateRequest:
|
||||||
|
payload = ContactCreateRequest(
|
||||||
|
display_name=draft.fn,
|
||||||
|
given_name=draft.given_name,
|
||||||
|
family_name=draft.family_name,
|
||||||
|
organization=draft.organization,
|
||||||
|
role_title=draft.role_title,
|
||||||
|
note=draft.note,
|
||||||
|
tags=draft.tags,
|
||||||
|
emails=draft.emails,
|
||||||
|
phones=draft.phones,
|
||||||
|
postal_addresses=draft.addresses,
|
||||||
|
provenance={
|
||||||
|
"vcard": {
|
||||||
|
"version": draft.version,
|
||||||
|
"uid": draft.uid,
|
||||||
|
"revision": draft.revision,
|
||||||
|
"urls": draft.urls,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vcards_with_issues(
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
max_cards: int = MAX_VCARD_CARDS,
|
||||||
|
) -> VCardParseResult:
|
||||||
|
if max_cards < 1 or max_cards > MAX_VCARD_CARDS:
|
||||||
|
raise VCardError(f"max_cards must be between 1 and {MAX_VCARD_CARDS}.")
|
||||||
|
blocks = _card_blocks_with_issues(content, max_cards=max_cards)
|
||||||
|
parsed: list[ParsedVCard] = []
|
||||||
|
issues = list(blocks.issues)
|
||||||
|
skipped = 0
|
||||||
|
for index, block in enumerate(blocks.cards, start=1):
|
||||||
|
card, card_issues = _parse_card(index, block)
|
||||||
|
issues.extend(card_issues)
|
||||||
|
if card is None:
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
parsed.append(card)
|
||||||
|
return VCardParseResult(cards=parsed, issues=issues, skipped=skipped)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vcards(content: str) -> list[ParsedVCard]:
|
||||||
|
result = parse_vcards_with_issues(content)
|
||||||
|
errors = [issue for issue in result.issues if issue.severity == "error"]
|
||||||
|
if errors:
|
||||||
|
raise VCardError(errors[0].message)
|
||||||
|
return result.cards
|
||||||
|
|
||||||
|
|
||||||
|
def contact_to_vcard(contact: Contact, *, version: Literal["3.0", "4.0"] = "4.0") -> str:
|
||||||
|
lines = _contact_identity_lines(contact, version=version)
|
||||||
|
lines.extend(_contact_email_lines(contact))
|
||||||
|
lines.extend(_contact_phone_lines(contact))
|
||||||
|
lines.extend(_contact_address_lines(contact))
|
||||||
|
lines.extend(_contact_note_and_tag_lines(contact))
|
||||||
|
lines.extend(_contact_url_lines(contact))
|
||||||
|
lines.append("END:VCARD")
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_identity_lines(
|
||||||
|
contact: Contact,
|
||||||
|
*,
|
||||||
|
version: Literal["3.0", "4.0"],
|
||||||
|
) -> list[str]:
|
||||||
|
lines = [
|
||||||
|
"BEGIN:VCARD",
|
||||||
|
f"VERSION:{version}",
|
||||||
|
f"FN:{_escape_text(contact.display_name)}",
|
||||||
|
f"N:{_escape_text(contact.family_name)};{_escape_text(contact.given_name)};;;",
|
||||||
|
]
|
||||||
|
if contact.source_ref:
|
||||||
|
lines.append(f"UID:{_escape_text(contact.source_ref)}")
|
||||||
|
if contact.source_revision:
|
||||||
|
lines.append(f"REV:{_escape_text(contact.source_revision)}")
|
||||||
|
if contact.organization:
|
||||||
|
lines.append(f"ORG:{_escape_text(contact.organization)}")
|
||||||
|
if contact.role_title:
|
||||||
|
lines.append(f"TITLE:{_escape_text(contact.role_title)}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_email_lines(contact: Contact) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
for email in contact.emails:
|
||||||
|
label = f";TYPE={_escape_text(email.label)}" if email.label else ""
|
||||||
|
lines.append(f"EMAIL{label}:{_escape_text(email.email)}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_phone_lines(contact: Contact) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
for phone in contact.phones:
|
||||||
|
label = f";TYPE={_escape_text(phone.label)}" if phone.label else ""
|
||||||
|
lines.append(f"TEL{label}:{_escape_text(phone.phone)}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_address_lines(contact: Contact) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
for address in contact.postal_addresses:
|
||||||
|
label = f";TYPE={_escape_text(address.label)}" if address.label else ""
|
||||||
|
lines.append(f"ADR{label}:;;{_escape_text(address.street)};{_escape_text(address.locality)};{_escape_text(address.region)};{_escape_text(address.postal_code)};{_escape_text(address.country)}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_note_and_tag_lines(contact: Contact) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
if contact.note:
|
||||||
|
lines.append(f"NOTE:{_escape_text(contact.note)}")
|
||||||
|
if contact.tags:
|
||||||
|
lines.append(f"CATEGORIES:{','.join(_escape_text(tag) for tag in contact.tags)}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_url_lines(contact: Contact) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
urls = _contact_vcard_urls(contact)
|
||||||
|
if isinstance(urls, list):
|
||||||
|
for url in urls:
|
||||||
|
if isinstance(url, str) and url.strip():
|
||||||
|
lines.append(f"URL:{_escape_text(url.strip())}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_vcard_urls(contact: Contact) -> object:
|
||||||
|
if not isinstance(contact.provenance, dict):
|
||||||
|
return None
|
||||||
|
vcard = contact.provenance.get("vcard")
|
||||||
|
if not isinstance(vcard, dict):
|
||||||
|
return None
|
||||||
|
return vcard.get("urls")
|
||||||
|
|
||||||
|
|
||||||
|
def contacts_to_vcard(
|
||||||
|
contacts: list[Contact],
|
||||||
|
*,
|
||||||
|
version: Literal["3.0", "4.0"] = "4.0",
|
||||||
|
) -> str:
|
||||||
|
return "".join(contact_to_vcard(contact, version=version) for contact in contacts)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
VCardPlanAction = Literal["create", "update", "ignore", "unchanged", "conflict"]
|
||||||
|
VCardCommitAction = Literal["create", "update", "ignore"]
|
||||||
|
|
||||||
|
|
||||||
|
class VCardBatchFilePayload(BaseModel):
|
||||||
|
filename: str = Field(min_length=1, max_length=500)
|
||||||
|
content_base64: str = Field(min_length=1, max_length=14_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
class VCardBatchPreviewRequest(BaseModel):
|
||||||
|
files: list[VCardBatchFilePayload] = Field(min_length=1, max_length=50)
|
||||||
|
duplicate_card_policy: Literal["reject", "first", "last"] = "reject"
|
||||||
|
existing_contact_policy: Literal["update", "ignore", "reject"] = "update"
|
||||||
|
|
||||||
|
|
||||||
|
class VCardDuplicateSuggestion(BaseModel):
|
||||||
|
contact_id: str
|
||||||
|
display_name: str
|
||||||
|
reasons: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class VCardBatchPlanItemResponse(BaseModel):
|
||||||
|
source_key: str
|
||||||
|
source_filename: str
|
||||||
|
card_index: int
|
||||||
|
action: VCardPlanAction
|
||||||
|
allowed_actions: list[VCardCommitAction] = Field(default_factory=list)
|
||||||
|
contact_id: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
changed_fields: list[str] = Field(default_factory=list)
|
||||||
|
duplicate_suggestions: list[VCardDuplicateSuggestion] = Field(default_factory=list)
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VCardBatchDiagnosticResponse(BaseModel):
|
||||||
|
severity: Literal["info", "warning", "error"]
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
source_filename: str | None = None
|
||||||
|
card_index: int | None = None
|
||||||
|
field: str | None = None
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class VCardBatchProgressResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
completed: int
|
||||||
|
created: int = 0
|
||||||
|
updated: int = 0
|
||||||
|
ignored: int = 0
|
||||||
|
failed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class VCardBatchRunResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
address_book_id: str
|
||||||
|
status: str
|
||||||
|
input_hash: str
|
||||||
|
plan_hash: str
|
||||||
|
parser_version: str
|
||||||
|
execution_mode: Literal["bounded_sync", "persisted_batch"]
|
||||||
|
file_count: int
|
||||||
|
card_count: int
|
||||||
|
statistics: dict[str, int | str] = Field(default_factory=dict)
|
||||||
|
diagnostics: list[VCardBatchDiagnosticResponse] = Field(default_factory=list)
|
||||||
|
plan: list[VCardBatchPlanItemResponse] = Field(default_factory=list)
|
||||||
|
progress: VCardBatchProgressResponse
|
||||||
|
can_apply: bool
|
||||||
|
can_cancel: bool
|
||||||
|
commit_hash: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
applied_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VCardBatchSelection(BaseModel):
|
||||||
|
source_key: str = Field(min_length=1, max_length=1000)
|
||||||
|
action: VCardCommitAction
|
||||||
|
|
||||||
|
|
||||||
|
class VCardBatchCommitRequest(BaseModel):
|
||||||
|
expected_plan_hash: str = Field(min_length=64, max_length=64)
|
||||||
|
selections: list[VCardBatchSelection] = Field(
|
||||||
|
default_factory=list, max_length=10_000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VCardBatchCancelRequest(BaseModel):
|
||||||
|
expected_plan_hash: str = Field(min_length=64, max_length=64)
|
||||||
|
reason: str = Field(min_length=3, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class VCardExportRequest(BaseModel):
|
||||||
|
scope: Literal["address_book", "address_list", "contacts"] = "address_book"
|
||||||
|
address_list_id: str | None = Field(default=None, max_length=36)
|
||||||
|
contact_ids: list[str] = Field(default_factory=list, max_length=10_000)
|
||||||
|
version: Literal["3.0", "4.0"] = "4.0"
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_scope(self) -> "VCardExportRequest":
|
||||||
|
if self.scope == "address_list" and not self.address_list_id:
|
||||||
|
raise ValueError("Address-list export requires address_list_id.")
|
||||||
|
if self.scope == "contacts" and not self.contact_ids:
|
||||||
|
raise ValueError(
|
||||||
|
"Selected-contact export requires at least one contact id."
|
||||||
|
)
|
||||||
|
if self.scope != "address_list" and self.address_list_id:
|
||||||
|
raise ValueError("address_list_id is only valid for address-list export.")
|
||||||
|
if self.scope != "contacts" and self.contact_ids:
|
||||||
|
raise ValueError("contact_ids are only valid for selected-contact export.")
|
||||||
|
if len(set(self.contact_ids)) != len(self.contact_ids):
|
||||||
|
raise ValueError("Selected contact ids must be unique.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class VCardExportResponse(BaseModel):
|
||||||
|
filename: str
|
||||||
|
media_type: str = "text/vcard"
|
||||||
|
scope: str
|
||||||
|
version: str
|
||||||
|
ordering: str
|
||||||
|
contact_count: int
|
||||||
|
content_hash: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"VCardBatchCancelRequest",
|
||||||
|
"VCardBatchCommitRequest",
|
||||||
|
"VCardBatchFilePayload",
|
||||||
|
"VCardBatchPreviewRequest",
|
||||||
|
"VCardBatchRunResponse",
|
||||||
|
"VCardBatchSelection",
|
||||||
|
"VCardExportRequest",
|
||||||
|
"VCardExportResponse",
|
||||||
|
]
|
||||||
@@ -0,0 +1,901 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from govoplan_addresses.backend.db.models import (
|
||||||
|
AddressImportRun,
|
||||||
|
AddressListEntry,
|
||||||
|
Contact,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.schemas import (
|
||||||
|
ContactCreateRequest,
|
||||||
|
ContactUpdateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.service import (
|
||||||
|
AddressBookError,
|
||||||
|
create_contact,
|
||||||
|
get_visible_address_book,
|
||||||
|
get_visible_address_list,
|
||||||
|
get_visible_contact,
|
||||||
|
update_contact,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.vcard import (
|
||||||
|
MAX_VCARD_CARDS,
|
||||||
|
VCARD_PARSER_VERSION,
|
||||||
|
contacts_to_vcard,
|
||||||
|
parse_vcards_with_issues,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.vcard_batch_schemas import (
|
||||||
|
VCardBatchCancelRequest,
|
||||||
|
VCardBatchCommitRequest,
|
||||||
|
VCardBatchPreviewRequest,
|
||||||
|
VCardExportRequest,
|
||||||
|
)
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.db.base import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
MAX_VCARD_BATCH_BYTES = 10_000_000
|
||||||
|
DEFAULT_PERSISTED_BATCH_THRESHOLD = 500
|
||||||
|
|
||||||
|
|
||||||
|
def preview_vcard_batch(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
address_book_id: str,
|
||||||
|
payload: VCardBatchPreviewRequest,
|
||||||
|
) -> AddressImportRun:
|
||||||
|
book = get_visible_address_book(session, principal, address_book_id)
|
||||||
|
if book.read_only:
|
||||||
|
raise AddressBookError("Static vCard imports require a writable address book.")
|
||||||
|
|
||||||
|
decoded = _decode_files(payload)
|
||||||
|
input_hash = _hash_json(
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
|
"size": len(raw),
|
||||||
|
}
|
||||||
|
for filename, raw in decoded
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
parsed_cards, diagnostics = _parse_files(decoded)
|
||||||
|
plan = _plan_cards(
|
||||||
|
session,
|
||||||
|
book.id,
|
||||||
|
parsed_cards,
|
||||||
|
duplicate_card_policy=payload.duplicate_card_policy,
|
||||||
|
existing_contact_policy=payload.existing_contact_policy,
|
||||||
|
)
|
||||||
|
statistics: dict[str, int | str] = dict(
|
||||||
|
Counter(str(item["action"]) for item in plan)
|
||||||
|
)
|
||||||
|
statistics.update(
|
||||||
|
{
|
||||||
|
"files": len(decoded),
|
||||||
|
"cards": len(parsed_cards),
|
||||||
|
"errors": sum(item["severity"] == "error" for item in diagnostics),
|
||||||
|
"warnings": sum(item["severity"] == "warning" for item in diagnostics),
|
||||||
|
"parser_version": VCARD_PARSER_VERSION,
|
||||||
|
"execution_mode": _execution_mode(len(parsed_cards)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
plan_hash = _hash_json(
|
||||||
|
{
|
||||||
|
"address_book_id": book.id,
|
||||||
|
"input_hash": input_hash,
|
||||||
|
"parser_version": VCARD_PARSER_VERSION,
|
||||||
|
"duplicate_card_policy": payload.duplicate_card_policy,
|
||||||
|
"existing_contact_policy": payload.existing_contact_policy,
|
||||||
|
"plan": plan,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
source_filename = decoded[0][0]
|
||||||
|
if len(decoded) > 1:
|
||||||
|
source_filename = f"{source_filename} (+{len(decoded) - 1} files)"
|
||||||
|
run = AddressImportRun(
|
||||||
|
tenant_id=book.tenant_id,
|
||||||
|
address_book_id=book.id,
|
||||||
|
profile_id=None,
|
||||||
|
source_filename=source_filename[:500],
|
||||||
|
source_format="vcard",
|
||||||
|
input_hash=input_hash,
|
||||||
|
plan_hash=plan_hash,
|
||||||
|
status="previewed",
|
||||||
|
row_count=len(parsed_cards),
|
||||||
|
statistics=statistics,
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
plan_data=plan,
|
||||||
|
result_evidence={
|
||||||
|
"parser_version": VCARD_PARSER_VERSION,
|
||||||
|
"file_manifest": [
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
|
"size": len(raw),
|
||||||
|
}
|
||||||
|
for filename, raw in decoded
|
||||||
|
],
|
||||||
|
"progress": _progress(len(plan)),
|
||||||
|
},
|
||||||
|
created_by_account_id=principal.account_id,
|
||||||
|
)
|
||||||
|
session.add(run)
|
||||||
|
session.flush()
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def get_vcard_batch_run(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
run_id: str,
|
||||||
|
) -> AddressImportRun:
|
||||||
|
book_ids = [book.id for book in _visible_books(session, principal)]
|
||||||
|
if not book_ids:
|
||||||
|
raise AddressBookError("vCard batch run not found.")
|
||||||
|
item = (
|
||||||
|
session.query(AddressImportRun)
|
||||||
|
.filter(
|
||||||
|
AddressImportRun.id == run_id,
|
||||||
|
AddressImportRun.address_book_id.in_(book_ids),
|
||||||
|
AddressImportRun.source_format == "vcard",
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise AddressBookError("vCard batch run not found.")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def apply_vcard_batch(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
run_id: str,
|
||||||
|
payload: VCardBatchCommitRequest,
|
||||||
|
) -> AddressImportRun:
|
||||||
|
run = get_vcard_batch_run(session, principal, run_id)
|
||||||
|
if run.plan_hash != payload.expected_plan_hash:
|
||||||
|
raise AddressBookError("The reviewed vCard plan changed; create a new preview.")
|
||||||
|
selections = _selection_map(payload)
|
||||||
|
commit_hash = _hash_json(
|
||||||
|
{
|
||||||
|
"plan_hash": run.plan_hash,
|
||||||
|
"selections": [
|
||||||
|
{"source_key": key, "action": selections[key]}
|
||||||
|
for key in sorted(selections)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
evidence = dict(run.result_evidence or {})
|
||||||
|
if run.status == "applied":
|
||||||
|
if evidence.get("commit_hash") != commit_hash:
|
||||||
|
raise AddressBookError(
|
||||||
|
"This vCard batch was already applied with a different selection."
|
||||||
|
)
|
||||||
|
return run
|
||||||
|
if run.status != "previewed":
|
||||||
|
raise AddressBookError(
|
||||||
|
f"vCard batch cannot be applied from status {run.status!r}."
|
||||||
|
)
|
||||||
|
if not selections:
|
||||||
|
raise AddressBookError(
|
||||||
|
"Select at least one vCard action before applying the batch."
|
||||||
|
)
|
||||||
|
|
||||||
|
plan_by_key = {str(item["source_key"]): item for item in run.plan_data or []}
|
||||||
|
unknown = sorted(set(selections).difference(plan_by_key))
|
||||||
|
if unknown:
|
||||||
|
raise AddressBookError(
|
||||||
|
"The selection contains cards that are not part of the reviewed plan."
|
||||||
|
)
|
||||||
|
created_ids: list[str] = []
|
||||||
|
updated_ids: list[str] = []
|
||||||
|
ignored = 0
|
||||||
|
for source_key in sorted(plan_by_key):
|
||||||
|
item = plan_by_key[source_key]
|
||||||
|
action = selections.get(source_key, "ignore")
|
||||||
|
allowed = set(item.get("allowed_actions") or [])
|
||||||
|
if action not in allowed:
|
||||||
|
raise AddressBookError(
|
||||||
|
f'Action {action!r} is not allowed for vCard "{item.get("display_name") or source_key}".'
|
||||||
|
)
|
||||||
|
if action == "ignore":
|
||||||
|
ignored += 1
|
||||||
|
continue
|
||||||
|
contact = _apply_plan_item(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
run=run,
|
||||||
|
item=item,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
if action == "create":
|
||||||
|
created_ids.append(contact.id)
|
||||||
|
else:
|
||||||
|
updated_ids.append(contact.id)
|
||||||
|
|
||||||
|
run.status = "applied"
|
||||||
|
run.applied_at = utcnow()
|
||||||
|
run.result_evidence = {
|
||||||
|
**evidence,
|
||||||
|
"commit_hash": commit_hash,
|
||||||
|
"selection_count": len(selections),
|
||||||
|
"created_contact_ids": created_ids,
|
||||||
|
"updated_contact_ids": updated_ids,
|
||||||
|
"ignored_count": ignored,
|
||||||
|
"applied_by_account_id": principal.account_id,
|
||||||
|
"applied_at": run.applied_at.isoformat(),
|
||||||
|
"progress": {
|
||||||
|
"total": len(plan_by_key),
|
||||||
|
"completed": len(plan_by_key),
|
||||||
|
"created": len(created_ids),
|
||||||
|
"updated": len(updated_ids),
|
||||||
|
"ignored": ignored,
|
||||||
|
"failed": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
run.statistics = {
|
||||||
|
**dict(run.statistics or {}),
|
||||||
|
"applied_create": len(created_ids),
|
||||||
|
"applied_update": len(updated_ids),
|
||||||
|
"applied_ignore": ignored,
|
||||||
|
}
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_vcard_batch(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
run_id: str,
|
||||||
|
payload: VCardBatchCancelRequest,
|
||||||
|
) -> AddressImportRun:
|
||||||
|
run = get_vcard_batch_run(session, principal, run_id)
|
||||||
|
if run.plan_hash != payload.expected_plan_hash:
|
||||||
|
raise AddressBookError("The reviewed vCard plan changed; reload the batch.")
|
||||||
|
if run.status == "cancelled":
|
||||||
|
return run
|
||||||
|
if run.status != "previewed":
|
||||||
|
raise AddressBookError("Only a previewed vCard batch can be cancelled.")
|
||||||
|
run.status = "cancelled"
|
||||||
|
run.result_evidence = {
|
||||||
|
**dict(run.result_evidence or {}),
|
||||||
|
"cancel_reason": payload.reason.strip(),
|
||||||
|
"cancelled_by_account_id": principal.account_id,
|
||||||
|
"cancelled_at": utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def vcard_batch_payload(run: AddressImportRun) -> dict[str, Any]:
|
||||||
|
evidence = dict(run.result_evidence or {})
|
||||||
|
progress = dict(evidence.get("progress") or _progress(run.row_count))
|
||||||
|
return {
|
||||||
|
"id": run.id,
|
||||||
|
"address_book_id": run.address_book_id,
|
||||||
|
"status": run.status,
|
||||||
|
"input_hash": run.input_hash,
|
||||||
|
"plan_hash": run.plan_hash,
|
||||||
|
"parser_version": str(evidence.get("parser_version") or VCARD_PARSER_VERSION),
|
||||||
|
"execution_mode": str(
|
||||||
|
(run.statistics or {}).get("execution_mode") or "bounded_sync"
|
||||||
|
),
|
||||||
|
"file_count": int((run.statistics or {}).get("files") or 0),
|
||||||
|
"card_count": run.row_count,
|
||||||
|
"statistics": dict(run.statistics or {}),
|
||||||
|
"diagnostics": list(run.diagnostics or []),
|
||||||
|
"plan": [_public_plan_item(item) for item in run.plan_data or []],
|
||||||
|
"progress": progress,
|
||||||
|
"can_apply": run.status == "previewed" and bool(run.plan_data),
|
||||||
|
"can_cancel": run.status == "previewed",
|
||||||
|
"commit_hash": evidence.get("commit_hash"),
|
||||||
|
"created_at": run.created_at,
|
||||||
|
"updated_at": run.updated_at,
|
||||||
|
"applied_at": run.applied_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def vcard_diagnostics_payload(run: AddressImportRun) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"run_id": run.id,
|
||||||
|
"status": run.status,
|
||||||
|
"input_hash": run.input_hash,
|
||||||
|
"plan_hash": run.plan_hash,
|
||||||
|
"parser_version": (run.result_evidence or {}).get("parser_version"),
|
||||||
|
"statistics": dict(run.statistics or {}),
|
||||||
|
"diagnostics": list(run.diagnostics or []),
|
||||||
|
"effects": [_public_plan_item(item) for item in run.plan_data or []],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def export_vcards(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
address_book_id: str,
|
||||||
|
payload: VCardExportRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
book = get_visible_address_book(session, principal, address_book_id)
|
||||||
|
contacts = _export_contacts(session, principal, book.id, payload)
|
||||||
|
contacts.sort(key=lambda item: (item.display_name.casefold(), item.id))
|
||||||
|
content = contacts_to_vcard(contacts, version=payload.version)
|
||||||
|
scope_label = {
|
||||||
|
"address_book": book.name,
|
||||||
|
"address_list": "address-list",
|
||||||
|
"contacts": "selected-contacts",
|
||||||
|
}[payload.scope]
|
||||||
|
return {
|
||||||
|
"filename": f"{_safe_filename(scope_label)}-{payload.version.replace('.', '')}.vcf",
|
||||||
|
"media_type": "text/vcard",
|
||||||
|
"scope": payload.scope,
|
||||||
|
"version": payload.version,
|
||||||
|
"ordering": "display_name_casefold_then_contact_id",
|
||||||
|
"contact_count": len(contacts),
|
||||||
|
"content_hash": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_files(payload: VCardBatchPreviewRequest) -> list[tuple[str, bytes]]:
|
||||||
|
decoded: list[tuple[str, bytes]] = []
|
||||||
|
total = 0
|
||||||
|
for item in payload.files:
|
||||||
|
filename = item.filename.strip()
|
||||||
|
if not filename.casefold().endswith(".vcf"):
|
||||||
|
raise AddressBookError("vCard batch uploads accept only .vcf files.")
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(item.content_base64, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
raise AddressBookError(
|
||||||
|
f'vCard file "{filename}" is not valid base64.'
|
||||||
|
) from exc
|
||||||
|
if not raw:
|
||||||
|
raise AddressBookError(f'vCard file "{filename}" is empty.')
|
||||||
|
total += len(raw)
|
||||||
|
if total > MAX_VCARD_BATCH_BYTES:
|
||||||
|
raise AddressBookError(
|
||||||
|
f"Combined vCard uploads are limited to {MAX_VCARD_BATCH_BYTES} bytes."
|
||||||
|
)
|
||||||
|
decoded.append((filename, raw))
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_files(
|
||||||
|
decoded: list[tuple[str, bytes]],
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
cards: list[dict[str, Any]] = []
|
||||||
|
diagnostics: list[dict[str, Any]] = []
|
||||||
|
for file_index, (filename, raw) in enumerate(decoded):
|
||||||
|
try:
|
||||||
|
content = raw.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise AddressBookError(
|
||||||
|
f'vCard file "{filename}" is not valid UTF-8: {exc}.'
|
||||||
|
) from exc
|
||||||
|
remaining = MAX_VCARD_CARDS - len(cards)
|
||||||
|
if remaining < 1:
|
||||||
|
raise AddressBookError(
|
||||||
|
f"vCard batches are limited to {MAX_VCARD_CARDS} cards."
|
||||||
|
)
|
||||||
|
result = parse_vcards_with_issues(content, max_cards=remaining)
|
||||||
|
for issue in result.issues:
|
||||||
|
diagnostics.append(
|
||||||
|
{
|
||||||
|
"severity": issue.severity,
|
||||||
|
"code": "vcard_parse_error"
|
||||||
|
if issue.severity == "error"
|
||||||
|
else "vcard_parse_warning",
|
||||||
|
"message": issue.message,
|
||||||
|
"source_filename": filename,
|
||||||
|
"card_index": issue.index or None,
|
||||||
|
"field": issue.field,
|
||||||
|
"details": {"line": issue.line} if issue.line is not None else {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for card_index, parsed in enumerate(result.cards, start=1):
|
||||||
|
raw_hash = hashlib.sha256(parsed.raw.encode("utf-8")).hexdigest()
|
||||||
|
identity = (
|
||||||
|
f"uid:{parsed.source_ref.strip()}"
|
||||||
|
if parsed.source_ref and parsed.source_ref.strip()
|
||||||
|
else f"sha256:{raw_hash}"
|
||||||
|
)
|
||||||
|
source_key = hashlib.sha256(
|
||||||
|
f"{file_index}:{filename}:{card_index}:{raw_hash}".encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
cards.append(
|
||||||
|
{
|
||||||
|
"source_key": source_key,
|
||||||
|
"source_identity": identity,
|
||||||
|
"source_filename": filename,
|
||||||
|
"card_index": card_index,
|
||||||
|
"raw": parsed.raw,
|
||||||
|
"source_ref": parsed.source_ref.strip()
|
||||||
|
if parsed.source_ref
|
||||||
|
else None,
|
||||||
|
"source_revision": parsed.source_revision.strip()
|
||||||
|
if parsed.source_revision
|
||||||
|
else None,
|
||||||
|
"payload": parsed.payload.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return cards, diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_cards(
|
||||||
|
session: Session,
|
||||||
|
address_book_id: str,
|
||||||
|
cards: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
duplicate_card_policy: str,
|
||||||
|
existing_contact_policy: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
contacts = (
|
||||||
|
session.query(Contact)
|
||||||
|
.options(
|
||||||
|
selectinload(Contact.emails),
|
||||||
|
selectinload(Contact.phones),
|
||||||
|
selectinload(Contact.postal_addresses),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
Contact.address_book_id == address_book_id, Contact.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_source: dict[str, list[Contact]] = defaultdict(list)
|
||||||
|
by_email: dict[str, list[Contact]] = defaultdict(list)
|
||||||
|
for contact in contacts:
|
||||||
|
if contact.source_ref:
|
||||||
|
by_source[contact.source_ref.strip()].append(contact)
|
||||||
|
for email in contact.emails:
|
||||||
|
normalized = (email.normalized_email or email.email).strip().casefold()
|
||||||
|
if normalized:
|
||||||
|
by_email[normalized].append(contact)
|
||||||
|
|
||||||
|
identity_positions: dict[str, list[int]] = defaultdict(list)
|
||||||
|
for index, card in enumerate(cards):
|
||||||
|
identity_positions[str(card["source_identity"])].append(index)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for index, card in enumerate(cards):
|
||||||
|
positions = identity_positions[str(card["source_identity"])]
|
||||||
|
if len(positions) > 1:
|
||||||
|
chosen = positions[0] if duplicate_card_policy == "first" else positions[-1]
|
||||||
|
if duplicate_card_policy == "reject":
|
||||||
|
result.append(
|
||||||
|
_planned_card(
|
||||||
|
card,
|
||||||
|
action="conflict",
|
||||||
|
allowed=["ignore"],
|
||||||
|
message="Duplicate UID or identical card appears in this batch.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if index != chosen:
|
||||||
|
result.append(
|
||||||
|
_planned_card(
|
||||||
|
card,
|
||||||
|
action="ignore",
|
||||||
|
allowed=["ignore"],
|
||||||
|
message=f"Duplicate card ignored by {duplicate_card_policy} policy.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
suggestions = _duplicate_candidates(
|
||||||
|
card, by_source=by_source, by_email=by_email
|
||||||
|
)
|
||||||
|
exact_source = [item for item in suggestions if "source_uid" in item["reasons"]]
|
||||||
|
candidates = exact_source or suggestions
|
||||||
|
if len(candidates) > 1:
|
||||||
|
result.append(
|
||||||
|
_planned_card(
|
||||||
|
card,
|
||||||
|
action="conflict",
|
||||||
|
allowed=["create", "ignore"],
|
||||||
|
suggestions=suggestions,
|
||||||
|
message="Multiple existing contacts match this card; create explicitly or ignore it.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
existing = next(
|
||||||
|
(
|
||||||
|
contact
|
||||||
|
for contact in contacts
|
||||||
|
if candidates and contact.id == candidates[0]["contact_id"]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
result.append(
|
||||||
|
_planned_card(
|
||||||
|
card,
|
||||||
|
action="create",
|
||||||
|
allowed=["create", "ignore"],
|
||||||
|
suggestions=suggestions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
changed = _changed_fields(existing, card["payload"])
|
||||||
|
if not changed:
|
||||||
|
result.append(
|
||||||
|
_planned_card(
|
||||||
|
card,
|
||||||
|
action="unchanged",
|
||||||
|
allowed=["ignore"],
|
||||||
|
contact=existing,
|
||||||
|
suggestions=suggestions,
|
||||||
|
message="Existing contact already matches the parsed card.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif existing_contact_policy == "reject":
|
||||||
|
result.append(
|
||||||
|
_planned_card(
|
||||||
|
card,
|
||||||
|
action="conflict",
|
||||||
|
allowed=["ignore"],
|
||||||
|
contact=existing,
|
||||||
|
suggestions=suggestions,
|
||||||
|
changed=changed,
|
||||||
|
message="An existing contact matches and the preview policy rejects updates.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif existing_contact_policy == "ignore":
|
||||||
|
result.append(
|
||||||
|
_planned_card(
|
||||||
|
card,
|
||||||
|
action="ignore",
|
||||||
|
allowed=["update", "ignore"],
|
||||||
|
contact=existing,
|
||||||
|
suggestions=suggestions,
|
||||||
|
changed=changed,
|
||||||
|
message="Existing contact is ignored by preview policy.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result.append(
|
||||||
|
_planned_card(
|
||||||
|
card,
|
||||||
|
action="update",
|
||||||
|
allowed=["update", "ignore"],
|
||||||
|
contact=existing,
|
||||||
|
suggestions=suggestions,
|
||||||
|
changed=changed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_card(
|
||||||
|
card: dict[str, Any],
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
allowed: list[str],
|
||||||
|
contact: Contact | None = None,
|
||||||
|
suggestions: list[dict[str, Any]] | None = None,
|
||||||
|
changed: list[str] | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
**card,
|
||||||
|
"row_number": int(card["card_index"]),
|
||||||
|
"action": action,
|
||||||
|
"allowed_actions": allowed,
|
||||||
|
"contact_id": contact.id if contact is not None else None,
|
||||||
|
"expected_contact_hash": _contact_hash(contact)
|
||||||
|
if contact is not None
|
||||||
|
else None,
|
||||||
|
"display_name": card["payload"].get("display_name"),
|
||||||
|
"changed_fields": changed or [],
|
||||||
|
"duplicate_suggestions": suggestions or [],
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_candidates(
|
||||||
|
card: dict[str, Any],
|
||||||
|
*,
|
||||||
|
by_source: dict[str, list[Contact]],
|
||||||
|
by_email: dict[str, list[Contact]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
reasons: dict[str, set[str]] = defaultdict(set)
|
||||||
|
contacts: dict[str, Contact] = {}
|
||||||
|
source_ref = card.get("source_ref")
|
||||||
|
if source_ref:
|
||||||
|
for contact in by_source.get(str(source_ref), []):
|
||||||
|
contacts[contact.id] = contact
|
||||||
|
reasons[contact.id].add("source_uid")
|
||||||
|
for item in card["payload"].get("emails") or []:
|
||||||
|
normalized = str(item.get("email") or "").strip().casefold()
|
||||||
|
for contact in by_email.get(normalized, []):
|
||||||
|
contacts[contact.id] = contact
|
||||||
|
reasons[contact.id].add("email")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"contact_id": contact_id,
|
||||||
|
"display_name": contacts[contact_id].display_name,
|
||||||
|
"reasons": sorted(reasons[contact_id]),
|
||||||
|
}
|
||||||
|
for contact_id in sorted(
|
||||||
|
contacts, key=lambda item: (contacts[item].display_name.casefold(), item)
|
||||||
|
)[:5]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_plan_item(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
run: AddressImportRun,
|
||||||
|
item: dict[str, Any],
|
||||||
|
action: str,
|
||||||
|
) -> Contact:
|
||||||
|
contact_payload = ContactCreateRequest.model_validate(item["payload"])
|
||||||
|
if action == "create":
|
||||||
|
if item.get("source_ref"):
|
||||||
|
appeared = (
|
||||||
|
session.query(Contact)
|
||||||
|
.filter(
|
||||||
|
Contact.address_book_id == run.address_book_id,
|
||||||
|
Contact.source_ref == item["source_ref"],
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if appeared is not None:
|
||||||
|
raise AddressBookError(
|
||||||
|
"A matching vCard UID appeared after preview; preview the batch again."
|
||||||
|
)
|
||||||
|
contact = create_contact(
|
||||||
|
session, principal, run.address_book_id, contact_payload
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
contact_id = str(item.get("contact_id") or "")
|
||||||
|
if not contact_id:
|
||||||
|
raise AddressBookError(
|
||||||
|
"The reviewed vCard update has no stable target contact."
|
||||||
|
)
|
||||||
|
current = get_visible_contact(session, principal, contact_id)
|
||||||
|
if _contact_hash(current) != item.get("expected_contact_hash"):
|
||||||
|
raise AddressBookError(
|
||||||
|
f'Contact "{current.display_name}" changed after preview; preview the batch again.'
|
||||||
|
)
|
||||||
|
contact = update_contact(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
current.id,
|
||||||
|
ContactUpdateRequest.model_validate(item["payload"]),
|
||||||
|
)
|
||||||
|
contact.source_kind = "vcard"
|
||||||
|
contact.source_ref = (
|
||||||
|
item.get("source_ref")
|
||||||
|
or f"vcard-sha256:{str(item['source_identity']).split(':', 1)[-1]}"
|
||||||
|
)
|
||||||
|
contact.source_payload_kind = "vcard"
|
||||||
|
contact.source_payload_raw = item["raw"]
|
||||||
|
contact.source_revision = item.get("source_revision")
|
||||||
|
provenance = dict(contact.provenance or {})
|
||||||
|
provenance["vcard_batch"] = {
|
||||||
|
"run_id": run.id,
|
||||||
|
"input_hash": run.input_hash,
|
||||||
|
"plan_hash": run.plan_hash,
|
||||||
|
"parser_version": VCARD_PARSER_VERSION,
|
||||||
|
"source_filename": item["source_filename"],
|
||||||
|
"card_index": item["card_index"],
|
||||||
|
}
|
||||||
|
contact.provenance = provenance
|
||||||
|
session.flush()
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_fields(contact: Contact, payload: dict[str, Any]) -> list[str]:
|
||||||
|
current = _contact_projection(contact)
|
||||||
|
incoming = _payload_projection(payload)
|
||||||
|
return sorted(key for key in incoming if current.get(key) != incoming.get(key))
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_hash(contact: Contact) -> str:
|
||||||
|
return _hash_json(_contact_projection(contact))
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_projection(contact: Contact) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"display_name": contact.display_name,
|
||||||
|
"given_name": contact.given_name,
|
||||||
|
"family_name": contact.family_name,
|
||||||
|
"organization": contact.organization,
|
||||||
|
"role_title": contact.role_title,
|
||||||
|
"note": contact.note,
|
||||||
|
"tags": list(contact.tags or []),
|
||||||
|
"emails": [
|
||||||
|
{"label": item.label, "email": item.email, "is_primary": item.is_primary}
|
||||||
|
for item in contact.emails
|
||||||
|
],
|
||||||
|
"phones": [
|
||||||
|
{"label": item.label, "phone": item.phone, "is_primary": item.is_primary}
|
||||||
|
for item in contact.phones
|
||||||
|
],
|
||||||
|
"postal_addresses": [
|
||||||
|
{
|
||||||
|
"label": item.label,
|
||||||
|
"street": item.street,
|
||||||
|
"postal_code": item.postal_code,
|
||||||
|
"locality": item.locality,
|
||||||
|
"region": item.region,
|
||||||
|
"country": item.country,
|
||||||
|
"is_primary": item.is_primary,
|
||||||
|
}
|
||||||
|
for item in contact.postal_addresses
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_projection(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
key: payload.get(key)
|
||||||
|
for key in (
|
||||||
|
"display_name",
|
||||||
|
"given_name",
|
||||||
|
"family_name",
|
||||||
|
"organization",
|
||||||
|
"role_title",
|
||||||
|
"note",
|
||||||
|
"tags",
|
||||||
|
"emails",
|
||||||
|
"phones",
|
||||||
|
"postal_addresses",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _selection_map(payload: VCardBatchCommitRequest) -> dict[str, str]:
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for selection in payload.selections:
|
||||||
|
if selection.source_key in result:
|
||||||
|
raise AddressBookError("Each vCard may be selected only once.")
|
||||||
|
result[selection.source_key] = selection.action
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _public_plan_item(item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
key: item.get(key)
|
||||||
|
for key in (
|
||||||
|
"source_key",
|
||||||
|
"source_filename",
|
||||||
|
"card_index",
|
||||||
|
"action",
|
||||||
|
"allowed_actions",
|
||||||
|
"contact_id",
|
||||||
|
"display_name",
|
||||||
|
"changed_fields",
|
||||||
|
"duplicate_suggestions",
|
||||||
|
"message",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _export_contacts(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
address_book_id: str,
|
||||||
|
payload: VCardExportRequest,
|
||||||
|
) -> list[Contact]:
|
||||||
|
if payload.scope == "address_book":
|
||||||
|
return _loaded_contacts(session, address_book_id=address_book_id)
|
||||||
|
if payload.scope == "contacts":
|
||||||
|
contacts = [
|
||||||
|
get_visible_contact(session, principal, contact_id)
|
||||||
|
for contact_id in payload.contact_ids
|
||||||
|
]
|
||||||
|
if any(contact.address_book_id != address_book_id for contact in contacts):
|
||||||
|
raise AddressBookError(
|
||||||
|
"Every selected contact must belong to the exported address book."
|
||||||
|
)
|
||||||
|
return contacts
|
||||||
|
address_list = get_visible_address_list(
|
||||||
|
session, principal, str(payload.address_list_id)
|
||||||
|
)
|
||||||
|
if address_list.address_book_id != address_book_id:
|
||||||
|
raise AddressBookError(
|
||||||
|
"The selected address list does not belong to the exported address book."
|
||||||
|
)
|
||||||
|
contact_ids = [
|
||||||
|
item.contact_id
|
||||||
|
for item in (
|
||||||
|
session.query(AddressListEntry)
|
||||||
|
.filter(AddressListEntry.address_list_id == address_list.id)
|
||||||
|
.order_by(AddressListEntry.order_index.asc(), AddressListEntry.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not contact_ids:
|
||||||
|
return []
|
||||||
|
return _loaded_contacts(
|
||||||
|
session, address_book_id=address_book_id, contact_ids=set(contact_ids)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _loaded_contacts(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
address_book_id: str,
|
||||||
|
contact_ids: set[str] | None = None,
|
||||||
|
) -> list[Contact]:
|
||||||
|
query = (
|
||||||
|
session.query(Contact)
|
||||||
|
.options(
|
||||||
|
selectinload(Contact.emails),
|
||||||
|
selectinload(Contact.phones),
|
||||||
|
selectinload(Contact.postal_addresses),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
Contact.address_book_id == address_book_id, Contact.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if contact_ids is not None:
|
||||||
|
query = query.filter(Contact.id.in_(contact_ids))
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_books(session: Session, principal: ApiPrincipal):
|
||||||
|
from govoplan_addresses.backend.service import list_address_books
|
||||||
|
|
||||||
|
return list_address_books(session, principal)
|
||||||
|
|
||||||
|
|
||||||
|
def _execution_mode(card_count: int) -> str:
|
||||||
|
raw = os.getenv(
|
||||||
|
"GOVOPLAN_ADDRESSES_VCARD_JOB_THRESHOLD", str(DEFAULT_PERSISTED_BATCH_THRESHOLD)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
threshold = max(1, min(MAX_VCARD_CARDS, int(raw)))
|
||||||
|
except ValueError:
|
||||||
|
threshold = DEFAULT_PERSISTED_BATCH_THRESHOLD
|
||||||
|
return "persisted_batch" if card_count >= threshold else "bounded_sync"
|
||||||
|
|
||||||
|
|
||||||
|
def _progress(total: int) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"completed": 0,
|
||||||
|
"created": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"ignored": 0,
|
||||||
|
"failed": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_json(value: Any) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(value: str) -> str:
|
||||||
|
safe = "".join(
|
||||||
|
character if character.isalnum() or character in {"-", "_"} else "-"
|
||||||
|
for character in value.strip()
|
||||||
|
)
|
||||||
|
return safe.strip("-")[:120] or "contacts"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"apply_vcard_batch",
|
||||||
|
"cancel_vcard_batch",
|
||||||
|
"export_vcards",
|
||||||
|
"get_vcard_batch_run",
|
||||||
|
"preview_vcard_batch",
|
||||||
|
"vcard_batch_payload",
|
||||||
|
"vcard_diagnostics_payload",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
|
||||||
|
from govoplan_addresses.backend.carddav import (
|
||||||
|
AddressCardDAVClient,
|
||||||
|
AddressCardDAVError,
|
||||||
|
absolute_dav_url,
|
||||||
|
urllib_transport,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.router import api_discover_carddav_address_books
|
||||||
|
from govoplan_addresses.backend.schemas import AddressCardDavDiscoveryRequest
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
host, port = server.server_address
|
||||||
|
yield f"http://{host}:{port}"
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
class CardDAVUrlSecurityTests(unittest.TestCase):
|
||||||
|
def test_transport_revalidates_dns_at_connection_time(self) -> None:
|
||||||
|
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
|
||||||
|
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
|
||||||
|
), patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
side_effect=(public, private),
|
||||||
|
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
|
||||||
|
AddressCardDAVError,
|
||||||
|
"non-public network",
|
||||||
|
):
|
||||||
|
urllib_transport("GET", "https://dav.example.test/contact.vcf", {}, None, 2)
|
||||||
|
socket_factory.assert_not_called()
|
||||||
|
|
||||||
|
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
|
||||||
|
base_url = "https://dav.example.test/addressbooks/ada/"
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
absolute_dav_url(base_url, "/principals/users/ada/"),
|
||||||
|
"https://dav.example.test/principals/users/ada/",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "configured collection origin"):
|
||||||
|
absolute_dav_url(base_url, "https://evil.example.test/steal/")
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "query or fragment"):
|
||||||
|
absolute_dav_url(base_url, "/principals/users/ada/?token=secret")
|
||||||
|
|
||||||
|
def test_object_href_must_remain_inside_configured_collection(self) -> None:
|
||||||
|
client = AddressCardDAVClient(collection_url="https://dav.example.test/addressbooks/ada")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "collection origin"):
|
||||||
|
client.object_url("https://evil.example.test/steal.vcf")
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "collection path"):
|
||||||
|
client.object_url("https://dav.example.test/addressbooks/other/steal.vcf")
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "collection path"):
|
||||||
|
client.object_url("/addressbooks/ada/%2e%2e/other/steal.vcf")
|
||||||
|
with self.assertRaisesRegex(AddressCardDAVError, "query or fragment"):
|
||||||
|
client.object_url("/addressbooks/ada/contact.vcf?download=1")
|
||||||
|
self.assertEqual(
|
||||||
|
client.object_url("/addressbooks/ada/contact.vcf"),
|
||||||
|
"https://dav.example.test/addressbooks/ada/contact.vcf",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_transport_refuses_redirect_before_forwarding_authorization(self) -> None:
|
||||||
|
forwarded_authorization: list[str | None] = []
|
||||||
|
|
||||||
|
class TargetHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
|
forwarded_authorization.append(self.headers.get("Authorization"))
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
with running_http_server(TargetHandler) as target_url:
|
||||||
|
class RedirectHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", f"{target_url}/stolen.vcf")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
with running_http_server(RedirectHandler) as redirect_url:
|
||||||
|
status, _headers, _body = urllib_transport(
|
||||||
|
"GET",
|
||||||
|
f"{redirect_url}/contact.vcf",
|
||||||
|
{"Authorization": "Bearer top-secret"},
|
||||||
|
None,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(status, 302)
|
||||||
|
self.assertEqual(forwarded_authorization, [])
|
||||||
|
|
||||||
|
def test_transport_preserves_same_origin_redirects(self) -> None:
|
||||||
|
forwarded_authorization: list[str | None] = []
|
||||||
|
|
||||||
|
class RedirectHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
|
if self.path == "/contact.vcf":
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", "/redirected.vcf")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
forwarded_authorization.append(self.headers.get("Authorization"))
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"contact")
|
||||||
|
|
||||||
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
with running_http_server(RedirectHandler) as source_url:
|
||||||
|
status, _headers, body = urllib_transport(
|
||||||
|
"GET",
|
||||||
|
f"{source_url}/contact.vcf",
|
||||||
|
{"Authorization": "Bearer expected"},
|
||||||
|
None,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body, b"contact")
|
||||||
|
self.assertEqual(forwarded_authorization, ["Bearer expected"])
|
||||||
|
|
||||||
|
|
||||||
|
class CardDAVDiscoveryAuthorizationTests(unittest.TestCase):
|
||||||
|
def test_sync_read_alone_cannot_start_authenticated_discovery(self) -> None:
|
||||||
|
principal = ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-read-only",
|
||||||
|
membership_id="membership-read-only",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset({"addresses:sync:read"}),
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("govoplan_addresses.backend.router.discover_carddav_address_books") as discover:
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
api_discover_carddav_address_books(
|
||||||
|
AddressCardDavDiscoveryRequest(
|
||||||
|
url="https://dav.example.test/",
|
||||||
|
auth_type="basic",
|
||||||
|
username="reader",
|
||||||
|
password="secret",
|
||||||
|
),
|
||||||
|
principal,
|
||||||
|
object(), # type: ignore[arg-type] - scope rejection precedes session use
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 403)
|
||||||
|
self.assertEqual(raised.exception.detail, "Missing scope: addresses:sync:write")
|
||||||
|
discover.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, User
|
||||||
|
from govoplan_addresses.backend.db.models import (
|
||||||
|
AddressBook,
|
||||||
|
AddressImportRun,
|
||||||
|
AddressList,
|
||||||
|
AddressListEntry,
|
||||||
|
AddressSyncConflict,
|
||||||
|
AddressSyncSource,
|
||||||
|
AddressSyncTombstone,
|
||||||
|
Contact,
|
||||||
|
ContactChannelRule,
|
||||||
|
ContactEmail,
|
||||||
|
ContactFieldProvenance,
|
||||||
|
ContactMergeRecord,
|
||||||
|
ContactPhone,
|
||||||
|
ContactPointQualityDecision,
|
||||||
|
ContactPostalAddress,
|
||||||
|
ContactRedirect,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.dsar_provider import (
|
||||||
|
ADDRESSES_DSAR_CAPABILITY,
|
||||||
|
AddressesDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.manifest import manifest
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: AddressesDsarProvider,
|
||||||
|
*,
|
||||||
|
addresses_active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.addresses_active = addresses_active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (ADDRESSES_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "addresses"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
addresses_active = self.addresses_active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": (("addresses",) if addresses_active else ())},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "addresses"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != ADDRESSES_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class AddressesDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
self.account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="subject@example.test",
|
||||||
|
normalized_email="subject@example.test",
|
||||||
|
display_name="Subject",
|
||||||
|
password_hash="password-secret-do-not-export",
|
||||||
|
)
|
||||||
|
self.user = User(
|
||||||
|
id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=self.account.id,
|
||||||
|
email=self.account.email,
|
||||||
|
display_name="Subject",
|
||||||
|
)
|
||||||
|
self.book = AddressBook(
|
||||||
|
id="book-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Residents",
|
||||||
|
created_by_account_id=self.account.id,
|
||||||
|
metadata_={"secret": "book-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.contact = Contact(
|
||||||
|
id="contact-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
display_name="Subject Person",
|
||||||
|
given_name="Subject",
|
||||||
|
family_name="Person",
|
||||||
|
organization="Example household",
|
||||||
|
note="A bounded subject note",
|
||||||
|
tags=["resident"],
|
||||||
|
source_kind="carddav",
|
||||||
|
source_ref="https://source.invalid/private/contact.vcf",
|
||||||
|
source_payload_kind="vcard",
|
||||||
|
source_payload_raw="raw-source-payload-do-not-export",
|
||||||
|
source_revision="revision-7",
|
||||||
|
provenance={"secret": "contact-provenance-do-not-export"},
|
||||||
|
created_by_account_id=self.account.id,
|
||||||
|
metadata_={"secret": "contact-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.email = ContactEmail(
|
||||||
|
id="email-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
label="private",
|
||||||
|
email="Subject@Example.test",
|
||||||
|
original_email="Subject@Example.test",
|
||||||
|
normalized_email="subject@example.test",
|
||||||
|
provenance={"secret": "email-provenance-do-not-export"},
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
self.phone = ContactPhone(
|
||||||
|
id="phone-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
label="mobile",
|
||||||
|
phone="+49 30 123456",
|
||||||
|
original_phone="030 123456",
|
||||||
|
normalized_phone="+4930123456",
|
||||||
|
provenance={"secret": "phone-provenance-do-not-export"},
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
self.postal = ContactPostalAddress(
|
||||||
|
id="postal-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
label="home",
|
||||||
|
street="Example Street 1",
|
||||||
|
postal_code="10115",
|
||||||
|
locality="Berlin",
|
||||||
|
country="DE",
|
||||||
|
original_value={"secret": "postal-original-do-not-export"},
|
||||||
|
normalized_value={"secret": "postal-normalized-do-not-export"},
|
||||||
|
provenance={"secret": "postal-provenance-do-not-export"},
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
self.address_list = AddressList(
|
||||||
|
id="list-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
name="District residents",
|
||||||
|
created_by_account_id="another-account",
|
||||||
|
)
|
||||||
|
self.list_entry = AddressListEntry(
|
||||||
|
id="entry-1",
|
||||||
|
address_list_id=self.address_list.id,
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
contact_email_id=self.email.id,
|
||||||
|
target_kind="email",
|
||||||
|
metadata_={"secret": "list-entry-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.channel_rule = ContactChannelRule(
|
||||||
|
id="rule-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
channel="email",
|
||||||
|
purpose="resident-notice",
|
||||||
|
contact_point_id=self.email.id,
|
||||||
|
decision="allow",
|
||||||
|
legal_basis="public task",
|
||||||
|
evidence_ref="records://consent/evidence-1",
|
||||||
|
reason="Current resident preference",
|
||||||
|
effective_from=now,
|
||||||
|
created_by_account_id="another-account",
|
||||||
|
metadata_={"secret": "rule-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.quality = ContactPointQualityDecision(
|
||||||
|
id="quality-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
channel="email",
|
||||||
|
contact_point_id=self.email.id,
|
||||||
|
state="valid",
|
||||||
|
reason_code="verified",
|
||||||
|
reason="Verified by operator",
|
||||||
|
evidence_ref="files://private/evidence",
|
||||||
|
effective_from=now,
|
||||||
|
created_by_account_id="another-account",
|
||||||
|
metadata_={"secret": "quality-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.provenance = ContactFieldProvenance(
|
||||||
|
id="provenance-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
field_path="emails[0].email",
|
||||||
|
value={"secret": "field-value-do-not-export"},
|
||||||
|
source_kind="carddav",
|
||||||
|
source_ref="https://source.invalid/private",
|
||||||
|
source_revision="revision-7",
|
||||||
|
precedence=10,
|
||||||
|
selected=True,
|
||||||
|
reason_code="source_authority",
|
||||||
|
explanation="Selected from the authoritative source",
|
||||||
|
visibility="operator",
|
||||||
|
created_by_account_id="another-account",
|
||||||
|
metadata_={"secret": "field-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.sync_source = AddressSyncSource(
|
||||||
|
id="source-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
connector_type="carddav",
|
||||||
|
display_name="Residents CardDAV",
|
||||||
|
external_account_ref="private-account-ref-do-not-export",
|
||||||
|
external_address_book_ref="private-book-ref-do-not-export",
|
||||||
|
sync_token="sync-token-do-not-export",
|
||||||
|
etag="private-etag-do-not-export",
|
||||||
|
remote_revision="private-remote-revision-do-not-export",
|
||||||
|
last_diagnostic={"secret": "diagnostic-do-not-export"},
|
||||||
|
created_by_account_id=self.account.id,
|
||||||
|
metadata_={"secret": "source-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.tombstone = AddressSyncTombstone(
|
||||||
|
id="tombstone-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
sync_source_id=self.sync_source.id,
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
remote_uid="private-uid-do-not-export",
|
||||||
|
resource_href="private-href-do-not-export",
|
||||||
|
synced_at=now,
|
||||||
|
metadata_={"secret": "tombstone-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.conflict = AddressSyncConflict(
|
||||||
|
id="conflict-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
sync_source_id=self.sync_source.id,
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
contact_id=self.contact.id,
|
||||||
|
remote_uid="private-conflict-uid-do-not-export",
|
||||||
|
resource_href="private-conflict-href-do-not-export",
|
||||||
|
field_path="family_name",
|
||||||
|
local_value={"secret": "local-value-do-not-export"},
|
||||||
|
remote_value={"secret": "remote-value-do-not-export"},
|
||||||
|
status="resolved",
|
||||||
|
resolution="local",
|
||||||
|
resolved_at=now,
|
||||||
|
resolved_by_account_id=self.account.id,
|
||||||
|
metadata_={"secret": "conflict-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
self.import_run = AddressImportRun(
|
||||||
|
id="import-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
source_filename="contacts.csv",
|
||||||
|
source_format="csv",
|
||||||
|
input_hash="a" * 64,
|
||||||
|
plan_hash="b" * 64,
|
||||||
|
status="applied",
|
||||||
|
row_count=1,
|
||||||
|
statistics={"secret": "statistics-do-not-export"},
|
||||||
|
diagnostics=[{"secret": "import-diagnostic-do-not-export"}],
|
||||||
|
plan_data=[{"secret": "import-plan-do-not-export"}],
|
||||||
|
result_evidence={"secret": "import-result-do-not-export"},
|
||||||
|
created_by_account_id=self.account.id,
|
||||||
|
applied_at=now,
|
||||||
|
)
|
||||||
|
self.merge = ContactMergeRecord(
|
||||||
|
id="merge-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
winner_contact_id=self.contact.id,
|
||||||
|
loser_contact_ids=["old-contact-1"],
|
||||||
|
status="active",
|
||||||
|
reason="Duplicate contact",
|
||||||
|
survivorship={"secret": "survivorship-do-not-export"},
|
||||||
|
decisions=[{"secret": "merge-decisions-do-not-export"}],
|
||||||
|
before_payload={"secret": "merge-before-do-not-export"},
|
||||||
|
after_payload={"secret": "merge-after-do-not-export"},
|
||||||
|
before_hash="c" * 64,
|
||||||
|
after_hash="d" * 64,
|
||||||
|
created_by_account_id="another-account",
|
||||||
|
provenance={"secret": "merge-provenance-do-not-export"},
|
||||||
|
)
|
||||||
|
self.redirect = ContactRedirect(
|
||||||
|
id="redirect-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
source_contact_id="old-contact-1",
|
||||||
|
target_contact_id=self.contact.id,
|
||||||
|
merge_record_id=self.merge.id,
|
||||||
|
)
|
||||||
|
self.unrelated = Contact(
|
||||||
|
id="contact-unrelated",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
display_name="Unrelated Person",
|
||||||
|
note="unrelated-person-do-not-export",
|
||||||
|
)
|
||||||
|
unrelated_email = ContactEmail(
|
||||||
|
id="email-unrelated",
|
||||||
|
contact_id=self.unrelated.id,
|
||||||
|
email="unrelated@example.test",
|
||||||
|
original_email="unrelated@example.test",
|
||||||
|
normalized_email="unrelated@example.test",
|
||||||
|
)
|
||||||
|
tenant_two_book = AddressBook(
|
||||||
|
id="book-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-2",
|
||||||
|
name="Other tenant",
|
||||||
|
)
|
||||||
|
tenant_two_contact = Contact(
|
||||||
|
id="contact-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
address_book_id=tenant_two_book.id,
|
||||||
|
display_name="Other Tenant Subject",
|
||||||
|
note="other-tenant-do-not-export",
|
||||||
|
)
|
||||||
|
tenant_two_email = ContactEmail(
|
||||||
|
id="email-tenant-2",
|
||||||
|
contact_id=tenant_two_contact.id,
|
||||||
|
email="subject@example.test",
|
||||||
|
original_email="subject@example.test",
|
||||||
|
normalized_email="subject@example.test",
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
self.account,
|
||||||
|
self.user,
|
||||||
|
self.book,
|
||||||
|
self.contact,
|
||||||
|
self.email,
|
||||||
|
self.phone,
|
||||||
|
self.postal,
|
||||||
|
self.address_list,
|
||||||
|
self.list_entry,
|
||||||
|
self.channel_rule,
|
||||||
|
self.quality,
|
||||||
|
self.provenance,
|
||||||
|
self.sync_source,
|
||||||
|
self.tombstone,
|
||||||
|
self.conflict,
|
||||||
|
self.import_run,
|
||||||
|
self.merge,
|
||||||
|
self.redirect,
|
||||||
|
self.unrelated,
|
||||||
|
unrelated_email,
|
||||||
|
tenant_two_book,
|
||||||
|
tenant_two_contact,
|
||||||
|
tenant_two_email,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = AddressesDsarProvider()
|
||||||
|
self.subject = DsarSubjectRef(
|
||||||
|
account_id=self.account.id,
|
||||||
|
email=self.account.email,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
|
||||||
|
provided_names = {item.name for item in manifest.provides_interfaces}
|
||||||
|
self.assertIn(ADDRESSES_DSAR_CAPABILITY, provided_names)
|
||||||
|
provider = manifest.capability_factories[ADDRESSES_DSAR_CAPABILITY](None)
|
||||||
|
self.assertIsInstance(provider, DsarProvider)
|
||||||
|
|
||||||
|
def test_search_is_tenant_scoped_related_and_minimized(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
|
||||||
|
resource_types = {record.resource_type for record in records}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"addresses_contact",
|
||||||
|
"addresses_contact_email",
|
||||||
|
"addresses_contact_phone",
|
||||||
|
"addresses_contact_postal_address",
|
||||||
|
"addresses_list_membership",
|
||||||
|
"addresses_channel_rule",
|
||||||
|
"addresses_quality_decision",
|
||||||
|
"addresses_field_provenance",
|
||||||
|
"addresses_merge_record",
|
||||||
|
"addresses_contact_redirect",
|
||||||
|
"addresses_sync_tombstone",
|
||||||
|
"addresses_sync_conflict",
|
||||||
|
"addresses_address_book_attribution",
|
||||||
|
"addresses_sync_source_attribution",
|
||||||
|
"addresses_import_run_attribution",
|
||||||
|
}.issubset(resource_types)
|
||||||
|
)
|
||||||
|
serialized = repr([record.to_dict() for record in records])
|
||||||
|
excluded_values = (
|
||||||
|
"password-secret-do-not-export",
|
||||||
|
"raw-source-payload-do-not-export",
|
||||||
|
"contact-provenance-do-not-export",
|
||||||
|
"book-metadata-do-not-export",
|
||||||
|
"field-value-do-not-export",
|
||||||
|
"sync-token-do-not-export",
|
||||||
|
"private-account-ref-do-not-export",
|
||||||
|
"private-remote-revision-do-not-export",
|
||||||
|
"local-value-do-not-export",
|
||||||
|
"remote-value-do-not-export",
|
||||||
|
"import-plan-do-not-export",
|
||||||
|
"merge-before-do-not-export",
|
||||||
|
"merge-after-do-not-export",
|
||||||
|
"unrelated-person-do-not-export",
|
||||||
|
"other-tenant-do-not-export",
|
||||||
|
)
|
||||||
|
for value in excluded_values:
|
||||||
|
self.assertNotIn(value, serialized)
|
||||||
|
|
||||||
|
def test_conflicting_selectors_and_uncorroborated_reference_fail_closed(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
email="subject@example.test",
|
||||||
|
external_references={"addresses.email": "other@example.test"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
uncorroborated = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
email="subject@example.test",
|
||||||
|
external_references={"addresses.contact": self.unrelated.id},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
self.assertEqual((), uncorroborated)
|
||||||
|
|
||||||
|
def test_plan_retains_evidence_and_routes_contact_data_to_review(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
|
||||||
|
kinds = {action.kind for action in actions}
|
||||||
|
self.assertEqual({"manual_review", "retain"}, kinds)
|
||||||
|
self.assertFalse(any(action.executable for action in actions))
|
||||||
|
retained = [action for action in actions if action.kind == "retain"]
|
||||||
|
self.assertTrue(retained)
|
||||||
|
self.assertTrue(all(action.rationale for action in retained))
|
||||||
|
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-addresses-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"blocked"}, {result.status for result in results})
|
||||||
|
self.assertIsNotNone(self.session.get(Contact, self.contact.id))
|
||||||
|
|
||||||
|
def test_execution_rejects_foreign_or_forged_executable_actions(self) -> None:
|
||||||
|
foreign = DsarErasureActionRef(
|
||||||
|
action_id="mail:delete:contact:contact-1",
|
||||||
|
provider_id="mail",
|
||||||
|
module_id="mail",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="addresses_contact",
|
||||||
|
resource_id=self.contact.id,
|
||||||
|
title="Foreign delete",
|
||||||
|
rationale="Must be rejected",
|
||||||
|
executable=True,
|
||||||
|
)
|
||||||
|
forged = DsarErasureActionRef(
|
||||||
|
action_id="addresses:delete:addresses_contact:contact-1",
|
||||||
|
provider_id="addresses",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="addresses_contact",
|
||||||
|
resource_id=self.contact.id,
|
||||||
|
title="Forged delete",
|
||||||
|
rationale="Must be rejected",
|
||||||
|
executable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=(foreign,),
|
||||||
|
request_id="dsar-addresses-2",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=(forged,),
|
||||||
|
request_id="dsar-addresses-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_discovers_active_and_inactive_provider(self) -> None:
|
||||||
|
request = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-ADDRESSES-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Respond to an authorized privacy request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=request,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual("searched", request.status)
|
||||||
|
self.assertEqual(["addresses"], request.coverage["covered_modules"])
|
||||||
|
self.assertEqual([], request.coverage["modules_without_provider"])
|
||||||
|
|
||||||
|
disabled = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-ADDRESSES-DISABLED",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Verify disabled-module coverage.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, addresses_active=False),
|
||||||
|
row=disabled,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, disabled.search_result["record_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
[ADDRESSES_DSAR_CAPABILITY],
|
||||||
|
disabled.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_addresses.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class AddressesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
german = (topic.translations or {}).get("de", {})
|
||||||
|
self.assertEqual(
|
||||||
|
{"title", "summary", "body"},
|
||||||
|
set(german),
|
||||||
|
topic.id,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(str(value).strip() for value in german.values()), topic.id
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
self.assertEqual({"/address-book"}, {item.path for item in frontend.routes}) # type: ignore[union-attr]
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"addresses.page",
|
||||||
|
"addresses.sources",
|
||||||
|
"addresses.contacts",
|
||||||
|
"addresses.detail",
|
||||||
|
"addresses.governance",
|
||||||
|
"addresses.sync",
|
||||||
|
},
|
||||||
|
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_help_and_consequence_metadata_remain_published(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
boundary = topics["addresses.boundary"]
|
||||||
|
governance = topics["addresses.contact-point-resolution"]
|
||||||
|
reference = topics["addresses.reference.fields-and-consequences"]
|
||||||
|
|
||||||
|
self.assertIn("addresses.state.read-only", boundary.metadata["help_contexts"])
|
||||||
|
self.assertIn(
|
||||||
|
"addresses.field.communication-purpose",
|
||||||
|
governance.metadata["help_contexts"],
|
||||||
|
)
|
||||||
|
self.assertIn("addresses.action.sync", reference.metadata["help_contexts"])
|
||||||
|
self.assertIn("merge", reference.metadata["consequence_classes"])
|
||||||
|
self.assertIn("governance_fact", reference.metadata["consequence_classes"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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,208 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_addresses.backend.db.models import AddressBook, Contact
|
||||||
|
from govoplan_addresses.backend.import_schemas import (
|
||||||
|
AddressImportConfiguration,
|
||||||
|
AddressImportPreviewRequest,
|
||||||
|
AddressImportProfileCreateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.imports import (
|
||||||
|
apply_address_import,
|
||||||
|
create_import_profile,
|
||||||
|
import_run_payload,
|
||||||
|
preview_address_import,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.ldif import parse_ldif_rows
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Principal:
|
||||||
|
account_id = "account-1"
|
||||||
|
group_ids = frozenset()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tenant_id(self) -> str:
|
||||||
|
return "tenant-1"
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in {
|
||||||
|
"addresses:address_book:read",
|
||||||
|
"addresses:address_book:write",
|
||||||
|
"addresses:contact:read",
|
||||||
|
"addresses:contact:write",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def encoded(value: bytes | str) -> str:
|
||||||
|
raw = value.encode() if isinstance(value, str) else value
|
||||||
|
return base64.b64encode(raw).decode()
|
||||||
|
|
||||||
|
|
||||||
|
class AddressLdifImportTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
self.session = sessionmaker(bind=engine, expire_on_commit=False)()
|
||||||
|
self.principal = Principal()
|
||||||
|
self.book = AddressBook(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Imported contacts",
|
||||||
|
source_kind="local",
|
||||||
|
read_only=False,
|
||||||
|
)
|
||||||
|
self.session.add(self.book)
|
||||||
|
|
||||||
|
def create_profile(self, **configuration_overrides):
|
||||||
|
configuration = AddressImportConfiguration(
|
||||||
|
field_mappings={
|
||||||
|
"source_key": "dn",
|
||||||
|
"display_name": "cn",
|
||||||
|
"given_name": "givenName",
|
||||||
|
"family_name": "sn",
|
||||||
|
"email": "mail",
|
||||||
|
"phone": "telephoneNumber",
|
||||||
|
"organization": "o",
|
||||||
|
},
|
||||||
|
**configuration_overrides,
|
||||||
|
)
|
||||||
|
profile = create_import_profile(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressImportProfileCreateRequest(
|
||||||
|
scope_type="tenant",
|
||||||
|
name="Directory export",
|
||||||
|
source_format="ldif",
|
||||||
|
configuration=configuration,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def test_parser_unfolds_and_decodes_text_without_projecting_binary_or_urls(self) -> None:
|
||||||
|
rows, diagnostics = parse_ldif_rows(
|
||||||
|
b"version: 1\n\n"
|
||||||
|
b"# exported contact\n"
|
||||||
|
b"dn: uid=ada,ou=people,dc=example,dc=test\n"
|
||||||
|
b"cn:: QWRhIExvdmVsYWNl\n"
|
||||||
|
b"sn: Love\n"
|
||||||
|
b" lace\n"
|
||||||
|
b"mail: ada@example.test\n"
|
||||||
|
b"mail: ada.work@example.test\n"
|
||||||
|
b"jpegPhoto:: /9j/\n"
|
||||||
|
b"seeAlso:< https://example.test/contact/ada\n",
|
||||||
|
max_entries=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(rows))
|
||||||
|
self.assertEqual(["Ada Lovelace"], rows[0][1]["cn"])
|
||||||
|
self.assertEqual(["Lovelace"], rows[0][1]["sn"])
|
||||||
|
self.assertEqual(["ada@example.test", "ada.work@example.test"], rows[0][1]["mail"])
|
||||||
|
self.assertNotIn("jpegphoto", rows[0][1])
|
||||||
|
self.assertNotIn("seealso", rows[0][1])
|
||||||
|
self.assertEqual(
|
||||||
|
{"ldif_binary_value_ignored", "ldif_url_value_ignored"},
|
||||||
|
{item["code"] for item in diagnostics},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_preview_apply_and_repeat_preserve_multivalue_provenance(self) -> None:
|
||||||
|
profile = self.create_profile(default_tags=["ldif"])
|
||||||
|
source = (
|
||||||
|
"dn: uid=ada,ou=people,dc=example,dc=test\n"
|
||||||
|
"cn: Ada Lovelace\n"
|
||||||
|
"givenName: Ada\n"
|
||||||
|
"sn: Lovelace\n"
|
||||||
|
"mail: ada@example.test\n"
|
||||||
|
"mail: ada.work@example.test\n"
|
||||||
|
"telephoneNumber: +49 30 123\n"
|
||||||
|
"o: Analysis Office\n"
|
||||||
|
)
|
||||||
|
request = AddressImportPreviewRequest(
|
||||||
|
profile_id=profile.id,
|
||||||
|
filename="contacts.ldif",
|
||||||
|
content_base64=encoded(source),
|
||||||
|
)
|
||||||
|
run = preview_address_import(self.session, self.principal, self.book.id, request)
|
||||||
|
self.assertEqual(1, run.statistics["create"])
|
||||||
|
self.assertFalse([item for item in run.diagnostics if item["severity"] == "error"])
|
||||||
|
|
||||||
|
apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash)
|
||||||
|
contact = self.session.query(Contact).one()
|
||||||
|
self.assertEqual(["ada@example.test", "ada.work@example.test"], [item.email for item in contact.emails])
|
||||||
|
self.assertEqual("ldif", contact.source_kind)
|
||||||
|
self.assertEqual("ldif", contact.provenance["import"]["source_format"])
|
||||||
|
self.assertEqual(64, len(contact.provenance["import"]["source_record_hash"]))
|
||||||
|
self.assertNotIn(source, repr(import_run_payload(run)))
|
||||||
|
|
||||||
|
repeated = preview_address_import(self.session, self.principal, self.book.id, request)
|
||||||
|
self.assertEqual(1, repeated.statistics["unchanged"])
|
||||||
|
apply_address_import(self.session, self.principal, repeated.id, expected_plan_hash=repeated.plan_hash)
|
||||||
|
self.assertEqual(1, self.session.query(Contact).count())
|
||||||
|
|
||||||
|
def test_change_records_are_rejected_by_default_and_add_requires_explicit_policy(self) -> None:
|
||||||
|
source = (
|
||||||
|
"dn: uid=ada,ou=people,dc=example,dc=test\n"
|
||||||
|
"changetype: add\n"
|
||||||
|
"cn: Ada Lovelace\n"
|
||||||
|
"mail: ada@example.test\n"
|
||||||
|
)
|
||||||
|
rejected_profile = self.create_profile()
|
||||||
|
rejected = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=rejected_profile.id,
|
||||||
|
filename="changes.ldif",
|
||||||
|
content_base64=encoded(source),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(0, rejected.row_count)
|
||||||
|
self.assertIn("ldif_change_record_rejected", {item["code"] for item in rejected.diagnostics})
|
||||||
|
self.assertFalse(import_run_payload(rejected)["can_apply"])
|
||||||
|
|
||||||
|
allowed_profile = self.create_profile(ldif_change_record_policy="treat_add_as_entry")
|
||||||
|
allowed = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=allowed_profile.id,
|
||||||
|
filename="changes.ldif",
|
||||||
|
content_base64=encoded(source),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, allowed.statistics["create"])
|
||||||
|
self.assertIn("ldif_add_record_imported", {item["code"] for item in allowed.diagnostics})
|
||||||
|
|
||||||
|
def test_invalid_base64_is_a_correction_diagnostic_and_blocks_apply(self) -> None:
|
||||||
|
profile = self.create_profile()
|
||||||
|
run = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=profile.id,
|
||||||
|
filename="broken.ldif",
|
||||||
|
content_base64=encoded(
|
||||||
|
"dn: uid=ada,dc=example,dc=test\n"
|
||||||
|
"cn:: this-is-not-base64!\n"
|
||||||
|
"mail: ada@example.test\n"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertIn("ldif_invalid_base64", {item["code"] for item in run.diagnostics})
|
||||||
|
self.assertFalse(import_run_payload(run)["can_apply"])
|
||||||
|
with self.assertRaisesRegex(ValueError, "error diagnostics"):
|
||||||
|
apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
from govoplan_addresses.backend.db.models import Base
|
||||||
|
from govoplan_addresses.backend.manifest import get_manifest
|
||||||
|
from govoplan_core.db.migrations import migrate_database
|
||||||
|
|
||||||
|
|
||||||
|
class AddressesMigrationTests(unittest.TestCase):
|
||||||
|
def test_schema_identifiers_fit_postgresql_limit(self) -> None:
|
||||||
|
overlong_indexes = sorted(
|
||||||
|
index.name
|
||||||
|
for table in Base.metadata.tables.values()
|
||||||
|
if table.name.startswith("addresses_")
|
||||||
|
for index in table.indexes
|
||||||
|
if index.name and len(index.name) > 63
|
||||||
|
)
|
||||||
|
self.assertEqual([], overlong_indexes)
|
||||||
|
|
||||||
|
def test_fresh_database_reaches_import_profile_head(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-addresses-migration-") as directory:
|
||||||
|
url = f"sqlite:///{Path(directory) / 'addresses.db'}"
|
||||||
|
migrate_database(
|
||||||
|
database_url=url,
|
||||||
|
enabled_modules=("addresses",),
|
||||||
|
manifest_factories=(get_manifest,),
|
||||||
|
)
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
self.assertIn(
|
||||||
|
"d6e8f9a0b1c2",
|
||||||
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
|
)
|
||||||
|
tables = set(inspect(connection).get_table_names())
|
||||||
|
self.assertIn("addresses_import_profiles", tables)
|
||||||
|
self.assertIn("addresses_import_runs", tables)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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,309 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
from govoplan_addresses.backend.db.models import AddressBook, Contact
|
||||||
|
from govoplan_addresses.backend.import_schemas import (
|
||||||
|
AddressImportConfiguration,
|
||||||
|
AddressImportPreviewRequest,
|
||||||
|
AddressImportProfileCreateRequest,
|
||||||
|
AddressImportProfileUpdateRequest,
|
||||||
|
AddressImportRollbackRequest,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.imports import (
|
||||||
|
apply_address_import,
|
||||||
|
create_import_profile,
|
||||||
|
get_import_run,
|
||||||
|
import_run_payload,
|
||||||
|
preview_address_import,
|
||||||
|
rollback_address_import,
|
||||||
|
update_import_profile,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Principal:
|
||||||
|
account_id = "account-1"
|
||||||
|
group_ids = frozenset({"group-1"})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tenant_id(self) -> str:
|
||||||
|
return "tenant-1"
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in {
|
||||||
|
"addresses:address_book:read",
|
||||||
|
"addresses:address_book:write",
|
||||||
|
"addresses:contact:read",
|
||||||
|
"addresses:contact:write",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OtherTenantPrincipal(Principal):
|
||||||
|
@property
|
||||||
|
def tenant_id(self) -> str:
|
||||||
|
return "tenant-2"
|
||||||
|
|
||||||
|
|
||||||
|
def encoded(value: str) -> str:
|
||||||
|
return base64.b64encode(value.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
class AddressTabularImportTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
self.session = sessionmaker(bind=engine, expire_on_commit=False)()
|
||||||
|
self.principal = Principal()
|
||||||
|
self.book = AddressBook(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Imported contacts",
|
||||||
|
source_kind="local",
|
||||||
|
read_only=False,
|
||||||
|
)
|
||||||
|
self.session.add(self.book)
|
||||||
|
self.profile = create_import_profile(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressImportProfileCreateRequest(
|
||||||
|
scope_type="tenant",
|
||||||
|
name="Monthly contacts",
|
||||||
|
source_format="csv",
|
||||||
|
configuration=AddressImportConfiguration(
|
||||||
|
delimiter=";",
|
||||||
|
field_mappings={
|
||||||
|
"source_key": "id",
|
||||||
|
"given_name": "first",
|
||||||
|
"family_name": "last",
|
||||||
|
"email": "email",
|
||||||
|
"organization": "organization",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
def test_preview_apply_repeat_and_guarded_rollback(self) -> None:
|
||||||
|
payload = AddressImportPreviewRequest(
|
||||||
|
profile_id=self.profile.id,
|
||||||
|
filename="contacts.csv",
|
||||||
|
content_base64=encoded(
|
||||||
|
"id;first;last;email;organization\n"
|
||||||
|
"1;Ada;Lovelace;ada@example.test;Analysis Office\n"
|
||||||
|
"2;Grace;Hopper;grace@example.test;Computing Office\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
run = preview_address_import(self.session, self.principal, self.book.id, payload)
|
||||||
|
self.assertEqual(2, run.statistics["create"])
|
||||||
|
self.assertFalse(run.diagnostics)
|
||||||
|
|
||||||
|
applied = apply_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
run.id,
|
||||||
|
expected_plan_hash=run.plan_hash,
|
||||||
|
)
|
||||||
|
self.assertEqual("applied", applied.status)
|
||||||
|
self.assertEqual(2, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
|
||||||
|
self.assertIs(applied, apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash))
|
||||||
|
response_evidence = import_run_payload(applied)["result_evidence"]
|
||||||
|
self.assertEqual(2, response_evidence["created_contact_count"])
|
||||||
|
self.assertNotIn("created_contact_ids", response_evidence)
|
||||||
|
self.assertNotIn("updated_contacts", response_evidence)
|
||||||
|
|
||||||
|
repeated = preview_address_import(self.session, self.principal, self.book.id, payload)
|
||||||
|
self.assertEqual(2, repeated.statistics["unchanged"])
|
||||||
|
apply_address_import(self.session, self.principal, repeated.id, expected_plan_hash=repeated.plan_hash)
|
||||||
|
self.assertEqual(2, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
|
||||||
|
|
||||||
|
rolled_back = rollback_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
run.id,
|
||||||
|
AddressImportRollbackRequest(
|
||||||
|
expected_plan_hash=run.plan_hash,
|
||||||
|
reason="The operator selected the wrong monthly file.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual("rolled_back", rolled_back.status)
|
||||||
|
self.assertEqual(0, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
|
||||||
|
|
||||||
|
def test_rollback_rejects_a_stale_review_hash(self) -> None:
|
||||||
|
run = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=self.profile.id,
|
||||||
|
filename="contacts.csv",
|
||||||
|
content_base64=encoded(
|
||||||
|
"id;first;last;email;organization\n"
|
||||||
|
"1;Ada;Lovelace;ada@example.test;Analysis Office\n"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
apply_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
run.id,
|
||||||
|
expected_plan_hash=run.plan_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "reviewed import plan changed"):
|
||||||
|
rollback_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
run.id,
|
||||||
|
AddressImportRollbackRequest(
|
||||||
|
expected_plan_hash="0" * 64,
|
||||||
|
reason="The operator selected the wrong monthly file.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_persisted_run_read_is_tenant_bounded_and_source_safe(self) -> None:
|
||||||
|
source = "id;first;last;email;organization\n1;Ada;Lovelace;ada@example.test;Analysis Office\n"
|
||||||
|
run = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=self.profile.id,
|
||||||
|
filename="contacts.csv",
|
||||||
|
content_base64=encoded(source),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
payload = import_run_payload(get_import_run(self.session, self.principal, run.id))
|
||||||
|
self.assertEqual("previewed", payload["status"])
|
||||||
|
self.assertEqual(run.plan_hash, payload["plan_hash"])
|
||||||
|
self.assertNotIn("plan_data", payload)
|
||||||
|
self.assertNotIn(source, repr(payload))
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "not found"):
|
||||||
|
get_import_run(self.session, OtherTenantPrincipal(), run.id)
|
||||||
|
|
||||||
|
def test_duplicate_keys_and_changed_targets_block_apply(self) -> None:
|
||||||
|
duplicate = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=self.profile.id,
|
||||||
|
filename="duplicates.csv",
|
||||||
|
content_base64=encoded(
|
||||||
|
"id;first;last;email;organization\n"
|
||||||
|
"1;Ada;Lovelace;ada@example.test;One\n"
|
||||||
|
"1;Ada;Lovelace;ada@example.test;Two\n"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(2, duplicate.statistics["conflict"])
|
||||||
|
with self.assertRaisesRegex(ValueError, "error diagnostics"):
|
||||||
|
apply_address_import(self.session, self.principal, duplicate.id, expected_plan_hash=duplicate.plan_hash)
|
||||||
|
|
||||||
|
initial = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=self.profile.id,
|
||||||
|
filename="one.csv",
|
||||||
|
content_base64=encoded("id;first;last;email;organization\n1;Ada;Lovelace;ada@example.test;One\n"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
apply_address_import(self.session, self.principal, initial.id, expected_plan_hash=initial.plan_hash)
|
||||||
|
changed = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=self.profile.id,
|
||||||
|
filename="one.csv",
|
||||||
|
content_base64=encoded("id;first;last;email;organization\n1;Ada;Lovelace;ada@example.test;Two\n"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
contact = self.session.query(Contact).filter(Contact.deleted_at.is_(None)).one()
|
||||||
|
contact.organization = "Concurrent edit"
|
||||||
|
self.session.flush()
|
||||||
|
with self.assertRaisesRegex(ValueError, "changed after preview"):
|
||||||
|
apply_address_import(self.session, self.principal, changed.id, expected_plan_hash=changed.plan_hash)
|
||||||
|
|
||||||
|
def test_profile_updates_create_immutable_versions(self) -> None:
|
||||||
|
next_profile = update_import_profile(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.profile.id,
|
||||||
|
payload=AddressImportProfileUpdateRequest(name="Monthly contacts v2"),
|
||||||
|
)
|
||||||
|
self.assertFalse(self.profile.is_current)
|
||||||
|
self.assertTrue(next_profile.is_current)
|
||||||
|
self.assertEqual(self.profile.profile_key, next_profile.profile_key)
|
||||||
|
self.assertEqual(2, next_profile.version)
|
||||||
|
|
||||||
|
def test_xlsx_sheet_selection_and_formula_rejection(self) -> None:
|
||||||
|
workbook = Workbook()
|
||||||
|
workbook.active.title = "Ignore"
|
||||||
|
sheet = workbook.create_sheet("Contacts")
|
||||||
|
sheet.append(["id", "first", "last", "email", "organization"])
|
||||||
|
sheet.append(["1", "Ada", "Lovelace", "ada@example.test", "Analysis Office"])
|
||||||
|
content = BytesIO()
|
||||||
|
workbook.save(content)
|
||||||
|
xlsx_profile = create_import_profile(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressImportProfileCreateRequest(
|
||||||
|
scope_type="tenant",
|
||||||
|
name="Workbook contacts",
|
||||||
|
source_format="xlsx",
|
||||||
|
configuration=AddressImportConfiguration(
|
||||||
|
sheet_name="Contacts",
|
||||||
|
field_mappings={
|
||||||
|
"source_key": "id",
|
||||||
|
"given_name": "first",
|
||||||
|
"family_name": "last",
|
||||||
|
"email": "email",
|
||||||
|
"organization": "organization",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
run = preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=xlsx_profile.id,
|
||||||
|
filename="contacts.xlsx",
|
||||||
|
content_base64=base64.b64encode(content.getvalue()).decode(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, run.statistics["create"])
|
||||||
|
|
||||||
|
sheet["E2"] = "=CONCAT(\"Analysis\", \" Office\")"
|
||||||
|
content = BytesIO()
|
||||||
|
workbook.save(content)
|
||||||
|
with self.assertRaisesRegex(ValueError, "formulas are never evaluated"):
|
||||||
|
preview_address_import(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=xlsx_profile.id,
|
||||||
|
filename="contacts.xlsx",
|
||||||
|
content_base64=base64.b64encode(content.getvalue()).decode(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_addresses.backend.db.models import Contact, ContactEmail, ContactPhone, ContactPostalAddress
|
||||||
|
from govoplan_addresses.backend.vcard import contact_to_vcard, parse_vcards_with_issues
|
||||||
|
|
||||||
|
|
||||||
|
class VCardTests(unittest.TestCase):
|
||||||
|
def test_parse_vcard_preserves_folded_and_escaped_text(self) -> None:
|
||||||
|
result = parse_vcards_with_issues(
|
||||||
|
"BEGIN:VCARD\r\n"
|
||||||
|
"VERSION:4.0\r\n"
|
||||||
|
"UID:contact-1\r\n"
|
||||||
|
"FN:Ada \r\n"
|
||||||
|
" Lovelace\r\n"
|
||||||
|
"N:Lovelace;Ada;;;\r\n"
|
||||||
|
"NOTE:Line one\\nLine two\r\n"
|
||||||
|
"CATEGORIES:science\\,history,engineering\r\n"
|
||||||
|
"EMAIL;TYPE=work;PREF=1:ada@example.local\r\n"
|
||||||
|
"ADR;TYPE=work:;;Main Street 1;Berlin;BE;10115;Germany\r\n"
|
||||||
|
"URL:https://example.local/ada\r\n"
|
||||||
|
"END:VCARD\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([], result.issues)
|
||||||
|
self.assertEqual(1, len(result.cards))
|
||||||
|
payload = result.cards[0].payload
|
||||||
|
self.assertEqual("Ada Lovelace", payload.display_name)
|
||||||
|
self.assertEqual("Line one\nLine two", payload.note)
|
||||||
|
self.assertEqual(["science,history", "engineering"], payload.tags)
|
||||||
|
self.assertEqual("work", payload.emails[0].label)
|
||||||
|
self.assertTrue(payload.emails[0].is_primary)
|
||||||
|
self.assertEqual("Berlin", payload.postal_addresses[0].locality)
|
||||||
|
self.assertEqual(["https://example.local/ada"], payload.provenance["vcard"]["urls"])
|
||||||
|
|
||||||
|
def test_contact_to_vcard_escapes_text_and_filters_urls(self) -> None:
|
||||||
|
contact = Contact(
|
||||||
|
address_book_id="book-1",
|
||||||
|
display_name="Ada Lovelace",
|
||||||
|
given_name="Ada",
|
||||||
|
family_name="Lovelace",
|
||||||
|
organization="Analytical Engine Office",
|
||||||
|
role_title="Mathematician",
|
||||||
|
note="Line one\nLine two",
|
||||||
|
tags=["science,history", "engineering"],
|
||||||
|
source_ref="contact-1",
|
||||||
|
source_revision="rev-1",
|
||||||
|
provenance={"vcard": {"urls": [" https://example.local/ada ", "", 42]}},
|
||||||
|
)
|
||||||
|
contact.emails = [ContactEmail(label="work", email="ada@example.local", is_primary=True, order_index=0)]
|
||||||
|
contact.phones = [ContactPhone(label="work", phone="+49 30 123", is_primary=True, order_index=0)]
|
||||||
|
contact.postal_addresses = [
|
||||||
|
ContactPostalAddress(
|
||||||
|
label="work",
|
||||||
|
street="Main Street 1",
|
||||||
|
locality="Berlin",
|
||||||
|
region="BE",
|
||||||
|
postal_code="10115",
|
||||||
|
country="Germany",
|
||||||
|
is_primary=True,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
content = contact_to_vcard(contact)
|
||||||
|
|
||||||
|
self.assertIn("UID:contact-1\r\n", content)
|
||||||
|
self.assertIn("REV:rev-1\r\n", content)
|
||||||
|
self.assertIn("NOTE:Line one\\nLine two\r\n", content)
|
||||||
|
self.assertIn("CATEGORIES:science\\,history,engineering\r\n", content)
|
||||||
|
self.assertIn("EMAIL;TYPE=work:ada@example.local\r\n", content)
|
||||||
|
self.assertIn("URL:https://example.local/ada\r\n", content)
|
||||||
|
self.assertNotIn("URL:42", content)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_addresses.backend.db.models import (
|
||||||
|
AddressBook,
|
||||||
|
AddressList,
|
||||||
|
AddressListEntry,
|
||||||
|
Contact,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.schemas import ContactCreateRequest
|
||||||
|
from govoplan_addresses.backend.service import AddressBookError, create_contact
|
||||||
|
from govoplan_addresses.backend.vcard import (
|
||||||
|
MAX_VCARD_UNFOLDED_LINE_CHARS,
|
||||||
|
parse_vcards_with_issues,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.vcard_batch_schemas import (
|
||||||
|
VCardBatchCancelRequest,
|
||||||
|
VCardBatchCommitRequest,
|
||||||
|
VCardBatchFilePayload,
|
||||||
|
VCardBatchPreviewRequest,
|
||||||
|
VCardBatchSelection,
|
||||||
|
VCardExportRequest,
|
||||||
|
)
|
||||||
|
from govoplan_addresses.backend.vcard_batches import (
|
||||||
|
apply_vcard_batch,
|
||||||
|
cancel_vcard_batch,
|
||||||
|
export_vcards,
|
||||||
|
preview_vcard_batch,
|
||||||
|
vcard_batch_payload,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Principal:
|
||||||
|
account_id = "account-1"
|
||||||
|
group_ids = frozenset()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tenant_id(self) -> str:
|
||||||
|
return "tenant-1"
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in {
|
||||||
|
"addresses:address_book:read",
|
||||||
|
"addresses:address_book:write",
|
||||||
|
"addresses:contact:read",
|
||||||
|
"addresses:contact:write",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def vcard(uid: str, name: str, email: str) -> str:
|
||||||
|
return (
|
||||||
|
"BEGIN:VCARD\r\n"
|
||||||
|
"VERSION:4.0\r\n"
|
||||||
|
f"UID:{uid}\r\n"
|
||||||
|
f"FN:{name}\r\n"
|
||||||
|
f"EMAIL:{email}\r\n"
|
||||||
|
"END:VCARD\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def batch_file(filename: str, content: str) -> VCardBatchFilePayload:
|
||||||
|
return VCardBatchFilePayload(
|
||||||
|
filename=filename,
|
||||||
|
content_base64=base64.b64encode(content.encode()).decode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VCardBatchTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
self.session = sessionmaker(bind=engine, expire_on_commit=False)()
|
||||||
|
self.principal = Principal()
|
||||||
|
self.book = AddressBook(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Batch contacts",
|
||||||
|
source_kind="local",
|
||||||
|
read_only=False,
|
||||||
|
)
|
||||||
|
self.session.add(self.book)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
def test_multifile_preview_selective_apply_and_repeat_are_idempotent(self) -> None:
|
||||||
|
run = preview_vcard_batch(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
VCardBatchPreviewRequest(
|
||||||
|
files=[
|
||||||
|
batch_file(
|
||||||
|
"ada.vcf", vcard("ada-1", "Ada Lovelace", "ada@example.test")
|
||||||
|
),
|
||||||
|
batch_file(
|
||||||
|
"grace.vcf",
|
||||||
|
vcard("grace-1", "Grace Hopper", "grace@example.test"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, self.session.query(Contact).count())
|
||||||
|
self.assertEqual("previewed", run.status)
|
||||||
|
self.assertEqual(2, run.row_count)
|
||||||
|
self.assertEqual("govoplan-vcard/2", run.result_evidence["parser_version"])
|
||||||
|
self.assertNotIn("ada@example.test", repr(vcard_batch_payload(run)))
|
||||||
|
|
||||||
|
selections = [
|
||||||
|
VCardBatchSelection(
|
||||||
|
source_key=run.plan_data[0]["source_key"], action="create"
|
||||||
|
),
|
||||||
|
VCardBatchSelection(
|
||||||
|
source_key=run.plan_data[1]["source_key"], action="ignore"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
request = VCardBatchCommitRequest(
|
||||||
|
expected_plan_hash=run.plan_hash, selections=selections
|
||||||
|
)
|
||||||
|
applied = apply_vcard_batch(self.session, self.principal, run.id, request)
|
||||||
|
repeated = apply_vcard_batch(self.session, self.principal, run.id, request)
|
||||||
|
|
||||||
|
self.assertIs(applied, repeated)
|
||||||
|
self.assertEqual(1, self.session.query(Contact).count())
|
||||||
|
self.assertEqual("Ada Lovelace", self.session.query(Contact).one().display_name)
|
||||||
|
self.assertEqual(1, applied.result_evidence["progress"]["ignored"])
|
||||||
|
with self.assertRaisesRegex(AddressBookError, "different selection"):
|
||||||
|
apply_vcard_batch(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
run.id,
|
||||||
|
VCardBatchCommitRequest(
|
||||||
|
expected_plan_hash=run.plan_hash,
|
||||||
|
selections=[
|
||||||
|
VCardBatchSelection(
|
||||||
|
source_key=run.plan_data[1]["source_key"], action="create"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_duplicate_uid_policy_and_cancellation(self) -> None:
|
||||||
|
run = preview_vcard_batch(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
VCardBatchPreviewRequest(
|
||||||
|
files=[
|
||||||
|
batch_file(
|
||||||
|
"duplicates.vcf",
|
||||||
|
vcard("same", "First", "first@example.test")
|
||||||
|
+ vcard("same", "Last", "last@example.test"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
duplicate_card_policy="reject",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["conflict", "conflict"], [item["action"] for item in run.plan_data]
|
||||||
|
)
|
||||||
|
cancelled = cancel_vcard_batch(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
run.id,
|
||||||
|
VCardBatchCancelRequest(
|
||||||
|
expected_plan_hash=run.plan_hash,
|
||||||
|
reason="Operator rejected duplicate source UIDs.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual("cancelled", cancelled.status)
|
||||||
|
self.assertEqual(0, self.session.query(Contact).count())
|
||||||
|
|
||||||
|
last = preview_vcard_batch(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
VCardBatchPreviewRequest(
|
||||||
|
files=[
|
||||||
|
batch_file(
|
||||||
|
"duplicates.vcf",
|
||||||
|
vcard("same", "First", "first@example.test")
|
||||||
|
+ vcard("same", "Last", "last@example.test"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
duplicate_card_policy="last",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["ignore", "create"], [item["action"] for item in last.plan_data]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_deterministic_scoped_export_supports_vcard_versions(self) -> None:
|
||||||
|
grace = create_contact(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
ContactCreateRequest(display_name="Grace Hopper"),
|
||||||
|
)
|
||||||
|
ada = create_contact(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
ContactCreateRequest(display_name="Ada Lovelace"),
|
||||||
|
)
|
||||||
|
address_list = AddressList(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address_book_id=self.book.id,
|
||||||
|
name="Selected",
|
||||||
|
source_kind="local",
|
||||||
|
read_only=False,
|
||||||
|
)
|
||||||
|
self.session.add(address_list)
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add(
|
||||||
|
AddressListEntry(
|
||||||
|
address_list_id=address_list.id, contact_id=grace.id, order_index=0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
selected = export_vcards(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
VCardExportRequest(
|
||||||
|
scope="contacts", contact_ids=[grace.id, ada.id], version="3.0"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repeated = export_vcards(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
VCardExportRequest(
|
||||||
|
scope="contacts", contact_ids=[ada.id, grace.id], version="3.0"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
listed = export_vcards(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
self.book.id,
|
||||||
|
VCardExportRequest(scope="address_list", address_list_id=address_list.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(selected["content_hash"], repeated["content_hash"])
|
||||||
|
self.assertLess(
|
||||||
|
selected["content"].index("Ada Lovelace"),
|
||||||
|
selected["content"].index("Grace Hopper"),
|
||||||
|
)
|
||||||
|
self.assertIn("VERSION:3.0", selected["content"])
|
||||||
|
self.assertEqual(1, listed["contact_count"])
|
||||||
|
self.assertIn("Grace Hopper", listed["content"])
|
||||||
|
|
||||||
|
def test_parser_rejects_pathological_unfolded_lines(self) -> None:
|
||||||
|
result = parse_vcards_with_issues(
|
||||||
|
"BEGIN:VCARD\nFN:"
|
||||||
|
+ ("a" * (MAX_VCARD_UNFOLDED_LINE_CHARS + 1))
|
||||||
|
+ "\nEND:VCARD"
|
||||||
|
)
|
||||||
|
self.assertEqual([], result.cards)
|
||||||
|
self.assertIn("unfolded lines", result.issues[0].message)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/addresses-webui",
|
||||||
|
"version": "0.1.21",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/addresses.css": "./src/styles/addresses.css"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:ui-structure": "node scripts/test-selection-list-structure.mjs",
|
||||||
|
"test:import-run": "rm -rf .import-run-test-build && mkdir -p .import-run-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-run-test-build/package.json && ../../govoplan-core/webui/node_modules/.bin/tsc -p tsconfig.import-run-tests.json && node .import-run-test-build/tests/import-run-state.test.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const pagePath = fileURLToPath(new URL("../src/features/addressbook/AddressBookPage.tsx", import.meta.url));
|
||||||
|
const stylesPath = fileURLToPath(new URL("../src/styles/addresses.css", import.meta.url));
|
||||||
|
const page = readFileSync(pagePath, "utf8");
|
||||||
|
const styles = readFileSync(stylesPath, "utf8");
|
||||||
|
|
||||||
|
assert.match(page, /SegmentedControl,[\s\S]*SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
|
||||||
|
assert.match(page, /<SelectionList label="Contacts" className="address-contact-selection-list">/);
|
||||||
|
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selected\}[\s\S]*className=\{`address-contact-row/);
|
||||||
|
assert.match(page, /draggable=\{!contact\.deleted_at && !saving\}/);
|
||||||
|
assert.match(page, /<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">/);
|
||||||
|
assert.match(page, /selected=\{cardDavForm\.collection_url === item\.collection_url\}/);
|
||||||
|
assert.match(page, /<SegmentedControl<ConflictMergeChoice>[\s\S]*role="group"[\s\S]*value=\{conflictMergeChoices\[row\.field\] \?\? "local"\}/);
|
||||||
|
assert.doesNotMatch(page, /<button[\s\S]{0,160}(?:address-contact-row|address-sync-result-row)/);
|
||||||
|
assert.doesNotMatch(styles, /\.address-conflict-choice button/);
|
||||||
|
assert.doesNotMatch(styles, /\.address-contact-row:(?:hover|focus-visible)/);
|
||||||
|
|
||||||
|
console.log("Address-book flat selections use central components.");
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
export type ImportRunLifecycle = {
|
||||||
|
label: string;
|
||||||
|
tone: "info" | "success" | "warning" | "inactive";
|
||||||
|
canApply: boolean;
|
||||||
|
canRollback: boolean;
|
||||||
|
guidance: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function importRunIdFromSearch(search: URLSearchParams): string {
|
||||||
|
return search.get("import_run")?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withImportRunSearch(search: URLSearchParams, runId?: string | null): URLSearchParams {
|
||||||
|
const next = new URLSearchParams(search);
|
||||||
|
const normalized = runId?.trim();
|
||||||
|
if (normalized) next.set("import_run", normalized);
|
||||||
|
else next.delete("import_run");
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importRunLifecycle(status: string): ImportRunLifecycle {
|
||||||
|
if (status === "previewed") {
|
||||||
|
return {
|
||||||
|
label: "Ready for review",
|
||||||
|
tone: "info",
|
||||||
|
canApply: true,
|
||||||
|
canRollback: false,
|
||||||
|
guidance: "Review the persisted effects and diagnostics before applying this plan."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (status === "applied") {
|
||||||
|
return {
|
||||||
|
label: "Applied",
|
||||||
|
tone: "success",
|
||||||
|
canApply: false,
|
||||||
|
canRollback: true,
|
||||||
|
guidance: "The plan has already been applied. Rollback remains guarded by its recorded plan hash and contact evidence."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (status === "rolled_back") {
|
||||||
|
return {
|
||||||
|
label: "Rolled back",
|
||||||
|
tone: "inactive",
|
||||||
|
canApply: false,
|
||||||
|
canRollback: false,
|
||||||
|
guidance: "This run was rolled back and cannot be applied again. Create a new preview to import the source again."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (status === "expired") {
|
||||||
|
return {
|
||||||
|
label: "Expired",
|
||||||
|
tone: "warning",
|
||||||
|
canApply: false,
|
||||||
|
canRollback: false,
|
||||||
|
guidance: "This preview is no longer actionable. Create a new preview from the original source."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: status || "Unavailable",
|
||||||
|
tone: "warning",
|
||||||
|
canApply: false,
|
||||||
|
canRollback: false,
|
||||||
|
guidance: "This run is not actionable in its current lifecycle state."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unavailableImportRunMessage(status?: number): string {
|
||||||
|
if (status === 404 || status === 410) {
|
||||||
|
return "This import run is missing, expired, or not available to your tenant. No source data was loaded.";
|
||||||
|
}
|
||||||
|
return "The persisted import run could not be loaded. Reload it after the service becomes available.";
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const ADDRESSES_DOCUMENTATION = {
|
||||||
|
topicId: "addresses.boundary",
|
||||||
|
documentationType: "user"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const ADDRESS_FIELDS_DOCUMENTATION = {
|
||||||
|
topicId: "addresses.reference.fields-and-consequences",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const ADDRESS_GOVERNANCE_DOCUMENTATION = {
|
||||||
|
topicId: "addresses.contact-point-resolution",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const ADDRESSES_I18N = {
|
||||||
|
requiredAction: "i18n:govoplan-addresses.required_action",
|
||||||
|
actor: "i18n:govoplan-addresses.actor",
|
||||||
|
destination: "i18n:govoplan-addresses.destination",
|
||||||
|
permissionDetails: "i18n:govoplan-addresses.permission_details",
|
||||||
|
permissionAction: "i18n:govoplan-addresses.permission_action",
|
||||||
|
permissionActor: "i18n:govoplan-addresses.permission_actor",
|
||||||
|
permissionDestination: "i18n:govoplan-addresses.permission_destination"
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
|
en: {
|
||||||
|
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Add contact",
|
||||||
|
"i18n:govoplan-addresses.address_book.f6327f59": "Address Book",
|
||||||
|
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Address book scopes",
|
||||||
|
"i18n:govoplan-addresses.ada_lovelace.a69a9f8a": "Ada Lovelace",
|
||||||
|
"i18n:govoplan-addresses.carddav_ldap_import_connectors_can_be_added_late.0471f513": "CardDAV, LDAP, and import connectors can be added later.",
|
||||||
|
"i18n:govoplan-addresses.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4": "Choose addresses from personal, group, or tenant sources.",
|
||||||
|
"i18n:govoplan-addresses.contacts.b0dd615c": "Contacts",
|
||||||
|
"i18n:govoplan-addresses.data_protection.06e87cd3": "Data Protection",
|
||||||
|
"i18n:govoplan-addresses.directory.4b892fe0": "Directory",
|
||||||
|
"i18n:govoplan-addresses.email.84add5b2": "Email",
|
||||||
|
"i18n:govoplan-addresses.favorite.6b90b6a1": "Favorite",
|
||||||
|
"i18n:govoplan-addresses.finance_team.ff353e5c": "Finance Team",
|
||||||
|
"i18n:govoplan-addresses.group.171a0606": "Group",
|
||||||
|
"i18n:govoplan-addresses.group_address_books.aed5a7c0": "Group address books",
|
||||||
|
"i18n:govoplan-addresses.grace_hopper.d97e6939": "Grace Hopper",
|
||||||
|
"i18n:govoplan-addresses.helpdesk.191815bf": "Helpdesk",
|
||||||
|
"i18n:govoplan-addresses.import.d6fbc9d2": "Import",
|
||||||
|
"i18n:govoplan-addresses.manage_sources.ec758de0": "Manage sources",
|
||||||
|
"i18n:govoplan-addresses.mock.3bba2a47": "Mock",
|
||||||
|
"i18n:govoplan-addresses.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4": "Mock workspace for personal, group, and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.",
|
||||||
|
"i18n:govoplan-addresses.name.709a2322": "Name",
|
||||||
|
"i18n:govoplan-addresses.no_contacts_found.ad977b09": "No contacts found.",
|
||||||
|
"i18n:govoplan-addresses.personal.40f07323": "Personal",
|
||||||
|
"i18n:govoplan-addresses.personal_address_book.e240066d": "Personal address book",
|
||||||
|
"i18n:govoplan-addresses.planned_address_actions.1d4a056a": "Planned address actions",
|
||||||
|
"i18n:govoplan-addresses.private_contacts_and_remembered_addresses.2bd71556": "Private contacts and remembered addresses.",
|
||||||
|
"i18n:govoplan-addresses.private_contacts_remembered_recipients_and_perso.685cca95": "Private contacts, remembered recipients, and personal distribution lists.",
|
||||||
|
"i18n:govoplan-addresses.project_office.c35aa9ca": "Project Office",
|
||||||
|
"i18n:govoplan-addresses.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529": "Remember addresses used in campaigns after opt-in.",
|
||||||
|
"i18n:govoplan-addresses.scope.4651a34e": "Scope",
|
||||||
|
"i18n:govoplan-addresses.share_selected_contacts_with_a_group.604d1464": "Share selected contacts with a group.",
|
||||||
|
"i18n:govoplan-addresses.shared.50d0d8dd": "Shared",
|
||||||
|
"i18n:govoplan-addresses.shared_contact_sets_for_teams_departments_or_cam.4408edd7": "Shared contact sets for teams, departments, or campaigns.",
|
||||||
|
"i18n:govoplan-addresses.shared_group_address_books_and_lists.12bac69d": "Shared group address books and lists.",
|
||||||
|
"i18n:govoplan-addresses.shared_list.b3c94b39": "Shared list",
|
||||||
|
"i18n:govoplan-addresses.status.bae7d5be": "Status",
|
||||||
|
"i18n:govoplan-addresses.sync.905f6309": "Sync",
|
||||||
|
"i18n:govoplan-addresses.tags.848eed0f": "Tags",
|
||||||
|
"i18n:govoplan-addresses.tenant.3ca93c78": "Tenant",
|
||||||
|
"i18n:govoplan-addresses.tenant_directory.11b0e09c": "Tenant directory",
|
||||||
|
"i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Tenant directory and approved shared contacts.",
|
||||||
|
"i18n:govoplan-addresses.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9": "Tenant-wide contacts, functional mailboxes, and approved shared entries.",
|
||||||
|
"i18n:govoplan-addresses.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a": "Use contacts in To, Cc, Bcc, sender, and reply-to fields.",
|
||||||
|
"i18n:govoplan-addresses.used_recently.af75cea7": "Used recently",
|
||||||
|
"i18n:govoplan-addresses.sources": "Address sources",
|
||||||
|
"i18n:govoplan-addresses.contact_detail": "Contact detail",
|
||||||
|
"i18n:govoplan-addresses.communication_governance": "Communication governance",
|
||||||
|
"i18n:govoplan-addresses.required_action": "Required action",
|
||||||
|
"i18n:govoplan-addresses.actor": "Responsible actor",
|
||||||
|
"i18n:govoplan-addresses.destination": "Where to continue",
|
||||||
|
"i18n:govoplan-addresses.permission_details": "Your account can inspect Addresses but cannot create or change address books, lists, or contacts.",
|
||||||
|
"i18n:govoplan-addresses.permission_action": "Ask for the address-book, list, or contact permission needed for the intended task.",
|
||||||
|
"i18n:govoplan-addresses.permission_actor": "A tenant administrator or owner of the address-book scope",
|
||||||
|
"i18n:govoplan-addresses.permission_destination": "Access administration for the current tenant or group",
|
||||||
|
"i18n:govoplan-addresses.unsaved_book_title": "Unsaved address book",
|
||||||
|
"i18n:govoplan-addresses.unsaved_list_title": "Unsaved address list",
|
||||||
|
"i18n:govoplan-addresses.unsaved_contact_title": "Unsaved contact",
|
||||||
|
"i18n:govoplan-addresses.unsaved_message": "Save or discard this draft before leaving the editor.",
|
||||||
|
"Addresses are read-only": "Addresses are read-only",
|
||||||
|
"Address books": "Address books",
|
||||||
|
"Address sources": "Address sources",
|
||||||
|
"Contact detail": "Contact detail",
|
||||||
|
"Show archived": "Show archived",
|
||||||
|
"Search contacts": "Search contacts",
|
||||||
|
"No address books found.": "No address books found.",
|
||||||
|
"No contact selected": "No contact selected",
|
||||||
|
"Add address book": "Add address book",
|
||||||
|
"Edit address book": "Edit address book",
|
||||||
|
"Add address list": "Add address list",
|
||||||
|
"Edit address list": "Edit address list",
|
||||||
|
"Add contact": "Add contact",
|
||||||
|
"Edit contact": "Edit contact",
|
||||||
|
"Communication governance": "Communication governance",
|
||||||
|
"Display name": "Display name",
|
||||||
|
"Given name": "Given name",
|
||||||
|
"Family name": "Family name",
|
||||||
|
"Organization": "Organization",
|
||||||
|
"Role title": "Role title",
|
||||||
|
"Email addresses": "Email addresses",
|
||||||
|
"Phone numbers": "Phone numbers",
|
||||||
|
"Postal addresses": "Postal addresses",
|
||||||
|
"Primary": "Primary",
|
||||||
|
"Description": "Description",
|
||||||
|
"Note": "Note"
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Kontakt hinzufügen",
|
||||||
|
"i18n:govoplan-addresses.address_book.f6327f59": "Adressbuch",
|
||||||
|
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Adressbuch-Bereiche",
|
||||||
|
"i18n:govoplan-addresses.ada_lovelace.a69a9f8a": "Ada Lovelace",
|
||||||
|
"i18n:govoplan-addresses.carddav_ldap_import_connectors_can_be_added_late.0471f513": "CardDAV-, LDAP- und Import-Anbindungen können später ergänzt werden.",
|
||||||
|
"i18n:govoplan-addresses.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4": "Adressen aus persönlichen, Gruppen- oder Mandantenquellen auswählen.",
|
||||||
|
"i18n:govoplan-addresses.contacts.b0dd615c": "Kontakte",
|
||||||
|
"i18n:govoplan-addresses.data_protection.06e87cd3": "Datenschutz",
|
||||||
|
"i18n:govoplan-addresses.directory.4b892fe0": "Verzeichnis",
|
||||||
|
"i18n:govoplan-addresses.email.84add5b2": "E-Mail",
|
||||||
|
"i18n:govoplan-addresses.favorite.6b90b6a1": "Favorit",
|
||||||
|
"i18n:govoplan-addresses.finance_team.ff353e5c": "Finanzteam",
|
||||||
|
"i18n:govoplan-addresses.group.171a0606": "Gruppe",
|
||||||
|
"i18n:govoplan-addresses.group_address_books.aed5a7c0": "Gruppenadressbücher",
|
||||||
|
"i18n:govoplan-addresses.grace_hopper.d97e6939": "Grace Hopper",
|
||||||
|
"i18n:govoplan-addresses.helpdesk.191815bf": "Helpdesk",
|
||||||
|
"i18n:govoplan-addresses.import.d6fbc9d2": "Importieren",
|
||||||
|
"i18n:govoplan-addresses.manage_sources.ec758de0": "Quellen verwalten",
|
||||||
|
"i18n:govoplan-addresses.mock.3bba2a47": "Mock",
|
||||||
|
"i18n:govoplan-addresses.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4": "Mock-Arbeitsbereich für persönliche, Gruppen- und Mandantenadressbücher. Diese Kontakte können später Autovervollständigung und wiederverwendbare Adressauswahlen speisen.",
|
||||||
|
"i18n:govoplan-addresses.name.709a2322": "Name",
|
||||||
|
"i18n:govoplan-addresses.no_contacts_found.ad977b09": "Keine Kontakte gefunden.",
|
||||||
|
"i18n:govoplan-addresses.personal.40f07323": "Persönlich",
|
||||||
|
"i18n:govoplan-addresses.personal_address_book.e240066d": "Persönliches Adressbuch",
|
||||||
|
"i18n:govoplan-addresses.planned_address_actions.1d4a056a": "Geplante Adressaktionen",
|
||||||
|
"i18n:govoplan-addresses.private_contacts_and_remembered_addresses.2bd71556": "Private Kontakte und gemerkte Adressen.",
|
||||||
|
"i18n:govoplan-addresses.private_contacts_remembered_recipients_and_perso.685cca95": "Private Kontakte, gemerkte Empfänger und persönliche Verteilerlisten.",
|
||||||
|
"i18n:govoplan-addresses.project_office.c35aa9ca": "Projektbüro",
|
||||||
|
"i18n:govoplan-addresses.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529": "In Kampagnen verwendete Adressen nach Opt-in merken.",
|
||||||
|
"i18n:govoplan-addresses.scope.4651a34e": "Bereich",
|
||||||
|
"i18n:govoplan-addresses.share_selected_contacts_with_a_group.604d1464": "Ausgewählte Kontakte mit einer Gruppe teilen.",
|
||||||
|
"i18n:govoplan-addresses.shared.50d0d8dd": "Geteilt",
|
||||||
|
"i18n:govoplan-addresses.shared_contact_sets_for_teams_departments_or_cam.4408edd7": "Geteilte Kontaktsammlungen für Teams, Abteilungen oder Kampagnen.",
|
||||||
|
"i18n:govoplan-addresses.shared_group_address_books_and_lists.12bac69d": "Geteilte Gruppenadressbücher und Listen.",
|
||||||
|
"i18n:govoplan-addresses.shared_list.b3c94b39": "Geteilte Liste",
|
||||||
|
"i18n:govoplan-addresses.status.bae7d5be": "Status",
|
||||||
|
"i18n:govoplan-addresses.sync.905f6309": "Sync",
|
||||||
|
"i18n:govoplan-addresses.tags.848eed0f": "Tags",
|
||||||
|
"i18n:govoplan-addresses.tenant.3ca93c78": "Mandant",
|
||||||
|
"i18n:govoplan-addresses.tenant_directory.11b0e09c": "Mandantenverzeichnis",
|
||||||
|
"i18n:govoplan-addresses.tenant_directory_and_approved_shared_contacts.fa671f1b": "Mandantenverzeichnis und freigegebene geteilte Kontakte.",
|
||||||
|
"i18n:govoplan-addresses.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9": "Mandantenweite Kontakte, Funktionspostfächer und freigegebene Einträge.",
|
||||||
|
"i18n:govoplan-addresses.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a": "Kontakte in An-, Cc-, Bcc-, Absender- und Antwortfeldern verwenden.",
|
||||||
|
"i18n:govoplan-addresses.used_recently.af75cea7": "Kürzlich verwendet",
|
||||||
|
"i18n:govoplan-addresses.sources": "Adressquellen",
|
||||||
|
"i18n:govoplan-addresses.contact_detail": "Kontaktdetails",
|
||||||
|
"i18n:govoplan-addresses.communication_governance": "Kommunikationssteuerung",
|
||||||
|
"i18n:govoplan-addresses.required_action": "Erforderliche Aktion",
|
||||||
|
"i18n:govoplan-addresses.actor": "Verantwortliche Stelle",
|
||||||
|
"i18n:govoplan-addresses.destination": "Fortsetzung",
|
||||||
|
"i18n:govoplan-addresses.permission_details": "Ihr Konto darf Adressen einsehen, aber keine Adressbücher, Listen oder Kontakte erstellen oder ändern.",
|
||||||
|
"i18n:govoplan-addresses.permission_action": "Fordern Sie die für die Aufgabe erforderliche Adressbuch-, Listen- oder Kontaktberechtigung an.",
|
||||||
|
"i18n:govoplan-addresses.permission_actor": "Mandantenadministration oder Eigentümer des Adressbuchbereichs",
|
||||||
|
"i18n:govoplan-addresses.permission_destination": "Zugriffsverwaltung des aktuellen Mandanten oder der Gruppe",
|
||||||
|
"i18n:govoplan-addresses.unsaved_book_title": "Ungespeichertes Adressbuch",
|
||||||
|
"i18n:govoplan-addresses.unsaved_list_title": "Ungespeicherte Adressliste",
|
||||||
|
"i18n:govoplan-addresses.unsaved_contact_title": "Ungespeicherter Kontakt",
|
||||||
|
"i18n:govoplan-addresses.unsaved_message": "Speichern oder verwerfen Sie diesen Entwurf, bevor Sie den Editor verlassen.",
|
||||||
|
"Addresses are read-only": "Adressen sind schreibgeschützt",
|
||||||
|
"Address books": "Adressbücher",
|
||||||
|
"Address sources": "Adressquellen",
|
||||||
|
"Contact detail": "Kontaktdetails",
|
||||||
|
"Show archived": "Archivierte anzeigen",
|
||||||
|
"Search contacts": "Kontakte suchen",
|
||||||
|
"No address books found.": "Keine Adressbücher gefunden.",
|
||||||
|
"No contact selected": "Kein Kontakt ausgewählt",
|
||||||
|
"Add address book": "Adressbuch hinzufügen",
|
||||||
|
"Edit address book": "Adressbuch bearbeiten",
|
||||||
|
"Add address list": "Adressliste hinzufügen",
|
||||||
|
"Edit address list": "Adressliste bearbeiten",
|
||||||
|
"Add contact": "Kontakt hinzufügen",
|
||||||
|
"Edit contact": "Kontakt bearbeiten",
|
||||||
|
"Communication governance": "Kommunikationssteuerung",
|
||||||
|
"Display name": "Anzeigename",
|
||||||
|
"Given name": "Vorname",
|
||||||
|
"Family name": "Nachname",
|
||||||
|
"Organization": "Organisation",
|
||||||
|
"Role title": "Funktionsbezeichnung",
|
||||||
|
"Email addresses": "E-Mail-Adressen",
|
||||||
|
"Phone numbers": "Telefonnummern",
|
||||||
|
"Postal addresses": "Postanschriften",
|
||||||
|
"Primary": "Primär",
|
||||||
|
"Description": "Beschreibung",
|
||||||
|
"Note": "Notiz"
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default } from "./module";
|
||||||
|
export * from "./module";
|
||||||
|
export { default as AddressBookPage } from "./features/addressbook/AddressBookPage";
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import "./styles/addresses.css";
|
||||||
|
|
||||||
|
const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage"));
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
en: generatedTranslations.en,
|
||||||
|
de: generatedTranslations.de
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addressesModule: PlatformWebModule = {
|
||||||
|
id: "addresses",
|
||||||
|
label: "i18n:govoplan-addresses.address_book.f6327f59",
|
||||||
|
version: "0.1.9",
|
||||||
|
dependencies: [],
|
||||||
|
optionalDependencies: ["campaigns", "mail", "forms", "reporting", "portal", "postbox"],
|
||||||
|
translations,
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "addresses.page", moduleId: "addresses", kind: "route", label: "i18n:govoplan-addresses.address_book.f6327f59", order: 80 },
|
||||||
|
{ id: "addresses.sources", moduleId: "addresses", kind: "section", label: "i18n:govoplan-addresses.sources", parentId: "addresses.page", order: 10 },
|
||||||
|
{ id: "addresses.contacts", moduleId: "addresses", kind: "section", label: "i18n:govoplan-addresses.contacts.b0dd615c", parentId: "addresses.page", order: 20 },
|
||||||
|
{ id: "addresses.detail", moduleId: "addresses", kind: "section", label: "i18n:govoplan-addresses.contact_detail", parentId: "addresses.page", order: 30 },
|
||||||
|
{ id: "addresses.governance", moduleId: "addresses", kind: "action", label: "i18n:govoplan-addresses.communication_governance", parentId: "addresses.detail", order: 40 },
|
||||||
|
{ id: "addresses.sync", moduleId: "addresses", kind: "action", label: "i18n:govoplan-addresses.sync.905f6309", parentId: "addresses.sources", order: 50 }
|
||||||
|
],
|
||||||
|
navItems: [{ to: "/address-book", label: "i18n:govoplan-addresses.address_book.f6327f59", iconName: "book-user", anyOf: ["addresses:contact:read"], order: 80 }],
|
||||||
|
routes: [{ path: "/address-book", anyOf: ["addresses:contact:read"], order: 80, surfaceId: "addresses.page", render: ({ settings, auth, onAuthChange }) => createElement(AddressBookPage, { settings, auth, onAuthChange }) }]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default addressesModule;
|
||||||
@@ -0,0 +1,844 @@
|
|||||||
|
.address-book-scope-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-book-scope-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-book-scope-card strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-book-scope-card p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-error {
|
||||||
|
left: 14px;
|
||||||
|
margin: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 10px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-book-page.address-book-fullscreen {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
height: calc(100vh - 115px);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-workspace-frame {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-book-workspace {
|
||||||
|
background: var(--panel);
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(250px, 310px) minmax(280px, 360px) minmax(0, 1fr);
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-tree-panel,
|
||||||
|
.address-list-panel,
|
||||||
|
.address-detail-panel {
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-tree-panel {
|
||||||
|
border-right: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-tree-header {
|
||||||
|
background: var(--panel-header);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-tree-header .button-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-icon-actions {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-icon-actions .btn {
|
||||||
|
align-items: center;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-tree-filter-row {
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-tree-filter-row .toggle-switch-row {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-tree-list {
|
||||||
|
padding: 6px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-book-page .explorer-tree-node .address-tree-node-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-tree-node-content strong,
|
||||||
|
.address-tree-node-content small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-tree-node-content small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-tree-summary {
|
||||||
|
border-top: var(--border-line);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-source-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-source-subsummary {
|
||||||
|
border-top: var(--border-line);
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-source-summary p {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-sync-dialog {
|
||||||
|
max-width: min(960px, calc(100vw - 36px));
|
||||||
|
width: min(960px, calc(100vw - 36px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-dialog {
|
||||||
|
max-width: min(1080px, calc(100vw - 36px));
|
||||||
|
width: min(1080px, calc(100vw - 36px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-workspace,
|
||||||
|
.address-import-profile-editor,
|
||||||
|
.address-import-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-run-state {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px 14px;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) auto minmax(220px, 1.4fr) auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-run-state p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-profile-actions {
|
||||||
|
align-items: end;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-mapping-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px 14px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
max-height: min(42vh, 440px);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-preview > .address-sync-plan-grid {
|
||||||
|
border: 0;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-preview > .address-sync-plan-grid > div {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-preview > .address-sync-plan-grid strong {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-preview > .address-sync-plan-grid small {
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-sync-record-list,
|
||||||
|
.address-sync-plan-grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-sync-result-list,
|
||||||
|
.address-sync-record-list,
|
||||||
|
.address-sync-plan-grid {
|
||||||
|
border: var(--border-line);
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-sync-record-row,
|
||||||
|
.address-sync-plan-row {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
color: var(--text);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
padding: 9px 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-sync-result-row {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-sync-result-row strong,
|
||||||
|
.address-sync-result-row small,
|
||||||
|
.address-sync-record-row strong,
|
||||||
|
.address-sync-record-row small,
|
||||||
|
.address-sync-plan-row span,
|
||||||
|
.address-sync-plan-row small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-sync-result-row small,
|
||||||
|
.address-sync-record-row small,
|
||||||
|
.address-sync-plan-row small {
|
||||||
|
color: var(--muted);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-sync-plan-row {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-sync-source-card,
|
||||||
|
.address-sync-inspector {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-conflict-review {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-conflict-grid {
|
||||||
|
border: var(--border-line);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||||
|
max-height: min(52vh, 520px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-conflict-grid-header,
|
||||||
|
.address-conflict-row > * {
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-conflict-grid-header {
|
||||||
|
background: var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
position: sticky;
|
||||||
|
text-transform: uppercase;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-conflict-row {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-conflict-row.has-difference > * {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-conflict-row span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-list-panel,
|
||||||
|
.address-detail-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-list-panel {
|
||||||
|
border-right: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-panel-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 13px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-panel-header h2,
|
||||||
|
.address-detail-header h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-panel-header p,
|
||||||
|
.address-detail-header p {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-toolbar {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-toolbar input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-list {
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-pagination {
|
||||||
|
border-top: var(--border-line);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-selection-list {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-row {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-row-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-row-main strong,
|
||||||
|
.address-contact-row-main small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-row-main small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-row-meta {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-tag {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
color: var(--muted);
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
max-width: 110px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 4px 7px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail-panel {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail-section {
|
||||||
|
border-top: var(--border-line);
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail-section h3 {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail-list div {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail-list dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail-list dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-point-value {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px 8px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-point-value > small {
|
||||||
|
color: var(--muted);
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-point-value .btn {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-membership-row {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-membership-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-membership-row p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail-empty,
|
||||||
|
.address-empty-note {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail-empty {
|
||||||
|
align-content: center;
|
||||||
|
display: grid;
|
||||||
|
height: 100%;
|
||||||
|
justify-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-detail-empty p,
|
||||||
|
.address-empty-note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-dialog-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel.address-contact-dialog {
|
||||||
|
width: min(1120px, calc(100vw - 40px));
|
||||||
|
max-width: calc(100vw - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel.address-member-dialog {
|
||||||
|
width: min(720px, calc(100vw - 40px));
|
||||||
|
max-width: calc(100vw - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-member-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-member-search {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-member-candidate-list {
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
display: grid;
|
||||||
|
max-height: min(460px, calc(100vh - 300px));
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-member-candidate {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.7fr) auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-member-candidate select {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-member-candidate:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-member-candidate:hover {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-vcard-textarea {
|
||||||
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.address-import-mapping-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-import-preview > .address-sync-plan-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-form-section {
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-form-section-heading,
|
||||||
|
.address-form-row {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-form-section-heading {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-form-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-dialog .dialog-panel {
|
||||||
|
width: min(980px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel.address-quality-dialog,
|
||||||
|
.address-quality-dialog .dialog-panel {
|
||||||
|
width: min(1120px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
max-height: min(720px, calc(100vh - 210px));
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-provenance-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-provenance-list {
|
||||||
|
max-height: min(620px, calc(100vh - 300px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-provenance-value {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-empty {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-merge-field-sources select {
|
||||||
|
min-width: 0;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-metrics {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-section,
|
||||||
|
.address-quality-list,
|
||||||
|
.address-quality-row-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-section {
|
||||||
|
border-top: var(--border-line);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-list {
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-row {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
color: inherit;
|
||||||
|
display: grid;
|
||||||
|
font: inherit;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-row-button {
|
||||||
|
cursor: pointer;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-row-button:hover {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-row-heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-row-main small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-layout,
|
||||||
|
.address-governance-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-list {
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-rule {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-rule:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-rule-main,
|
||||||
|
.address-governance-rule-heading {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-rule-main {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-governance-rule-heading {
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-form-row-email,
|
||||||
|
.address-form-row-phone {
|
||||||
|
grid-template-columns: 92px minmax(88px, 0.3fr) minmax(220px, 1fr) 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-form-row-postal {
|
||||||
|
grid-template-columns: 92px minmax(80px, 0.2fr) minmax(180px, 1fr) minmax(90px, 0.35fr) minmax(120px, 0.45fr) minmax(110px, 0.4fr) minmax(120px, 0.45fr) 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-form-row > *,
|
||||||
|
.address-form-row input,
|
||||||
|
.address-form-row select,
|
||||||
|
.address-form-row textarea {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-primary-choice {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-form-row .btn {
|
||||||
|
align-items: center;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strong-link {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-selected-row {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-book-page .is-archived-row {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.address-book-workspace,
|
||||||
|
.address-import-run-state,
|
||||||
|
.address-form-row-email,
|
||||||
|
.address-form-row-phone,
|
||||||
|
.address-form-row-postal {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-quality-row,
|
||||||
|
.address-contact-point-value {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-contact-point-value > small {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
importRunIdFromSearch,
|
||||||
|
importRunLifecycle,
|
||||||
|
unavailableImportRunMessage,
|
||||||
|
withImportRunSearch
|
||||||
|
} from "../src/features/addressbook/importRunState";
|
||||||
|
|
||||||
|
const initial = new URLSearchParams("q=Ada&import_run=run-123");
|
||||||
|
assert.equal(importRunIdFromSearch(initial), "run-123");
|
||||||
|
assert.equal(withImportRunSearch(initial, "run-456").toString(), "q=Ada&import_run=run-456");
|
||||||
|
assert.equal(withImportRunSearch(initial, null).toString(), "q=Ada");
|
||||||
|
|
||||||
|
assert.equal(importRunLifecycle("previewed").canApply, true);
|
||||||
|
assert.equal(importRunLifecycle("previewed").canRollback, false);
|
||||||
|
assert.equal(importRunLifecycle("applied").canApply, false);
|
||||||
|
assert.equal(importRunLifecycle("applied").canRollback, true);
|
||||||
|
assert.equal(importRunLifecycle("rolled_back").canRollback, false);
|
||||||
|
assert.equal(importRunLifecycle("expired").label, "Expired");
|
||||||
|
assert.match(unavailableImportRunMessage(404), /missing, expired, or not available to your tenant/);
|
||||||
|
|
||||||
|
console.log("Address import-run deep-link and lifecycle state tests passed.");
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"target": "ES2022",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"typeRoots": ["../../govoplan-core/webui/node_modules/@types"],
|
||||||
|
"types": ["node"],
|
||||||
|
"outDir": ".import-run-test-build"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/features/addressbook/importRunState.ts",
|
||||||
|
"tests/import-run-state.test.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user