Compare commits
7
Commits
42fe262376
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
958a9959c8 | ||
|
|
d4fa024034 | ||
|
|
53490e7be7 | ||
|
|
3f1ff79e87 | ||
|
|
5cdf0ae9ff | ||
|
|
3458306e04 | ||
|
|
8740fb33f8 |
@@ -104,6 +104,17 @@ The module exposes core-mediated capabilities for:
|
|||||||
pickers.
|
pickers.
|
||||||
- `distribution.recipient_channel_facts`: current channel, governance, and
|
- `distribution.recipient_channel_facts`: current channel, governance, and
|
||||||
quality facts for distribution and Policy consumers.
|
quality facts for distribution and Policy consumers.
|
||||||
|
- `privacy.dsar.addresses`: tenant-bounded, minimized data-subject discovery
|
||||||
|
across contacts, contact points, list use, governance, provenance,
|
||||||
|
synchronization evidence, and operator attribution.
|
||||||
|
|
||||||
|
The DSAR provider accepts corroborated email/account selectors and namespaced
|
||||||
|
Addresses references. It does not export connector state, raw import or sync
|
||||||
|
payloads, opaque metadata, snapshot payloads, or merge before/after payloads.
|
||||||
|
Reusable contacts are never deleted automatically: shared/synchronized contact
|
||||||
|
changes require an authorized dependency review through the ordinary Addresses
|
||||||
|
workflows, while governance, quality, merge, sync, import, and attribution
|
||||||
|
evidence is retained with an explicit reason.
|
||||||
|
|
||||||
`addresses.recipient_source` returns:
|
`addresses.recipient_source` returns:
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/addresses-webui",
|
"name": "@govoplan/addresses-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-addresses"
|
name = "govoplan-addresses"
|
||||||
version = "0.1.18"
|
version = "0.1.23"
|
||||||
description = "GovOPlaN reusable address and recipient-source module."
|
description = "GovOPlaN reusable address and recipient-source module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defusedxml>=0.7.1",
|
"defusedxml>=0.7.1",
|
||||||
"govoplan-core>=0.1.18",
|
"govoplan-core>=0.1.45",
|
||||||
"ldap3>=2.9.1,<3",
|
"ldap3>=2.9.1,<3",
|
||||||
"openpyxl>=3.1.5,<4",
|
"openpyxl>=3.1.5,<4",
|
||||||
]
|
]
|
||||||
|
|||||||
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.'}}}
|
||||||
@@ -3,19 +3,28 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
import csv
|
import csv
|
||||||
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from datetime import UTC, datetime
|
||||||
from io import BytesIO, StringIO
|
from io import BytesIO, StringIO
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import and_, false, or_
|
from sqlalchemy import and_, false, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
from sqlalchemy.orm.attributes import set_committed_value
|
||||||
|
|
||||||
from govoplan_addresses.backend.db.models import (
|
from govoplan_addresses.backend.db.models import (
|
||||||
AddressImportProfile,
|
AddressImportProfile,
|
||||||
AddressImportRun,
|
AddressImportRun,
|
||||||
|
AddressListEntry,
|
||||||
Contact,
|
Contact,
|
||||||
|
ContactChannelRule,
|
||||||
|
ContactEmail,
|
||||||
|
ContactPhone,
|
||||||
|
ContactPointQualityDecision,
|
||||||
|
ContactPostalAddress,
|
||||||
)
|
)
|
||||||
from govoplan_addresses.backend.import_schemas import (
|
from govoplan_addresses.backend.import_schemas import (
|
||||||
AddressImportConfiguration,
|
AddressImportConfiguration,
|
||||||
@@ -34,6 +43,12 @@ from govoplan_addresses.backend.schemas import (
|
|||||||
)
|
)
|
||||||
from govoplan_addresses.backend.service import (
|
from govoplan_addresses.backend.service import (
|
||||||
AddressBookError,
|
AddressBookError,
|
||||||
|
_contact_change_payload,
|
||||||
|
_record_address_contact_change,
|
||||||
|
_require_mutable_book,
|
||||||
|
_replace_emails,
|
||||||
|
_replace_phones,
|
||||||
|
_replace_postal_addresses,
|
||||||
create_contact,
|
create_contact,
|
||||||
delete_contact,
|
delete_contact,
|
||||||
get_visible_address_book,
|
get_visible_address_book,
|
||||||
@@ -47,6 +62,16 @@ from govoplan_core.db.base import utcnow
|
|||||||
|
|
||||||
MAX_IMPORT_BYTES = 10_000_000
|
MAX_IMPORT_BYTES = 10_000_000
|
||||||
MAX_IMPORT_COLUMNS = 200
|
MAX_IMPORT_COLUMNS = 200
|
||||||
|
CONTACT_LOOKUP_BATCH_SIZE = 250
|
||||||
|
ROLLBACK_SNAPSHOT_VERSION = 2
|
||||||
|
CONTACT_POINTS = {
|
||||||
|
"emails": (ContactEmail, ("email",), _replace_emails),
|
||||||
|
"phones": (ContactPhone, ("phone",), _replace_phones),
|
||||||
|
"postal_addresses": (
|
||||||
|
ContactPostalAddress, ("street", "postal_code", "locality", "region", "country"),
|
||||||
|
_replace_postal_addresses,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _account_id(principal: ApiPrincipal) -> str:
|
def _account_id(principal: ApiPrincipal) -> str:
|
||||||
@@ -241,15 +266,19 @@ def get_import_run(
|
|||||||
session: Session,
|
session: Session,
|
||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
run_id: str,
|
run_id: str,
|
||||||
|
*,
|
||||||
|
lock: bool = False,
|
||||||
) -> AddressImportRun:
|
) -> AddressImportRun:
|
||||||
visible_book_ids = [book.id for book in _visible_import_books(session, principal)]
|
visible_book_ids = [book.id for book in _visible_import_books(session, principal)]
|
||||||
if not visible_book_ids:
|
if not visible_book_ids:
|
||||||
raise AddressBookError("Address import run not found.")
|
raise AddressBookError("Address import run not found.")
|
||||||
run = (
|
query = (
|
||||||
session.query(AddressImportRun)
|
session.query(AddressImportRun)
|
||||||
.filter(AddressImportRun.id == run_id, AddressImportRun.address_book_id.in_(visible_book_ids))
|
.filter(AddressImportRun.id == run_id, AddressImportRun.address_book_id.in_(visible_book_ids))
|
||||||
.one_or_none()
|
|
||||||
)
|
)
|
||||||
|
if lock:
|
||||||
|
query = query.populate_existing().with_for_update()
|
||||||
|
run = query.one_or_none()
|
||||||
if run is None:
|
if run is None:
|
||||||
raise AddressBookError("Address import run not found.")
|
raise AddressBookError("Address import run not found.")
|
||||||
return run
|
return run
|
||||||
@@ -262,13 +291,13 @@ def apply_address_import(
|
|||||||
*,
|
*,
|
||||||
expected_plan_hash: str,
|
expected_plan_hash: str,
|
||||||
) -> AddressImportRun:
|
) -> AddressImportRun:
|
||||||
run = get_import_run(session, principal, run_id)
|
run = get_import_run(session, principal, run_id, lock=True)
|
||||||
|
if run.plan_hash != expected_plan_hash:
|
||||||
|
raise AddressBookError("The reviewed import plan changed; create a new preview.")
|
||||||
if run.status == "applied":
|
if run.status == "applied":
|
||||||
return run
|
return run
|
||||||
if run.status != "previewed":
|
if run.status != "previewed":
|
||||||
raise AddressBookError(f"Import run cannot be applied from status {run.status!r}.")
|
raise AddressBookError(f"Import run cannot be applied from status {run.status!r}.")
|
||||||
if run.plan_hash != expected_plan_hash:
|
|
||||||
raise AddressBookError("The reviewed import plan changed; create a new preview.")
|
|
||||||
if any(item.get("severity") == "error" for item in run.diagnostics or []):
|
if any(item.get("severity") == "error" for item in run.diagnostics or []):
|
||||||
raise AddressBookError("Import plans with error diagnostics cannot be applied.")
|
raise AddressBookError("Import plans with error diagnostics cannot be applied.")
|
||||||
if any(item.get("action") == "conflict" for item in run.plan_data or []):
|
if any(item.get("action") == "conflict" for item in run.plan_data or []):
|
||||||
@@ -276,12 +305,15 @@ def apply_address_import(
|
|||||||
|
|
||||||
created_ids: list[str] = []
|
created_ids: list[str] = []
|
||||||
updated: list[dict[str, Any]] = []
|
updated: list[dict[str, Any]] = []
|
||||||
for item in run.plan_data or []:
|
# SQLAlchemy JSON columns do not track nested mutations. Keep the persisted
|
||||||
|
# preview untouched until assigning a genuinely changed complete plan.
|
||||||
|
applied_plan = copy.deepcopy(run.plan_data or [])
|
||||||
|
for item in applied_plan:
|
||||||
action = item.get("action")
|
action = item.get("action")
|
||||||
if action in {"ignored", "unchanged"}:
|
if action in {"ignored", "unchanged"}:
|
||||||
continue
|
continue
|
||||||
source_ref = str(item["source_ref"])
|
source_ref = str(item["source_ref"])
|
||||||
existing = _contact_by_source_ref(session, run.address_book_id, source_ref)
|
existing = _contact_by_source_ref(session, run.address_book_id, source_ref, lock=True)
|
||||||
if action == "create":
|
if action == "create":
|
||||||
if existing is not None and existing.deleted_at is None:
|
if existing is not None and existing.deleted_at is None:
|
||||||
raise AddressBookError("A target contact appeared after preview; preview the import again.")
|
raise AddressBookError("A target contact appeared after preview; preview the import again.")
|
||||||
@@ -299,18 +331,27 @@ def apply_address_import(
|
|||||||
elif action == "update":
|
elif action == "update":
|
||||||
if existing is None:
|
if existing is None:
|
||||||
raise AddressBookError("An import target disappeared after preview; preview the import again.")
|
raise AddressBookError("An import target disappeared after preview; preview the import again.")
|
||||||
|
_lock_contact_points(session, existing)
|
||||||
if _contact_hash(existing) != item.get("expected_contact_hash"):
|
if _contact_hash(existing) != item.get("expected_contact_hash"):
|
||||||
raise AddressBookError(
|
raise AddressBookError(
|
||||||
f'Contact "{existing.display_name}" changed after preview; preview the import again.'
|
f'Contact "{existing.display_name}" changed after preview; preview the import again.'
|
||||||
)
|
)
|
||||||
before = _contact_snapshot(existing)
|
_require_mutable_book(existing.address_book)
|
||||||
|
before = {
|
||||||
|
"version": ROLLBACK_SNAPSHOT_VERSION,
|
||||||
|
"contact": copy.deepcopy(_contact_snapshot(existing)),
|
||||||
|
"deleted_at": _deleted_at_value(existing.deleted_at),
|
||||||
|
}
|
||||||
|
update_payload = ContactUpdateRequest.model_validate(item["payload"])
|
||||||
|
points = _prepare_import_points(session, existing, update_payload)
|
||||||
if existing.deleted_at is not None:
|
if existing.deleted_at is not None:
|
||||||
restore_contact(session, principal, existing.id)
|
restore_contact(session, principal, existing.id)
|
||||||
|
_apply_import_points(existing, points)
|
||||||
contact = update_contact(
|
contact = update_contact(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
existing.id,
|
existing.id,
|
||||||
ContactUpdateRequest.model_validate(item["payload"]),
|
_without_contact_points(update_payload),
|
||||||
)
|
)
|
||||||
_stamp_import_contact(contact, run=run, item=item)
|
_stamp_import_contact(contact, run=run, item=item)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -319,7 +360,7 @@ def apply_address_import(
|
|||||||
|
|
||||||
run.status = "applied"
|
run.status = "applied"
|
||||||
run.applied_at = utcnow()
|
run.applied_at = utcnow()
|
||||||
run.plan_data = list(run.plan_data or [])
|
run.plan_data = applied_plan
|
||||||
run.result_evidence = {
|
run.result_evidence = {
|
||||||
"input_hash": run.input_hash,
|
"input_hash": run.input_hash,
|
||||||
"plan_hash": run.plan_hash,
|
"plan_hash": run.plan_hash,
|
||||||
@@ -337,7 +378,7 @@ def rollback_address_import(
|
|||||||
run_id: str,
|
run_id: str,
|
||||||
payload: AddressImportRollbackRequest,
|
payload: AddressImportRollbackRequest,
|
||||||
) -> AddressImportRun:
|
) -> AddressImportRun:
|
||||||
run = get_import_run(session, principal, run_id)
|
run = get_import_run(session, principal, run_id, lock=True)
|
||||||
if run.plan_hash != payload.expected_plan_hash:
|
if run.plan_hash != payload.expected_plan_hash:
|
||||||
raise AddressBookError("The reviewed import plan changed; reload the import run.")
|
raise AddressBookError("The reviewed import plan changed; reload the import run.")
|
||||||
if run.status == "rolled_back":
|
if run.status == "rolled_back":
|
||||||
@@ -347,6 +388,12 @@ def rollback_address_import(
|
|||||||
evidence = dict(run.result_evidence or {})
|
evidence = dict(run.result_evidence or {})
|
||||||
updated = list(evidence.get("updated_contacts") or [])
|
updated = list(evidence.get("updated_contacts") or [])
|
||||||
created_ids = list(evidence.get("created_contact_ids") or [])
|
created_ids = list(evidence.get("created_contact_ids") or [])
|
||||||
|
# Validate every before-image before touching any contact. Older runs did
|
||||||
|
# not record deletion state, so automatic recovery cannot infer it safely.
|
||||||
|
before_images = {
|
||||||
|
str(item["contact_id"]): _validated_rollback_snapshot(item.get("before"))
|
||||||
|
for item in updated
|
||||||
|
}
|
||||||
|
|
||||||
expected_hashes = {
|
expected_hashes = {
|
||||||
str(item["contact_id"]): str(item["after_hash"])
|
str(item["contact_id"]): str(item["after_hash"])
|
||||||
@@ -359,12 +406,20 @@ def rollback_address_import(
|
|||||||
if item.get("contact_id") in created_ids and item.get("after_hash")
|
if item.get("contact_id") in created_ids and item.get("after_hash")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for contact_id, expected_hash in expected_hashes.items():
|
if not set(created_ids).issubset(expected_hashes):
|
||||||
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
|
raise AddressBookError("Import rollback evidence is incomplete; automatic rollback is unsafe.")
|
||||||
|
for contact_id, expected_hash in sorted(expected_hashes.items()):
|
||||||
|
contact = get_visible_contact(session, principal, contact_id, include_deleted=True, lock=True)
|
||||||
|
_lock_contact_points(session, contact)
|
||||||
|
if contact.address_book_id != run.address_book_id:
|
||||||
|
raise AddressBookError("An import target moved to another address book; automatic rollback is unsafe.")
|
||||||
|
_require_mutable_book(contact.address_book)
|
||||||
if _contact_hash(contact) != expected_hash:
|
if _contact_hash(contact) != expected_hash:
|
||||||
raise AddressBookError(
|
raise AddressBookError(
|
||||||
f'Contact "{contact.display_name}" changed after import; automatic rollback is unsafe.'
|
f'Contact "{contact.display_name}" changed after import; automatic rollback is unsafe.'
|
||||||
)
|
)
|
||||||
|
if contact_id in before_images:
|
||||||
|
_validate_point_restoration(session, contact, before_images[contact_id][0]["points"])
|
||||||
|
|
||||||
for contact_id in created_ids:
|
for contact_id in created_ids:
|
||||||
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
|
contact = get_visible_contact(session, principal, contact_id, include_deleted=True)
|
||||||
@@ -372,22 +427,23 @@ def rollback_address_import(
|
|||||||
delete_contact(session, principal, contact.id)
|
delete_contact(session, principal, contact.id)
|
||||||
for item in updated:
|
for item in updated:
|
||||||
contact = get_visible_contact(session, principal, str(item["contact_id"]), include_deleted=True)
|
contact = get_visible_contact(session, principal, str(item["contact_id"]), include_deleted=True)
|
||||||
snapshot = dict(item["before"])
|
snapshot, previous_deleted_at = before_images[str(item["contact_id"])]
|
||||||
if contact.deleted_at is not None:
|
if contact.deleted_at is not None:
|
||||||
restore_contact(session, principal, contact.id)
|
restore_contact(session, principal, contact.id)
|
||||||
update_contact(
|
previous = _contact_change_payload(contact, prefix="previous_")
|
||||||
session,
|
_restore_contact_points(contact, snapshot["points"])
|
||||||
principal,
|
# A validated stored before-image is not a fresh user edit: do not trim,
|
||||||
contact.id,
|
# normalize or coerce it through the generic update path a second time.
|
||||||
ContactUpdateRequest.model_validate(snapshot["payload"]),
|
for field in ("display_name", "given_name", "family_name", "organization", "role_title", "note", "tags"):
|
||||||
)
|
setattr(contact, field, copy.deepcopy(snapshot["payload"][field]))
|
||||||
contact.source_kind = snapshot.get("source_kind") or "local"
|
for field in ("source_kind", "source_ref", "source_revision", "source_payload_kind", "source_payload_raw", "provenance"):
|
||||||
contact.source_ref = snapshot.get("source_ref")
|
setattr(contact, field, copy.deepcopy(snapshot[field]))
|
||||||
contact.source_revision = snapshot.get("source_revision")
|
contact.metadata_ = copy.deepcopy(snapshot["metadata"])
|
||||||
contact.source_payload_kind = snapshot.get("source_payload_kind")
|
contact.updated_by_account_id = _account_id(principal)
|
||||||
contact.source_payload_raw = snapshot.get("source_payload_raw")
|
_record_address_contact_change(session, principal, contact=contact, operation="updated", previous=previous)
|
||||||
contact.provenance = dict(snapshot.get("provenance") or {})
|
if previous_deleted_at is not None:
|
||||||
contact.metadata_ = dict(snapshot.get("metadata") or {})
|
delete_contact(session, principal, contact.id)
|
||||||
|
contact.deleted_at = previous_deleted_at
|
||||||
|
|
||||||
run.status = "rolled_back"
|
run.status = "rolled_back"
|
||||||
run.rolled_back_at = utcnow()
|
run.rolled_back_at = utcnow()
|
||||||
@@ -701,6 +757,12 @@ def _plan_rows(
|
|||||||
first_index.setdefault(key, index)
|
first_index.setdefault(key, index)
|
||||||
last_index[key] = index
|
last_index[key] = index
|
||||||
|
|
||||||
|
existing_contacts = _contacts_by_source_refs(
|
||||||
|
session,
|
||||||
|
book_id,
|
||||||
|
[f"import:{profile.profile_key}:{key}" for key in key_counts],
|
||||||
|
)
|
||||||
|
|
||||||
for index, (row_number, row, key) in enumerate(keyed_rows):
|
for index, (row_number, row, key) in enumerate(keyed_rows):
|
||||||
if key_counts[key] > 1:
|
if key_counts[key] > 1:
|
||||||
if config.duplicate_source_key_policy == "reject":
|
if config.duplicate_source_key_policy == "reject":
|
||||||
@@ -721,7 +783,7 @@ def _plan_rows(
|
|||||||
)
|
)
|
||||||
diagnostics.extend(row_diagnostics)
|
diagnostics.extend(row_diagnostics)
|
||||||
source_ref = f"import:{profile.profile_key}:{key}"
|
source_ref = f"import:{profile.profile_key}:{key}"
|
||||||
existing = _contact_by_source_ref(session, book_id, source_ref)
|
existing = existing_contacts.get(source_ref)
|
||||||
payload = _payload_from_mapped(
|
payload = _payload_from_mapped(
|
||||||
mapped,
|
mapped,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
@@ -951,13 +1013,79 @@ def _diagnostic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _contact_by_source_ref(session: Session, book_id: str, source_ref: str) -> Contact | None:
|
def _contacts_by_source_refs(session: Session, book_id: str, source_refs: list[str]) -> dict[str, Contact]:
|
||||||
return (
|
contacts: dict[str, Contact] = {}
|
||||||
|
for offset in range(0, len(source_refs), CONTACT_LOOKUP_BATCH_SIZE):
|
||||||
|
candidates = (
|
||||||
|
select(
|
||||||
|
Contact.id,
|
||||||
|
func.row_number().over(
|
||||||
|
partition_by=Contact.source_ref,
|
||||||
|
order_by=(Contact.created_at.asc(), Contact.id.asc()),
|
||||||
|
).label("source_position"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Contact.address_book_id == book_id,
|
||||||
|
Contact.source_ref.in_(source_refs[offset : offset + CONTACT_LOOKUP_BATCH_SIZE]),
|
||||||
|
)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
session.query(Contact)
|
||||||
|
.join(candidates, candidates.c.id == Contact.id)
|
||||||
|
.filter(candidates.c.source_position == 1)
|
||||||
|
.options(selectinload(Contact.emails), selectinload(Contact.phones), selectinload(Contact.postal_addresses))
|
||||||
|
.populate_existing()
|
||||||
|
.order_by(Contact.created_at.asc(), Contact.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for contact in rows:
|
||||||
|
# Preserve the historical first-match choice for duplicate stored
|
||||||
|
# source references, independently of batch/database row order.
|
||||||
|
contacts.setdefault(str(contact.source_ref), contact)
|
||||||
|
return contacts
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_by_source_ref(session: Session, book_id: str, source_ref: str, *, lock: bool = False) -> Contact | None:
|
||||||
|
query = (
|
||||||
session.query(Contact)
|
session.query(Contact)
|
||||||
.filter(Contact.address_book_id == book_id, Contact.source_ref == source_ref)
|
.filter(Contact.address_book_id == book_id, Contact.source_ref == source_ref)
|
||||||
.order_by(Contact.created_at.asc(), Contact.id.asc())
|
.order_by(Contact.created_at.asc(), Contact.id.asc())
|
||||||
.first()
|
|
||||||
)
|
)
|
||||||
|
if lock:
|
||||||
|
query = query.populate_existing().with_for_update()
|
||||||
|
return query.first()
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_rollback_snapshot(value: object) -> tuple[dict[str, Any], datetime | None]:
|
||||||
|
if (
|
||||||
|
not isinstance(value, dict)
|
||||||
|
or value.get("version") != ROLLBACK_SNAPSHOT_VERSION
|
||||||
|
or "deleted_at" not in value
|
||||||
|
or not isinstance(value.get("contact"), dict)
|
||||||
|
):
|
||||||
|
raise AddressBookError(
|
||||||
|
"This import has incomplete legacy rollback evidence; automatic rollback is unsafe. "
|
||||||
|
"Review and reconcile the affected contacts manually."
|
||||||
|
)
|
||||||
|
snapshot = value["contact"]
|
||||||
|
required_fields = {
|
||||||
|
"display_name", "given_name", "family_name", "organization", "role_title",
|
||||||
|
"note", "tags", "emails", "phones", "postal_addresses", "provenance",
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
not isinstance(snapshot.get("payload"), dict)
|
||||||
|
or not required_fields.issubset(snapshot["payload"])
|
||||||
|
or not {"source_kind", "source_ref", "source_revision", "source_payload_kind", "source_payload_raw", "provenance", "metadata"}.issubset(snapshot)
|
||||||
|
):
|
||||||
|
raise AddressBookError("Import rollback evidence is incomplete; automatic rollback is unsafe.")
|
||||||
|
try:
|
||||||
|
deleted_at = datetime.fromisoformat(value["deleted_at"]) if value["deleted_at"] is not None else None
|
||||||
|
ContactUpdateRequest.model_validate(snapshot["payload"])
|
||||||
|
_validate_point_snapshot(snapshot.get("points"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise AddressBookError("Import rollback evidence is invalid; automatic rollback is unsafe.") from exc
|
||||||
|
return snapshot, deleted_at
|
||||||
|
|
||||||
|
|
||||||
def _stamp_import_contact(contact: Contact, *, run: AddressImportRun, item: dict[str, Any]) -> None:
|
def _stamp_import_contact(contact: Contact, *, run: AddressImportRun, item: dict[str, Any]) -> None:
|
||||||
@@ -1007,12 +1135,176 @@ def _contact_snapshot(contact: Contact) -> dict[str, Any]:
|
|||||||
"source_payload_kind": contact.source_payload_kind,
|
"source_payload_kind": contact.source_payload_kind,
|
||||||
"source_payload_raw": contact.source_payload_raw,
|
"source_payload_raw": contact.source_payload_raw,
|
||||||
"provenance": dict(contact.provenance or {}),
|
"provenance": dict(contact.provenance or {}),
|
||||||
"metadata": dict(contact.metadata_ or {}),
|
"metadata": copy.deepcopy(contact.metadata_),
|
||||||
|
"points": {
|
||||||
|
name: [_point_snapshot(point) for point in getattr(contact, name)]
|
||||||
|
for name in CONTACT_POINTS
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _contact_hash(contact: Contact) -> str:
|
def _contact_hash(contact: Contact) -> str:
|
||||||
return _hash_json({**_contact_snapshot(contact), "deleted_at": contact.deleted_at.isoformat() if contact.deleted_at else None})
|
return _hash_json({**_contact_snapshot(contact), "deleted_at": _deleted_at_value(contact.deleted_at)})
|
||||||
|
|
||||||
|
|
||||||
|
def _point_snapshot(point: ContactEmail | ContactPhone | ContactPostalAddress) -> dict[str, Any]:
|
||||||
|
# Include every persisted evidence field, including identity, originals,
|
||||||
|
# normalization, provenance, ordering and timestamps, but never the parent FK.
|
||||||
|
return {
|
||||||
|
column.key: (
|
||||||
|
_deleted_at_value(getattr(point, column.key))
|
||||||
|
if column.key in {"created_at", "updated_at"}
|
||||||
|
else copy.deepcopy(getattr(point, column.key))
|
||||||
|
)
|
||||||
|
for column in point.__table__.columns if column.key != "contact_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_contact_points(session: Session, contact: Contact) -> None:
|
||||||
|
# The parent is locked by the caller. Point-only evidence updates need their
|
||||||
|
# own row locks; they need not update the parent row on PostgreSQL.
|
||||||
|
for name, (model, _identity, _replace) in CONTACT_POINTS.items():
|
||||||
|
positions = {point.id: index for index, point in enumerate(getattr(contact, name))}
|
||||||
|
points = session.query(model).filter(model.contact_id == contact.id).order_by(
|
||||||
|
model.id.asc(),
|
||||||
|
).populate_existing().with_for_update().all()
|
||||||
|
# Lock acquisition order is not presentation order. Preserve the
|
||||||
|
# relationship's existing order for tied indexes; new identities still
|
||||||
|
# enter the hash and therefore cannot evade the reviewed-state guard.
|
||||||
|
points.sort(key=lambda point: (point.order_index, positions.get(point.id, len(positions)), point.id))
|
||||||
|
set_committed_value(contact, name, points)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_point_snapshot(value: object) -> None:
|
||||||
|
if not isinstance(value, dict) or set(value) != set(CONTACT_POINTS):
|
||||||
|
raise ValueError("Missing complete contact-point evidence")
|
||||||
|
for name, (model, _identity, _replace) in CONTACT_POINTS.items():
|
||||||
|
points = value[name]
|
||||||
|
columns = {column.key: column for column in model.__table__.columns if column.key != "contact_id"}
|
||||||
|
ids: set[str] = set()
|
||||||
|
if not isinstance(points, list):
|
||||||
|
raise ValueError("Invalid contact-point collection")
|
||||||
|
for point in points:
|
||||||
|
if not isinstance(point, dict) or set(point) != set(columns):
|
||||||
|
raise ValueError("Incomplete contact-point evidence")
|
||||||
|
for key, column in columns.items():
|
||||||
|
field = point[key]
|
||||||
|
if field is None and column.nullable:
|
||||||
|
continue
|
||||||
|
if key in {"created_at", "updated_at"}:
|
||||||
|
datetime.fromisoformat(field)
|
||||||
|
elif type(field) is not column.type.python_type:
|
||||||
|
raise ValueError("Invalid contact-point evidence type")
|
||||||
|
if not point["id"] or point["id"] in ids:
|
||||||
|
raise ValueError("Invalid contact-point identity")
|
||||||
|
ids.add(point["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _without_contact_points(payload: ContactUpdateRequest) -> ContactUpdateRequest:
|
||||||
|
return ContactUpdateRequest.model_validate(
|
||||||
|
payload.model_dump(exclude=set(CONTACT_POINTS), exclude_unset=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_unreferenced_points(session: Session, contact: Contact, removed: set[str]) -> None:
|
||||||
|
if not removed:
|
||||||
|
return
|
||||||
|
# Do not detach address-list selections or silently retarget point-specific
|
||||||
|
# consent/quality decisions when an import removes or replaces a value.
|
||||||
|
referenced = session.query(AddressListEntry.id).filter(
|
||||||
|
AddressListEntry.contact_id == contact.id,
|
||||||
|
or_(AddressListEntry.contact_email_id.in_(removed), AddressListEntry.contact_postal_address_id.in_(removed)),
|
||||||
|
).first()
|
||||||
|
for model in (ContactChannelRule, ContactPointQualityDecision):
|
||||||
|
if referenced is not None:
|
||||||
|
break
|
||||||
|
referenced = session.query(model.id).filter(
|
||||||
|
model.contact_id == contact.id, model.contact_point_id.in_(removed),
|
||||||
|
).first()
|
||||||
|
if referenced is not None:
|
||||||
|
raise AddressBookError(
|
||||||
|
"An affected contact point has address-list or governance references; "
|
||||||
|
"review and reconcile those references before applying or rolling back this import."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_import_points(session: Session, contact: Contact, payload: ContactUpdateRequest):
|
||||||
|
staged = Contact(
|
||||||
|
source_kind=contact.source_kind, source_ref=contact.source_ref,
|
||||||
|
source_revision=contact.source_revision,
|
||||||
|
created_by_account_id=contact.created_by_account_id,
|
||||||
|
updated_by_account_id=contact.updated_by_account_id,
|
||||||
|
)
|
||||||
|
result = {}
|
||||||
|
removed: set[str] = set()
|
||||||
|
for name, (_model, identity, replace) in CONTACT_POINTS.items():
|
||||||
|
if name not in payload.model_fields_set:
|
||||||
|
continue
|
||||||
|
replace(staged, getattr(payload, name) or [])
|
||||||
|
available = list(getattr(contact, name))
|
||||||
|
matches = []
|
||||||
|
for candidate in list(getattr(staged, name)):
|
||||||
|
# Detach the normalization-only parent before a new point enters the
|
||||||
|
# persistent collection; otherwise save-update cascade can enlist it.
|
||||||
|
candidate.contact = None
|
||||||
|
original = next((point for point in available if all(
|
||||||
|
getattr(point, key) == getattr(candidate, key) for key in identity
|
||||||
|
)), None)
|
||||||
|
if original is not None:
|
||||||
|
available.remove(original)
|
||||||
|
matches.append((original, candidate))
|
||||||
|
removed.update(point.id for point in available)
|
||||||
|
result[name] = matches
|
||||||
|
_require_unreferenced_points(session, contact, removed)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_import_points(contact: Contact, prepared) -> None:
|
||||||
|
for name, matches in prepared.items():
|
||||||
|
points = []
|
||||||
|
for original, candidate in matches:
|
||||||
|
if original is None:
|
||||||
|
points.append(candidate)
|
||||||
|
else:
|
||||||
|
# Same value retains its exact original/normalized evidence and
|
||||||
|
# identity; only explicitly imported presentation fields change.
|
||||||
|
for field in ("label", "is_primary", "order_index"):
|
||||||
|
setattr(original, field, getattr(candidate, field))
|
||||||
|
points.append(original)
|
||||||
|
setattr(contact, name, points)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_point_restoration(session: Session, contact: Contact, points: dict[str, Any]) -> None:
|
||||||
|
removed: set[str] = set()
|
||||||
|
for name, (model, _identity, _replace) in CONTACT_POINTS.items():
|
||||||
|
desired = {point["id"] for point in points[name]}
|
||||||
|
removed.update(point.id for point in getattr(contact, name) if point.id not in desired)
|
||||||
|
# A deleted original identity must never be reclaimed from another contact.
|
||||||
|
if desired and session.query(model.id).filter(
|
||||||
|
model.id.in_(desired), model.contact_id != contact.id,
|
||||||
|
).first() is not None:
|
||||||
|
raise AddressBookError("Contact-point identity changed; automatic rollback is unsafe.")
|
||||||
|
_require_unreferenced_points(session, contact, removed)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_contact_points(contact: Contact, snapshots: dict[str, Any]) -> None:
|
||||||
|
for name, (model, _identity, _replace) in CONTACT_POINTS.items():
|
||||||
|
existing = {point.id: point for point in getattr(contact, name)}
|
||||||
|
restored = []
|
||||||
|
for snapshot in snapshots[name]:
|
||||||
|
point = existing.get(snapshot["id"])
|
||||||
|
if point is None:
|
||||||
|
point = model()
|
||||||
|
for key, value in snapshot.items():
|
||||||
|
setattr(point, key, datetime.fromisoformat(value) if key in {"created_at", "updated_at"} else copy.deepcopy(value))
|
||||||
|
restored.append(point)
|
||||||
|
setattr(contact, name, restored)
|
||||||
|
|
||||||
|
|
||||||
|
def _deleted_at_value(value: datetime | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return (value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def _hash_json(value: object) -> str:
|
def _hash_json(value: object) -> str:
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_addresses.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -11,12 +14,22 @@ from govoplan_addresses.backend.capabilities import (
|
|||||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
|
||||||
)
|
)
|
||||||
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
|
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from govoplan_core.core.access import (
|
||||||
from govoplan_core.core.contact_points import CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.contact_points import (
|
||||||
|
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_guards import (
|
||||||
|
drop_table_retirement_provider,
|
||||||
|
persistent_table_uninstall_guard,
|
||||||
|
)
|
||||||
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
|
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
|
||||||
from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS
|
from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
@@ -38,6 +51,7 @@ from govoplan_core.core.provider_governance import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_addresses.backend.dsar_provider import ADDRESSES_DSAR_CAPABILITY
|
||||||
from govoplan_addresses.backend.provider_state import (
|
from govoplan_addresses.backend.provider_state import (
|
||||||
CARDDAV_PROVIDER_ID,
|
CARDDAV_PROVIDER_ID,
|
||||||
LDAP_PROVIDER_ID,
|
LDAP_PROVIDER_ID,
|
||||||
@@ -46,6 +60,13 @@ from govoplan_addresses.backend.provider_state import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _addresses_dsar_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_addresses.backend.dsar_provider import AddressesDsarProvider
|
||||||
|
|
||||||
|
return AddressesDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
_addresses_table_retirement_provider = drop_table_retirement_provider(
|
_addresses_table_retirement_provider = drop_table_retirement_provider(
|
||||||
addresses_models.AddressImportRun,
|
addresses_models.AddressImportRun,
|
||||||
addresses_models.AddressImportProfile,
|
addresses_models.AddressImportProfile,
|
||||||
@@ -77,10 +98,18 @@ def _addresses_retirement_provider(session: object | None, module_id: str):
|
|||||||
return plan
|
return plan
|
||||||
|
|
||||||
def executor(execute_session: object, execute_module_id: str) -> None:
|
def executor(execute_session: object, execute_module_id: str) -> None:
|
||||||
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
|
if not hasattr(execute_session, "get_bind") or not hasattr(
|
||||||
raise RuntimeError("No database session is available for Addresses credential retirement.")
|
execute_session, "query"
|
||||||
if inspect(execute_session.get_bind()).has_table(addresses_models.AddressSyncSource.__tablename__):
|
):
|
||||||
from govoplan_addresses.backend.service import audit_address_credentials_for_retirement
|
raise RuntimeError(
|
||||||
|
"No database session is available for Addresses credential retirement."
|
||||||
|
)
|
||||||
|
if inspect(execute_session.get_bind()).has_table(
|
||||||
|
addresses_models.AddressSyncSource.__tablename__
|
||||||
|
):
|
||||||
|
from govoplan_addresses.backend.service import (
|
||||||
|
audit_address_credentials_for_retirement,
|
||||||
|
)
|
||||||
|
|
||||||
audit_address_credentials_for_retirement(execute_session)
|
audit_address_credentials_for_retirement(execute_session)
|
||||||
base_executor(execute_session, execute_module_id)
|
base_executor(execute_session, execute_module_id)
|
||||||
@@ -110,21 +139,77 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission("addresses:address_book:read", "View address books", "List address books visible to the current principal."),
|
_permission(
|
||||||
_permission("addresses:address_book:write", "Manage address books", "Create and edit local address books."),
|
"addresses:address_book:read",
|
||||||
_permission("addresses:address_book:delete", "Delete address books", "Soft-delete local address books."),
|
"View address books",
|
||||||
_permission("addresses:address_book:admin", "Administer address books", "Manage system-scoped address books and future sync sources."),
|
"List address books visible to the current principal.",
|
||||||
_permission("addresses:address_list:read", "View address lists", "List reusable address lists and their entries."),
|
),
|
||||||
_permission("addresses:address_list:write", "Manage address lists", "Create and edit reusable address lists."),
|
_permission(
|
||||||
_permission("addresses:address_list:delete", "Delete address lists", "Soft-delete reusable address lists."),
|
"addresses:address_book:write",
|
||||||
_permission("addresses:contact:read", "View contacts", "List and lookup contacts in visible address books."),
|
"Manage address books",
|
||||||
_permission("addresses:contact:write", "Manage contacts", "Create and edit local contacts."),
|
"Create and edit local address books.",
|
||||||
_permission("addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."),
|
),
|
||||||
_permission("addresses:governance:read", "View communication governance", "Inspect effective-dated consent, suppression, and channel-preference facts."),
|
_permission(
|
||||||
_permission("addresses:governance:write", "Manage communication governance", "Record and end consent, suppression, and channel-preference facts."),
|
"addresses:address_book:delete",
|
||||||
_permission("addresses:sync:read", "View address sync", "Inspect address sync sources, conflicts, tombstones, and diagnostics."),
|
"Delete address books",
|
||||||
_permission("addresses:sync:write", "Manage address sync", "Bind address books to external sources and record sync state."),
|
"Soft-delete local address books.",
|
||||||
_permission("addresses:sync:admin", "Administer address sync", "Administer address sync connectors and future destructive sync operations."),
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:address_book:admin",
|
||||||
|
"Administer address books",
|
||||||
|
"Manage system-scoped address books and future sync sources.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:address_list:read",
|
||||||
|
"View address lists",
|
||||||
|
"List reusable address lists and their entries.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:address_list:write",
|
||||||
|
"Manage address lists",
|
||||||
|
"Create and edit reusable address lists.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:address_list:delete",
|
||||||
|
"Delete address lists",
|
||||||
|
"Soft-delete reusable address lists.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:contact:read",
|
||||||
|
"View contacts",
|
||||||
|
"List and lookup contacts in visible address books.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:contact:write", "Manage contacts", "Create and edit local contacts."
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:contact:delete", "Delete contacts", "Soft-delete local contacts."
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:governance:read",
|
||||||
|
"View communication governance",
|
||||||
|
"Inspect effective-dated consent, suppression, and channel-preference facts.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:governance:write",
|
||||||
|
"Manage communication governance",
|
||||||
|
"Record and end consent, suppression, and channel-preference facts.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:sync:read",
|
||||||
|
"View address sync",
|
||||||
|
"Inspect address sync sources, conflicts, tombstones, and diagnostics.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:sync:write",
|
||||||
|
"Manage address sync",
|
||||||
|
"Bind address books to external sources and record sync state.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"addresses:sync:admin",
|
||||||
|
"Administer address sync",
|
||||||
|
"Administer address sync connectors and future destructive sync operations.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +238,13 @@ ROLE_TEMPLATES = (
|
|||||||
slug="address_book_reader",
|
slug="address_book_reader",
|
||||||
name="Address book reader",
|
name="Address book reader",
|
||||||
description="Read visible address books and contacts.",
|
description="Read visible address books and contacts.",
|
||||||
permissions=("addresses:address_book:read", "addresses:address_list:read", "addresses:contact:read", "addresses:governance:read", "addresses:sync:read"),
|
permissions=(
|
||||||
|
"addresses:address_book:read",
|
||||||
|
"addresses:address_list:read",
|
||||||
|
"addresses:contact:read",
|
||||||
|
"addresses:governance:read",
|
||||||
|
"addresses:sync:read",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -172,15 +263,42 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"address_books": session.query(AddressBook).filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None)).count(),
|
"address_books": session.query(AddressBook)
|
||||||
"address_lists": session.query(AddressList).filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None)).count(),
|
.filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None))
|
||||||
"contacts": session.query(Contact).filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None)).count(),
|
.count(),
|
||||||
"active_contact_merges": session.query(ContactMergeRecord).filter(ContactMergeRecord.tenant_id == tenant_id, ContactMergeRecord.status == "active").count(),
|
"address_lists": session.query(AddressList)
|
||||||
"contact_quality_decisions": session.query(ContactPointQualityDecision).filter(ContactPointQualityDecision.tenant_id == tenant_id).count(),
|
.filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None))
|
||||||
"contact_point_snapshots": session.query(ContactPointSnapshot).filter(ContactPointSnapshot.tenant_id == tenant_id).count(),
|
.count(),
|
||||||
"sync_sources": session.query(AddressSyncSource).filter(AddressSyncSource.tenant_id == tenant_id, AddressSyncSource.enabled.is_(True)).count(),
|
"contacts": session.query(Contact)
|
||||||
"address_import_profiles": session.query(AddressImportProfile).filter(AddressImportProfile.tenant_id == tenant_id, AddressImportProfile.is_current.is_(True)).count(),
|
.filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None))
|
||||||
"address_import_runs": session.query(AddressImportRun).filter(AddressImportRun.tenant_id == tenant_id).count(),
|
.count(),
|
||||||
|
"active_contact_merges": session.query(ContactMergeRecord)
|
||||||
|
.filter(
|
||||||
|
ContactMergeRecord.tenant_id == tenant_id,
|
||||||
|
ContactMergeRecord.status == "active",
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
"contact_quality_decisions": session.query(ContactPointQualityDecision)
|
||||||
|
.filter(ContactPointQualityDecision.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
|
"contact_point_snapshots": session.query(ContactPointSnapshot)
|
||||||
|
.filter(ContactPointSnapshot.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
|
"sync_sources": session.query(AddressSyncSource)
|
||||||
|
.filter(
|
||||||
|
AddressSyncSource.tenant_id == tenant_id,
|
||||||
|
AddressSyncSource.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
"address_import_profiles": session.query(AddressImportProfile)
|
||||||
|
.filter(
|
||||||
|
AddressImportProfile.tenant_id == tenant_id,
|
||||||
|
AddressImportProfile.is_current.is_(True),
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
"address_import_runs": session.query(AddressImportRun)
|
||||||
|
.filter(AddressImportRun.tenant_id == tenant_id)
|
||||||
|
.count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -200,13 +318,28 @@ CARDDAV_PROVIDER = ExternalProviderDeclaration(
|
|||||||
ProviderObjectDeclaration(
|
ProviderObjectDeclaration(
|
||||||
object_type="address_book",
|
object_type="address_book",
|
||||||
field_groups=("identity", "display", "sync_state"),
|
field_groups=("identity", "display", "sync_state"),
|
||||||
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
|
authority_modes=(
|
||||||
|
"external_authoritative",
|
||||||
|
"external_mirror",
|
||||||
|
"governed_sync",
|
||||||
|
),
|
||||||
default_authority_mode="external_mirror",
|
default_authority_mode="external_mirror",
|
||||||
),
|
),
|
||||||
ProviderObjectDeclaration(
|
ProviderObjectDeclaration(
|
||||||
object_type="contact",
|
object_type="contact",
|
||||||
field_groups=("identity", "name", "postal", "email", "phone", "source_metadata"),
|
field_groups=(
|
||||||
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
|
"identity",
|
||||||
|
"name",
|
||||||
|
"postal",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"source_metadata",
|
||||||
|
),
|
||||||
|
authority_modes=(
|
||||||
|
"external_authoritative",
|
||||||
|
"external_mirror",
|
||||||
|
"governed_sync",
|
||||||
|
),
|
||||||
default_authority_mode="governed_sync",
|
default_authority_mode="governed_sync",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -252,7 +385,15 @@ LDAP_PROVIDER = ExternalProviderDeclaration(
|
|||||||
objects=(
|
objects=(
|
||||||
ProviderObjectDeclaration(
|
ProviderObjectDeclaration(
|
||||||
object_type="contact",
|
object_type="contact",
|
||||||
field_groups=("identity", "name", "organization", "postal", "email", "phone", "source_metadata"),
|
field_groups=(
|
||||||
|
"identity",
|
||||||
|
"name",
|
||||||
|
"organization",
|
||||||
|
"postal",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"source_metadata",
|
||||||
|
),
|
||||||
authority_modes=("external_authoritative", "external_mirror"),
|
authority_modes=("external_authoritative", "external_mirror"),
|
||||||
default_authority_mode="external_authoritative",
|
default_authority_mode="external_authoritative",
|
||||||
),
|
),
|
||||||
@@ -281,7 +422,11 @@ LDAP_PROVIDER = ExternalProviderDeclaration(
|
|||||||
reconciliation="Only a complete paged search may infer an absent source object and create a local tombstone.",
|
reconciliation="Only a complete paged search may infer an absent source object and create a local tombstone.",
|
||||||
outage="Existing contacts remain available and visibly stale; an unavailable directory never causes deletes.",
|
outage="Existing contacts remain available and visibly stale; an unavailable directory never causes deletes.",
|
||||||
classifications=("personal", "confidential", "restricted"),
|
classifications=("personal", "confidential", "restricted"),
|
||||||
purposes=("directory projection", "recipient resolution", "identity-linked contact discovery"),
|
purposes=(
|
||||||
|
"directory projection",
|
||||||
|
"recipient resolution",
|
||||||
|
"identity-linked contact discovery",
|
||||||
|
),
|
||||||
retention="Address, audit, and records policies govern local projections and tombstone evidence.",
|
retention="Address, audit, and records policies govern local projections and tombstone evidence.",
|
||||||
secret_handling="Bind secrets remain in reusable credential envelopes; URLs, previews, and diagnostics contain no credentials.",
|
secret_handling="Bind secrets remain in reusable credential envelopes; URLs, previews, and diagnostics contain no credentials.",
|
||||||
),
|
),
|
||||||
@@ -293,27 +438,72 @@ LDAP_PROVIDER = ExternalProviderDeclaration(
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="addresses",
|
id="addresses",
|
||||||
name="Addresses",
|
name="Addresses",
|
||||||
version="0.1.18",
|
version="0.1.23",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(
|
||||||
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox", "connectors"),
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
|
optional_dependencies=(
|
||||||
|
"campaigns",
|
||||||
|
"mail",
|
||||||
|
"forms",
|
||||||
|
"reporting",
|
||||||
|
"portal",
|
||||||
|
"postbox",
|
||||||
|
"connectors",
|
||||||
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
|
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
|
ModuleInterfaceProvider(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"),
|
name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, version="1.0.0"),
|
),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
|
ModuleInterfaceProvider(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"),
|
name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, version="1.0.0"
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(name=ADDRESSES_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
route_factory=_addresses_router,
|
route_factory=_addresses_router,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),),
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/address-book",
|
||||||
|
label="Address Book",
|
||||||
|
icon="book-user",
|
||||||
|
required_any=("addresses:contact:read",),
|
||||||
|
order=80,
|
||||||
|
),
|
||||||
|
),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="addresses",
|
module_id="addresses",
|
||||||
package_name="@govoplan/addresses-webui",
|
package_name="@govoplan/addresses-webui",
|
||||||
routes=(FrontendRoute(path="/address-book", component="AddressBookPage", required_any=("addresses:contact:read",), order=80),),
|
routes=(
|
||||||
nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),),
|
FrontendRoute(
|
||||||
|
path="/address-book",
|
||||||
|
component="AddressBookPage",
|
||||||
|
required_any=("addresses:contact:read",),
|
||||||
|
order=80,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/address-book",
|
||||||
|
label="Address Book",
|
||||||
|
icon="book-user",
|
||||||
|
required_any=("addresses:contact:read",),
|
||||||
|
order=80,
|
||||||
|
),
|
||||||
|
),
|
||||||
product_areas=(
|
product_areas=(
|
||||||
ProductAreaContribution(
|
ProductAreaContribution(
|
||||||
id="people-responsibility",
|
id="people-responsibility",
|
||||||
@@ -321,17 +511,56 @@ manifest = ModuleManifest(
|
|||||||
label="i18n:govoplan-core.product_area.people_responsibility",
|
label="i18n:govoplan-core.product_area.people_responsibility",
|
||||||
icon="users",
|
icon="users",
|
||||||
description="i18n:govoplan-core.product_area.people_responsibility_description",
|
description="i18n:govoplan-core.product_area.people_responsibility_description",
|
||||||
surface_ids=("addresses.nav.address.book", "addresses.route.address.book"),
|
surface_ids=(
|
||||||
|
"addresses.nav.address.book",
|
||||||
|
"addresses.route.address.book",
|
||||||
|
),
|
||||||
order=70,
|
order=70,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
ViewSurface(id="addresses.page", module_id="addresses", kind="route", label="Address Book", order=80),
|
ViewSurface(
|
||||||
ViewSurface(id="addresses.sources", module_id="addresses", kind="section", label="Address sources", order=10),
|
id="addresses.page",
|
||||||
ViewSurface(id="addresses.contacts", module_id="addresses", kind="section", label="Contacts", order=20),
|
module_id="addresses",
|
||||||
ViewSurface(id="addresses.detail", module_id="addresses", kind="section", label="Contact detail", order=30),
|
kind="route",
|
||||||
ViewSurface(id="addresses.governance", module_id="addresses", kind="action", label="Communication governance", order=40),
|
label="Address Book",
|
||||||
ViewSurface(id="addresses.sync", module_id="addresses", kind="action", label="Address synchronization", order=50),
|
order=80,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="addresses.sources",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="section",
|
||||||
|
label="Address sources",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="addresses.contacts",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="section",
|
||||||
|
label="Contacts",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="addresses.detail",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="section",
|
||||||
|
label="Contact detail",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="addresses.governance",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="action",
|
||||||
|
label="Communication governance",
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="addresses.sync",
|
||||||
|
module_id="addresses",
|
||||||
|
kind="action",
|
||||||
|
label="Address synchronization",
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
@@ -343,8 +572,13 @@ manifest = ModuleManifest(
|
|||||||
retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.",
|
retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.",
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]).lookup_capability(context),
|
CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__(
|
||||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["people_search_capability"]).people_search_capability(context),
|
"govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]
|
||||||
|
).lookup_capability(context),
|
||||||
|
CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda context: __import__(
|
||||||
|
"govoplan_addresses.backend.capabilities",
|
||||||
|
fromlist=["people_search_capability"],
|
||||||
|
).people_search_capability(context),
|
||||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__(
|
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__(
|
||||||
"govoplan_addresses.backend.capabilities",
|
"govoplan_addresses.backend.capabilities",
|
||||||
fromlist=["recipient_source_capability"],
|
fromlist=["recipient_source_capability"],
|
||||||
@@ -361,6 +595,20 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_addresses.backend.capabilities",
|
"govoplan_addresses.backend.capabilities",
|
||||||
fromlist=["contact_point_resolution_capability"],
|
fromlist=["contact_point_resolution_capability"],
|
||||||
).contact_point_resolution_capability(context),
|
).contact_point_resolution_capability(context),
|
||||||
|
ADDRESSES_DSAR_CAPABILITY: _addresses_dsar_provider,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
ADDRESSES_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Addresses data-subject request provider",
|
||||||
|
summary=(
|
||||||
|
"Finds bounded contact, contact-point, address-list, governance, "
|
||||||
|
"provenance, synchronization, and operator-attribution data without "
|
||||||
|
"exporting raw source payloads, connector state, or opaque evidence."
|
||||||
|
),
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "addresses_admin", "records_manager"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
@@ -387,24 +635,138 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="addresses.privacy.data-subject-requests",
|
||||||
|
title="Review Addresses data in a data-subject request",
|
||||||
|
summary=(
|
||||||
|
"Collect tenant-scoped contact data while preserving shared address, "
|
||||||
|
"recipient, synchronization, and provenance evidence."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Addresses searches corroborated email and account selectors plus "
|
||||||
|
"namespaced contact and contact-point references. A matching contact "
|
||||||
|
"exports bounded identity, email, telephone, and postal values together "
|
||||||
|
"with its address-list use and minimized governance, quality, provenance, "
|
||||||
|
"merge, redirect, and synchronization evidence. Account matches add only "
|
||||||
|
"minimized operator attribution for governed configuration and evidence. "
|
||||||
|
"The provider excludes raw imported or synchronized source payloads, "
|
||||||
|
"connector tokens and revisions that could act as credentials, opaque "
|
||||||
|
"metadata, snapshot request and resolution payloads, import plans, merge "
|
||||||
|
"before/after payloads, unrelated contacts, and other tenants. Quality, "
|
||||||
|
"governance, provenance, merge, redirect, synchronization, import, "
|
||||||
|
"snapshot, and operator evidence is retained with an explicit reason. "
|
||||||
|
"Because reusable contacts can be shared, synchronized, merged, or "
|
||||||
|
"referenced by immutable recipient snapshots, the DSAR provider never "
|
||||||
|
"deletes them automatically. An authorized operator must review "
|
||||||
|
"dependencies and use the normal Addresses correction, archive, source, "
|
||||||
|
"merge, or governance workflow."
|
||||||
|
),
|
||||||
|
layer="static",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=(
|
||||||
|
"privacy_officer",
|
||||||
|
"addresses_admin",
|
||||||
|
"records_manager",
|
||||||
|
"operator",
|
||||||
|
),
|
||||||
|
related_modules=(
|
||||||
|
"access",
|
||||||
|
"audit",
|
||||||
|
"campaigns",
|
||||||
|
"dist_lists",
|
||||||
|
"records",
|
||||||
|
),
|
||||||
|
order=29,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Addresses-Daten in einer Betroffenenanfrage prüfen",
|
||||||
|
"summary": (
|
||||||
|
"Mandantenbezogene Kontaktdaten erfassen und dabei gemeinsame Adress-, Empfänger-, Synchronisations- und "
|
||||||
|
"Herkunftsnachweise bewahren."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Addresses durchsucht bestätigte E-Mail- und Kontoselektoren sowie namensraumgebundene Kontakt- und "
|
||||||
|
"Kontaktpunktverweise. Zu einem passenden Kontakt werden begrenzte Identitäts-, E-Mail-, Telefon- und Postwerte "
|
||||||
|
"einschließlich seiner Adresslistennutzung und minimierter Nachweise zu Governance, Qualität, Herkunft, Zusammenführung, "
|
||||||
|
"Weiterleitung und Synchronisation exportiert. Kontotreffer ergänzen nur minimierte Zuordnungen von Betriebspersonen zu "
|
||||||
|
"gesteuerter Konfiguration und Nachweisen. Ausgeschlossen sind rohe importierte oder synchronisierte Quelldaten, "
|
||||||
|
"Connector-Token und Revisionen mit Zugangsdatencharakter, undurchsichtige Metadaten, Anfrage- und Auflösungsnutzdaten "
|
||||||
|
"von Snapshots, Importpläne, Vorher-/Nachher-Daten von Zusammenführungen, unbeteiligte Kontakte und andere Mandanten. "
|
||||||
|
"Nachweise zu Qualität, Governance, Herkunft, Zusammenführung, Weiterleitung, Synchronisation, Import, Snapshot und "
|
||||||
|
"Betriebszuordnung werden mit ausdrücklicher Begründung aufbewahrt. Weil wiederverwendbare Kontakte geteilt, "
|
||||||
|
"synchronisiert, zusammengeführt oder von unveränderlichen Empfänger-Snapshots referenziert sein können, löscht der "
|
||||||
|
"DSAR-Provider sie niemals automatisch. Eine berechtigte Betriebsperson muss Abhängigkeiten prüfen und den regulären "
|
||||||
|
"Addresses-Ablauf für Korrektur, Archivierung, Quelle, Zusammenführung oder Governance verwenden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"seed": True,
|
||||||
|
"help_contexts": [
|
||||||
|
"addresses.contacts",
|
||||||
|
"addresses.governance",
|
||||||
|
"addresses.action.archive",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="addresses.boundary",
|
id="addresses.boundary",
|
||||||
title="Reusable address ownership",
|
title="Reusable address ownership",
|
||||||
summary="Reusable person, organization, household, postal, and email recipient sources belong to the addresses module.",
|
summary="Reusable person, organization, household, postal, and email recipient sources belong to the addresses module.",
|
||||||
body=(
|
body=(
|
||||||
|
"Open the book beside Address books for documentation of the address workspace. "
|
||||||
"Campaigns may keep immutable campaign-local recipient snapshots, but durable address directories, "
|
"Campaigns may keep immutable campaign-local recipient snapshots, but durable address directories, "
|
||||||
"recipient-source definitions, consent metadata, provenance, deduplication, and import/export workflows "
|
"recipient-source definitions, consent metadata, provenance, deduplication, and import/export workflows "
|
||||||
"are owned by govoplan-addresses."
|
"are owned by govoplan-addresses. The Address Book workspace keeps Reload directly "
|
||||||
|
"before Add address book at the upper right. Import / export, Connections, and Address "
|
||||||
|
"quality open labelled, scoped tools; Manage applies to the selected book or list and "
|
||||||
|
"separates archive actions from editing. Export version is chosen in Import / export. "
|
||||||
|
"Contact creation remains beside the contact list. Folder icons alone expand or collapse "
|
||||||
|
"the tree; labels select a group, book, or list without changing expansion. A selected "
|
||||||
|
"group is navigation, not an aggregate contact book. Reload preserves collapsed "
|
||||||
|
"branches. All permission and read-only reasons, confirmations, import safeguards, "
|
||||||
|
"and contact-to-list drag and drop still apply."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "operator", "module_admin"),
|
audience=("tenant_admin", "operator", "module_admin"),
|
||||||
related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
|
related_modules=(
|
||||||
|
"campaigns",
|
||||||
|
"mail",
|
||||||
|
"forms",
|
||||||
|
"reporting",
|
||||||
|
"portal",
|
||||||
|
"postbox",
|
||||||
|
),
|
||||||
order=30,
|
order=30,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Zuständigkeit für wiederverwendbare Adressen",
|
||||||
|
"summary": (
|
||||||
|
"Wiederverwendbare Personen-, Organisations-, Haushalts-, Post- und E-Mail-Empfängerquellen gehören dem "
|
||||||
|
"Addresses-Modul."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Öffnen Sie das Buch neben Adressbücher für die Dokumentation des Adressarbeitsbereichs. "
|
||||||
|
"Campaigns darf unveränderliche campaignlokale Empfänger-Snapshots halten. Dauerhafte Adressverzeichnisse, "
|
||||||
|
"Empfängerquellendefinitionen, Einwilligungsmetadaten, Herkunft, Dublettenbereinigung sowie Import- und Exportabläufe "
|
||||||
|
"gehören jedoch govoplan-addresses. Im Adressbuch steht Neuladen oben rechts unmittelbar vor "
|
||||||
|
"Adressbuch hinzufügen. Import / Export, Verbindungen und Adressqualität öffnen beschriftete, "
|
||||||
|
"kontextbezogene Werkzeuge. Verwalten bezieht sich auf das ausgewählte Adressbuch oder die Liste "
|
||||||
|
"und trennt Archivieren vom Bearbeiten. Die Exportversion wird unter Import / Export gewählt. "
|
||||||
|
"Kontakte werden weiterhin direkt neben der Kontaktliste angelegt. Nur Ordnersymbole klappen "
|
||||||
|
"den Baum auf oder zu; Beschriftungen wählen eine Gruppe, ein Adressbuch oder eine Liste aus, "
|
||||||
|
"ohne die Aufklappstellung zu ändern. Eine ausgewählte Gruppe dient der Navigation und ist "
|
||||||
|
"kein zusammengefasstes Adressbuch. Neuladen bewahrt zugeklappte Zweige. Berechtigungs- und "
|
||||||
|
"Schreibschutzgründe, Bestätigungen, Importsicherungen und das Ziehen von Kontakten in Listen gelten unverändert."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"addresses.page",
|
"addresses.page",
|
||||||
|
"addresses.explorer.transfer",
|
||||||
"addresses.sources",
|
"addresses.sources",
|
||||||
"addresses.contacts",
|
"addresses.contacts",
|
||||||
"addresses.detail",
|
"addresses.detail",
|
||||||
@@ -427,6 +789,18 @@ manifest = ModuleManifest(
|
|||||||
audience=("tenant_admin", "operator", "module_admin"),
|
audience=("tenant_admin", "operator", "module_admin"),
|
||||||
related_modules=("dist_lists", "campaigns", "policy", "templates"),
|
related_modules=("dist_lists", "campaigns", "policy", "templates"),
|
||||||
order=31,
|
order=31,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Kontaktpunktauflösung und Snapshots",
|
||||||
|
"summary": "Zweckbezogene Kanalziele auflösen und unveränderliche Empfängernachweise einfrieren.",
|
||||||
|
"body": (
|
||||||
|
"Addresses stellt eine versionierte Kontaktpunktfähigkeit für E-Mail-, Post-, Hauspost- und Portalziele bereit. "
|
||||||
|
"Aufrufende können Wirksamkeitsdatum, Kommunikationszweck, Adresszweck, Rückfallregel, Spracheinstellung und Postformat "
|
||||||
|
"angeben. Begrenzte Vorschauen bleiben aktuell; eingefrorene Snapshots bewahren aufgelöste Werte, Ausschlüsse, Quellen- "
|
||||||
|
"und Governance-Revisionen, Herkunft und einen deterministischen Nachweishash auch nach späteren Kontaktänderungen."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
@@ -446,7 +820,18 @@ manifest = ModuleManifest(
|
|||||||
"CSV, XLSX, and LDIF files can be mapped with scoped, reusable profile versions. Each preview validates headers or attributes, "
|
"CSV, XLSX, and LDIF files can be mapped with scoped, reusable profile versions. Each preview validates headers or attributes, "
|
||||||
"encodings, source keys, duplicates, blank values, format limits, and contact identity before any mutation. "
|
"encodings, source keys, duplicates, blank values, format limits, and contact identity before any mutation. "
|
||||||
"The reviewed input hash and plan hash are retained with row-level effects and diagnostics. Apply is idempotent, "
|
"The reviewed input hash and plan hash are retained with row-level effects and diagnostics. Apply is idempotent, "
|
||||||
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. A persisted run "
|
"rejects contacts changed after preview, and records sufficient evidence for a guarded rollback. Each new update "
|
||||||
|
"uses version-2 before-images for editable contact values, source metadata, complete contact-point identities, "
|
||||||
|
"original and normalized values, provenance, order and timestamps, and prior deletion state: rolling back an "
|
||||||
|
"import that restored a deleted contact archives it again. Older update runs without complete before-images require "
|
||||||
|
"manual reconciliation; automatic rollback stops before changing any contacts. Created-contact identities and "
|
||||||
|
"after-hashes are persisted with the applied plan and required for rollback; missing older guards or contacts "
|
||||||
|
"moved to another book also require reconciliation. Point-evidence edits are included in change guards. "
|
||||||
|
"Unchanged point values retain their identities and original evidence. Removing or replacing a point referenced "
|
||||||
|
"by an address list or point-specific consent/quality decision requires explicit reconciliation, including when "
|
||||||
|
"a new reference would otherwise be detached by rollback. Current book visibility and change "
|
||||||
|
"guards still apply. Preview source lookups and contact collections are loaded in bounded batches, without changing "
|
||||||
|
"duplicate policies, reviewed hashes, or apply-time validation. A persisted run "
|
||||||
"can be reopened with its run link after navigation or reload; previewed, applied, rolled-back, expired, and "
|
"can be reopened with its run link after navigation or reload; previewed, applied, rolled-back, expired, and "
|
||||||
"unavailable states remain explicit. Both apply and rollback submit the reviewed plan hash. Missing, expired, "
|
"unavailable states remain explicit. Both apply and rollback submit the reviewed plan hash. Missing, expired, "
|
||||||
"hidden, and cross-tenant runs disclose no source payload. XLSX formulas, macros, and legacy workbook formats "
|
"hidden, and cross-tenant runs disclose no source payload. XLSX formulas, macros, and legacy workbook formats "
|
||||||
@@ -457,8 +842,59 @@ manifest = ModuleManifest(
|
|||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "module_admin", "power_user"),
|
audience=("tenant_admin", "module_admin", "power_user"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("addresses",),
|
||||||
|
any_scopes=("addresses:contact:write", "addresses:sync:write"),
|
||||||
|
),
|
||||||
|
),
|
||||||
related_modules=("connectors", "datasources", "dataflow", "files", "audit"),
|
related_modules=("connectors", "datasources", "dataflow", "files", "audit"),
|
||||||
order=33,
|
order=33,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Kontakte aus CSV, XLSX und LDIF importieren",
|
||||||
|
"summary": (
|
||||||
|
"Wiederverwendbare, versionierte Kontaktzuordnungen vorprüfen und anwenden, ohne Zeilen oder Einträge "
|
||||||
|
"stillschweigend zu verlieren."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"CSV-, XLSX- und LDIF-Dateien lassen sich mit bereichsgebundenen, wiederverwendbaren Profilversionen zuordnen. Jede "
|
||||||
|
"Vorschau prüft Überschriften oder Attribute, Kodierung, Quellschlüssel, Dubletten, Leerwerte, Formatgrenzen und "
|
||||||
|
"Kontaktidentität vor jeder Änderung. Der geprüfte Eingabe- und Planhash wird mit zeilenbezogenen Wirkungen und Diagnosen "
|
||||||
|
"aufbewahrt. Die Anwendung ist idempotent, verwirft seit der Vorschau geänderte Kontakte und zeichnet ausreichende "
|
||||||
|
"Nachweise für eine gesicherte Rücknahme auf. Ein gespeicherter Lauf kann nach Navigation oder Neuladen über seinen Link "
|
||||||
|
"erneut geöffnet werden. Vorher-Bilder der Version 2 enthalten bearbeitbare Kontaktwerte, Quellmetadaten, "
|
||||||
|
"vollständige Kontaktpunktkennungen, Original- und normalisierte Werte, Herkunft, Reihenfolge, Zeitstempel und den "
|
||||||
|
"vorherigen Löschzustand: Die Rücknahme archiviert einen durch den Import wiederhergestellten Kontakt erneut. "
|
||||||
|
"Ältere Änderungsläufe ohne vollständige Vorher-Bilder erfordern einen manuellen Abgleich; die automatische "
|
||||||
|
"Rücknahme stoppt vor jeder Kontaktänderung. Kennungen neu angelegter Kontakte und Nachher-Hashes werden mit dem "
|
||||||
|
"angewendeten Plan gespeichert und sind für die Rücknahme erforderlich; fehlende ältere Sicherungen oder in ein "
|
||||||
|
"anderes Buch verschobene Kontakte erfordern ebenfalls einen Abgleich. Änderungen an Punktnachweisen werden vom "
|
||||||
|
"Änderungsschutz erfasst. Unveränderte Punktwerte behalten Kennung und Originalnachweise. Das Entfernen oder "
|
||||||
|
"Ersetzen eines in Adresslisten oder punktspezifischen Einwilligungs-/Qualitätsentscheidungen referenzierten "
|
||||||
|
"Punkts erfordert einen ausdrücklichen Abgleich; dies gilt auch für neue Referenzen, die eine Rücknahme sonst "
|
||||||
|
"lösen würde. Aktuelle Adressbuchsichtbarkeit und "
|
||||||
|
"Änderungsschutz bleiben wirksam. "
|
||||||
|
"Quellzuordnungen und Kontaktpunkte werden für die Vorschau in begrenzten Stapeln geladen, ohne Dublettenregeln, "
|
||||||
|
"geprüfte Hashes oder die erneute Prüfung bei Anwendung zu ändern. Ein gespeicherter Lauf kann über seinen Link "
|
||||||
|
"erneut geöffnet werden; Vorschau-, Anwendungs-, Rücknahme-, Ablauf- und Nichtverfügbarkeitszustände bleiben eindeutig. "
|
||||||
|
"Anwendung und Rücknahme übermitteln den geprüften Planhash. Fehlende, abgelaufene, verborgene und mandantenfremde Läufe "
|
||||||
|
"legen keine Quelldaten offen. XLSX-Formeln, Makros und ältere Arbeitsmappenformate werden niemals ausgeführt oder "
|
||||||
|
"importiert. Gefaltete LDIF-Zeilen, UTF-8- und Base64-Text, wiederholte Attribute und Kommentare werden verarbeitet; "
|
||||||
|
"Binär- und URL-Werte werden weder projiziert noch abgerufen. Änderungsdatensätze gelten standardmäßig als abgelehnte "
|
||||||
|
"Diagnose und dürfen nur über eine ausdrückliche Profilrichtlinie ignoriert oder bei Add-Einträgen als statische Daten "
|
||||||
|
"behandelt werden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"addresses.action.import",
|
||||||
|
"addresses.contacts",
|
||||||
|
"addresses.sources",
|
||||||
|
],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="addresses.vcard-batches",
|
id="addresses.vcard-batches",
|
||||||
@@ -480,9 +916,33 @@ manifest = ModuleManifest(
|
|||||||
audience=("tenant_admin", "operator", "module_admin", "power_user"),
|
audience=("tenant_admin", "operator", "module_admin", "power_user"),
|
||||||
related_modules=("files", "audit", "connectors"),
|
related_modules=("files", "audit", "connectors"),
|
||||||
order=34,
|
order=34,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "vCard-Stapel selektiv importieren und exportieren",
|
||||||
|
"summary": (
|
||||||
|
"Mehrere vCard-Dateien vorprüfen, die Wirkung jeder Karte wählen und deterministische bereichsgebundene Dateien exportieren."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Eine oder mehrere UTF-8-.vcf-Dateien werden in eine gespeicherte, nicht verändernde Vorschau mit begrenzten Diagnosen, "
|
||||||
|
"Dublettenhinweisen, Eingabehash, Parser-Version und deterministischem Planhash eingelesen. Betriebspersonen wählen "
|
||||||
|
"Anlegen, Aktualisieren oder Ignorieren nur dort, wo der geprüfte Plan es erlaubt. Die Anwendung verwirft veraltete "
|
||||||
|
"Kontaktziele und ist für dieselbe Auswahl idempotent; eine abweichende Wiederholung wird abgelehnt. Ausstehende Läufe "
|
||||||
|
"lassen sich neu laden oder abbrechen, ohne Kontakte zu verändern. Uploadgröße, Datei- und Kartenanzahl, Zeilenanzahl und "
|
||||||
|
"Länge entfalteter Zeilen sind begrenzt. Exporte können ein vollständiges Adressbuch, eine Adressliste oder ausgewählte "
|
||||||
|
"Kontakte umfassen; vCard 3.0 oder 4.0 wird ausdrücklich gewählt und Kontakte werden deterministisch nach Anzeigename und "
|
||||||
|
"stabiler Kennung sortiert. Export- und Importnachweise speichern Hashes und Anzahlen, während Diagnosen niemals rohe "
|
||||||
|
"Kontaktdaten offenlegen. Große Vorschauen bleiben gespeichert und geben ihren Stapelausführungsmodus an, sodass eine "
|
||||||
|
"Laufzeit-Jobfähigkeit sie bei Verfügbarkeit asynchron ausführen kann."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": ["addresses.action.import", "addresses.contacts", "addresses.sources"],
|
"help_contexts": [
|
||||||
|
"addresses.action.import",
|
||||||
|
"addresses.contacts",
|
||||||
|
"addresses.sources",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
@@ -501,6 +961,22 @@ manifest = ModuleManifest(
|
|||||||
audience=("tenant_admin", "operator", "module_admin"),
|
audience=("tenant_admin", "operator", "module_admin"),
|
||||||
related_modules=("connectors", "idm", "access", "policy", "audit"),
|
related_modules=("connectors", "idm", "access", "policy", "audit"),
|
||||||
order=35,
|
order=35,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "LDAP- und Active-Directory-Adressquellen",
|
||||||
|
"summary": (
|
||||||
|
"Maßgebliche Verzeichniskontakte über eine begrenzte, schreibgeschützte Synchronisationsquelle projizieren."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"LDAP-Quellen verwenden LDAPS oder StartTLS und wiederverwendbare Zugangsdatenhüllen. Die Ermittlung findet verfügbare "
|
||||||
|
"Basis-DNs; anschließend steuert das Quellprofil einen begrenzten seitenweisen Filter und eine ausdrückliche "
|
||||||
|
"Attributzuordnung. Die Vorschau verändert niemals Kontakte. Ein vollständiger erfolgreicher Lesevorgang darf lokale "
|
||||||
|
"Projektionen anlegen, aktualisieren oder als entfernt markieren; abgeschnittene oder fehlgeschlagene Lesevorgänge "
|
||||||
|
"unterdrücken Löschungen aufgrund von Abwesenheit und markieren die Quelle als veraltet. Stabile Quellschlüssel, "
|
||||||
|
"Revisionen, normalisierte Felder und Herkunft bleiben mit jedem erhaltenen Kontakt verbunden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="addresses.quality-and-merge",
|
id="addresses.quality-and-merge",
|
||||||
@@ -520,6 +996,21 @@ manifest = ModuleManifest(
|
|||||||
audience=("tenant_admin", "operator", "module_admin"),
|
audience=("tenant_admin", "operator", "module_admin"),
|
||||||
related_modules=("campaigns", "dist_lists", "policy", "audit"),
|
related_modules=("campaigns", "dist_lists", "policy", "audit"),
|
||||||
order=32,
|
order=32,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Kontaktqualität, Dubletten und umkehrbare Zusammenführungen",
|
||||||
|
"summary": "Adressqualität und Dublettenvorschläge prüfen, ohne Quellnachweise zu verlieren.",
|
||||||
|
"body": (
|
||||||
|
"Addresses bewahrt ursprüngliche und normalisierte Kontaktpunktwerte, zeichnet die Herkunft je Feld auf und überführt "
|
||||||
|
"ungültige, zurückgesandte, veraltete oder unzustellbare Zustände mit stabilen Grundcodes in die Empfängerauflösung. "
|
||||||
|
"Dublettenvorschläge sind begrenzt und erklären ihre Übereinstimmungsmerkmale. Eine Betriebsperson kann die zu erhaltenden "
|
||||||
|
"Werte wählen, Kontaktpunkte zusammenführen und die Zusammenführung später rückgängig machen oder aufteilen, solange der "
|
||||||
|
"aufgezeichnete Nachweis nach der Zusammenführung noch übereinstimmt. Kontaktweiterleitungen halten gespeicherte Verweise "
|
||||||
|
"auflösbar, und Mitgliedschaften in Adresslisten werden transaktional repariert. Audit bleibt eine optionale Integration; "
|
||||||
|
"die Änderungsfolge und Zusammenführungsnachweise von Addresses werden stets aufbewahrt."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="addresses.reference.fields-and-consequences",
|
id="addresses.reference.fields-and-consequences",
|
||||||
@@ -537,9 +1028,37 @@ manifest = ModuleManifest(
|
|||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "operator", "module_admin", "power_user"),
|
audience=("tenant_admin", "operator", "module_admin", "power_user"),
|
||||||
related_modules=("dist_lists", "connectors", "datasources", "campaigns", "policy", "audit"),
|
related_modules=(
|
||||||
|
"dist_lists",
|
||||||
|
"connectors",
|
||||||
|
"datasources",
|
||||||
|
"campaigns",
|
||||||
|
"policy",
|
||||||
|
"audit",
|
||||||
|
),
|
||||||
order=36,
|
order=36,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Adressfelder, Geltungsbereiche und Folgen von Aktionen",
|
||||||
|
"summary": (
|
||||||
|
"Geltungsbereich, Quellenhoheit, Kontaktpunkte, Listenmitgliedschaft sowie Folgen von Archivierung, Synchronisation und "
|
||||||
|
"Zusammenführung."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Adressbücher sind einer Person, Gruppe, einem Mandanten oder einem berechtigten Systemkontext zugeordnet. Geerbte und "
|
||||||
|
"extern maßgebliche Bücher können sichtbar, aber schreibgeschützt bleiben. Kontakte besitzen wiederverwendbare Angaben "
|
||||||
|
"zu Name, Organisation, elektronischen und telefonischen Kontaktpunkten, Postanschrift, Schlagwörtern, Notizen, Qualität "
|
||||||
|
"und Herkunft. Adresslisten verweisen auf Kontaktpunkte desselben Buchs und ersetzen keine Distribution Lists. Eine "
|
||||||
|
"Archivierung entfernt Buch, Liste oder Kontakt aus der gewöhnlichen Auswahl, bewahrt aber gesteuerte Historie und "
|
||||||
|
"Verweise. CardDAV- und LDAP-Quellen zeigen Richtung, Hoheit, Aktualität, Diagnosen, Konflikte und Verhalten bei "
|
||||||
|
"veraltetem Zustand. Import und Synchronisation erfordern vor jeder Änderung eine Vorschau. Kontaktzusammenführungen "
|
||||||
|
"wählen überlebenden Kontakt und Feldherkunft, reparieren Listenverweise transaktional und bewahren Weiterleitungen und "
|
||||||
|
"Nachweise, sodass eine passende Zusammenführung rückgängig gemacht oder aufgeteilt werden kann."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"addresses.field.book-scope",
|
"addresses.field.book-scope",
|
||||||
@@ -579,15 +1098,27 @@ manifest = ModuleManifest(
|
|||||||
maturity="vertical_slice",
|
maturity="vertical_slice",
|
||||||
documentation_ref="docs/ADDRESS_MODULE_ARCHITECTURE.md",
|
documentation_ref="docs/ADDRESS_MODULE_ARCHITECTURE.md",
|
||||||
test_ref="tests/test_addresses_service.py",
|
test_ref="tests/test_addresses_service.py",
|
||||||
known_limits=("External address-book synchronization remains a bounded connector slice rather than a supported provider profile.",),
|
known_limits=(
|
||||||
|
"External address-book synchronization remains a bounded connector slice rather than a supported provider profile.",
|
||||||
|
),
|
||||||
supported_authority_modes=(
|
supported_authority_modes=(
|
||||||
"native_authoritative",
|
"native_authoritative",
|
||||||
"external_authoritative",
|
"external_authoritative",
|
||||||
"external_mirror",
|
"external_mirror",
|
||||||
"governed_sync",
|
"governed_sync",
|
||||||
),
|
),
|
||||||
owned_concepts=("contact point", "address book", "contact consent", "recipient source"),
|
owned_concepts=(
|
||||||
non_owned_concepts=("identity", "organization", "campaign recipient snapshot", "procedure party"),
|
"contact point",
|
||||||
|
"address book",
|
||||||
|
"contact consent",
|
||||||
|
"recipient source",
|
||||||
|
),
|
||||||
|
non_owned_concepts=(
|
||||||
|
"identity",
|
||||||
|
"organization",
|
||||||
|
"campaign recipient snapshot",
|
||||||
|
"procedure party",
|
||||||
|
),
|
||||||
target_tested_providers=(CARDDAV_PROVIDER_ID,),
|
target_tested_providers=(CARDDAV_PROVIDER_ID,),
|
||||||
security_docs=("docs/ADDRESS_MODULE_ARCHITECTURE.md",),
|
security_docs=("docs/ADDRESS_MODULE_ARCHITECTURE.md",),
|
||||||
operations_docs=("README.md",),
|
operations_docs=("README.md",),
|
||||||
@@ -595,5 +1126,10 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|||||||
@@ -3278,8 +3278,11 @@ def _filtered_contact_query(
|
|||||||
return contact_query
|
return contact_query
|
||||||
|
|
||||||
|
|
||||||
def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False) -> Contact:
|
def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False, lock: bool = False) -> Contact:
|
||||||
contact = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted).filter(Contact.id == contact_id).one_or_none()
|
query = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted).filter(Contact.id == contact_id)
|
||||||
|
if lock:
|
||||||
|
query = query.populate_existing().with_for_update(of=Contact)
|
||||||
|
contact = query.one_or_none()
|
||||||
if contact is None:
|
if contact is None:
|
||||||
raise AddressBookError("Contact not found.")
|
raise AddressBookError("Contact not found.")
|
||||||
return contact
|
return contact
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -6,6 +6,18 @@ from govoplan_addresses.backend.manifest import manifest
|
|||||||
|
|
||||||
|
|
||||||
class AddressesInterfaceDocumentationContractTests(unittest.TestCase):
|
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:
|
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||||
frontend = manifest.frontend
|
frontend = manifest.frontend
|
||||||
self.assertIsNotNone(frontend)
|
self.assertIsNotNone(frontend)
|
||||||
@@ -29,7 +41,10 @@ class AddressesInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
reference = topics["addresses.reference.fields-and-consequences"]
|
reference = topics["addresses.reference.fields-and-consequences"]
|
||||||
|
|
||||||
self.assertIn("addresses.state.read-only", boundary.metadata["help_contexts"])
|
self.assertIn("addresses.state.read-only", boundary.metadata["help_contexts"])
|
||||||
self.assertIn("addresses.field.communication-purpose", governance.metadata["help_contexts"])
|
self.assertIn(
|
||||||
|
"addresses.field.communication-purpose",
|
||||||
|
governance.metadata["help_contexts"],
|
||||||
|
)
|
||||||
self.assertIn("addresses.action.sync", reference.metadata["help_contexts"])
|
self.assertIn("addresses.action.sync", reference.metadata["help_contexts"])
|
||||||
self.assertIn("merge", reference.metadata["consequence_classes"])
|
self.assertIn("merge", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("governance_fact", reference.metadata["consequence_classes"])
|
self.assertIn("governance_fact", reference.metadata["consequence_classes"])
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
|
|
||||||
from govoplan_addresses.backend.db.models import AddressBook, Contact
|
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone, ContactPostalAddress
|
||||||
from govoplan_addresses.backend.import_schemas import (
|
from govoplan_addresses.backend.import_schemas import (
|
||||||
AddressImportConfiguration,
|
AddressImportConfiguration,
|
||||||
AddressImportPreviewRequest,
|
AddressImportPreviewRequest,
|
||||||
@@ -18,6 +19,8 @@ from govoplan_addresses.backend.import_schemas import (
|
|||||||
)
|
)
|
||||||
from govoplan_addresses.backend.imports import (
|
from govoplan_addresses.backend.imports import (
|
||||||
apply_address_import,
|
apply_address_import,
|
||||||
|
_contact_hash,
|
||||||
|
_contact_snapshot,
|
||||||
create_import_profile,
|
create_import_profile,
|
||||||
get_import_run,
|
get_import_run,
|
||||||
import_run_payload,
|
import_run_payload,
|
||||||
@@ -26,6 +29,7 @@ from govoplan_addresses.backend.imports import (
|
|||||||
update_import_profile,
|
update_import_profile,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_addresses.backend.service import delete_contact
|
||||||
|
|
||||||
|
|
||||||
class Principal:
|
class Principal:
|
||||||
@@ -91,6 +95,315 @@ class AddressTabularImportTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.session.flush()
|
self.session.flush()
|
||||||
|
|
||||||
|
def _preview_rows(self, count: int = 1, *, organization: str = "Office"):
|
||||||
|
return preview_address_import(
|
||||||
|
self.session, self.principal, self.book.id,
|
||||||
|
AddressImportPreviewRequest(
|
||||||
|
profile_id=self.profile.id, filename="fixture.csv",
|
||||||
|
content_base64=encoded(
|
||||||
|
"id;first;last;email;organization\n"
|
||||||
|
+ "".join(f"{index};Given;Family;u{index}@example.test;{organization}\n" for index in range(count))
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply(self, run):
|
||||||
|
apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def test_rollback_restores_previously_deleted_state_and_source_fields(self) -> None:
|
||||||
|
self._apply(self._preview_rows())
|
||||||
|
contact = self.session.query(Contact).one()
|
||||||
|
delete_contact(self.session, self.principal, contact.id)
|
||||||
|
self.session.commit()
|
||||||
|
prior_deleted_at = contact.deleted_at
|
||||||
|
prior_source_revision = contact.source_revision
|
||||||
|
run = self._preview_rows(organization="Changed")
|
||||||
|
self._apply(run)
|
||||||
|
self.assertIsNone(contact.deleted_at)
|
||||||
|
before = run.result_evidence["updated_contacts"][0]["before"]
|
||||||
|
self.assertEqual(2, before["version"])
|
||||||
|
self.assertEqual(prior_deleted_at.isoformat(), before["deleted_at"])
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run.id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Restore the reviewed previous state."),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertEqual("rolled_back", run.status)
|
||||||
|
self.assertEqual(prior_deleted_at, contact.deleted_at)
|
||||||
|
self.assertEqual("Office", contact.organization)
|
||||||
|
self.assertEqual(prior_source_revision, contact.source_revision)
|
||||||
|
|
||||||
|
def test_rollback_restores_all_point_evidence_and_identities_after_reload(self) -> None:
|
||||||
|
self._apply(self._preview_rows())
|
||||||
|
contact = self.session.query(Contact).one()
|
||||||
|
contact.emails[0].original_email = " U0@EXAMPLE.TEST "
|
||||||
|
contact.emails[0].provenance = {"nested": {"original": "email evidence"}}
|
||||||
|
contact.note = " Exact retained note\r\n"
|
||||||
|
contact.tags = ["Exact", "Exact", " padded "]
|
||||||
|
contact.metadata_ = None
|
||||||
|
contact.phones.append(ContactPhone(
|
||||||
|
phone="+49 123", original_phone=" +49 (123) ", normalized_phone="+49123",
|
||||||
|
provenance={"original": "phone evidence"}, label="Office", is_primary=True, order_index=4,
|
||||||
|
))
|
||||||
|
contact.postal_addresses.append(ContactPostalAddress(
|
||||||
|
street="Main Street", original_value={"street": " Main Street "},
|
||||||
|
normalized_value={"street": "main street"}, provenance={"original": "postal evidence"},
|
||||||
|
is_primary=True, order_index=7,
|
||||||
|
))
|
||||||
|
self.session.commit()
|
||||||
|
before = _contact_snapshot(contact)["points"]
|
||||||
|
run = self._preview_rows(organization="Changed")
|
||||||
|
self._apply(run)
|
||||||
|
self.assertEqual(before["emails"], _contact_snapshot(contact)["points"]["emails"])
|
||||||
|
self.session.expire_all()
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run.id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Restore all original point evidence."),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.expire_all()
|
||||||
|
self.assertEqual(before, _contact_snapshot(contact)["points"])
|
||||||
|
self.assertEqual(" Exact retained note\r\n", contact.note)
|
||||||
|
self.assertEqual(["Exact", "Exact", " padded "], contact.tags)
|
||||||
|
self.assertIsNone(contact.metadata_)
|
||||||
|
|
||||||
|
def test_post_import_point_provenance_edit_is_guarded(self) -> None:
|
||||||
|
self._apply(self._preview_rows())
|
||||||
|
run = self._preview_rows(organization="Changed")
|
||||||
|
self._apply(run)
|
||||||
|
contact = self.session.query(Contact).one()
|
||||||
|
after_hash = _contact_hash(contact)
|
||||||
|
contact.emails[0].provenance = {"later": "manual evidence"}
|
||||||
|
self.session.commit()
|
||||||
|
self.assertNotEqual(after_hash, _contact_hash(contact))
|
||||||
|
with self.assertRaisesRegex(ValueError, "changed after import"):
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run.id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Do not erase a later point edit."),
|
||||||
|
)
|
||||||
|
self.assertEqual({"later": "manual evidence"}, contact.emails[0].provenance)
|
||||||
|
|
||||||
|
def test_point_lock_order_does_not_change_tied_collection_order_or_reviewed_hash(self) -> None:
|
||||||
|
self._apply(self._preview_rows())
|
||||||
|
contact = self.session.query(Contact).one()
|
||||||
|
contact.emails.append(ContactEmail(
|
||||||
|
id="00000000-0000-0000-0000-000000000000", email="extra@example.test",
|
||||||
|
original_email="extra@example.test", normalized_email="extra@example.test",
|
||||||
|
label="Extra", is_primary=False, order_index=0,
|
||||||
|
))
|
||||||
|
self.session.commit()
|
||||||
|
self.session.expire_all()
|
||||||
|
before = _contact_snapshot(contact)["points"]
|
||||||
|
run = self._preview_rows(organization="Changed")
|
||||||
|
self._apply(run)
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run.id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Preserve tied contact-point ordering."),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.expire_all()
|
||||||
|
self.assertEqual(before, _contact_snapshot(contact)["points"])
|
||||||
|
|
||||||
|
def _link_email(self, contact):
|
||||||
|
address_list = AddressList(address_book_id=self.book.id, tenant_id="tenant-1", name="Recipients")
|
||||||
|
entry = AddressListEntry(address_list=address_list, contact=contact, contact_email=contact.emails[0], target_kind="email")
|
||||||
|
self.session.add(entry)
|
||||||
|
self.session.commit()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def test_unchanged_point_keeps_address_list_identity_through_apply_and_rollback(self) -> None:
|
||||||
|
self._apply(self._preview_rows())
|
||||||
|
contact = self.session.query(Contact).one()
|
||||||
|
entry = self._link_email(contact)
|
||||||
|
point_id = contact.emails[0].id
|
||||||
|
run = self._preview_rows(organization="Changed")
|
||||||
|
self._apply(run)
|
||||||
|
self.session.expire_all()
|
||||||
|
self.assertEqual(point_id, entry.contact_email_id)
|
||||||
|
self.assertEqual(point_id, contact.emails[0].id)
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run.id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Keep the explicit recipient reference."),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.expire_all()
|
||||||
|
self.assertEqual(point_id, entry.contact_email_id)
|
||||||
|
self.assertEqual(point_id, contact.emails[0].id)
|
||||||
|
|
||||||
|
def test_replacing_a_linked_point_requires_reconciliation_before_contact_mutation(self) -> None:
|
||||||
|
self._apply(self._preview_rows())
|
||||||
|
contact = self.session.query(Contact).one()
|
||||||
|
entry = self._link_email(contact)
|
||||||
|
run = preview_address_import(
|
||||||
|
self.session, self.principal, self.book.id,
|
||||||
|
AddressImportPreviewRequest(profile_id=self.profile.id, filename="fixture.csv", content_base64=encoded(
|
||||||
|
"id;first;last;email;organization\n0;Given;Family;different@example.test;Changed\n"
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
before = _contact_hash(contact)
|
||||||
|
with self.assertRaisesRegex(ValueError, "address-list or governance references"):
|
||||||
|
apply_address_import(self.session, self.principal, run.id, expected_plan_hash=run.plan_hash)
|
||||||
|
self.assertEqual(before, _contact_hash(contact))
|
||||||
|
self.assertEqual(contact.emails[0].id, entry.contact_email_id)
|
||||||
|
self.assertEqual("previewed", run.status)
|
||||||
|
|
||||||
|
def test_new_reference_to_imported_point_blocks_destructive_rollback(self) -> None:
|
||||||
|
self._apply(self._preview_rows())
|
||||||
|
run = preview_address_import(
|
||||||
|
self.session, self.principal, self.book.id,
|
||||||
|
AddressImportPreviewRequest(profile_id=self.profile.id, filename="fixture.csv", content_base64=encoded(
|
||||||
|
"id;first;last;email;organization\n0;Given;Family;different@example.test;Changed\n"
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
self._apply(run)
|
||||||
|
contact = self.session.query(Contact).one()
|
||||||
|
entry = self._link_email(contact)
|
||||||
|
before = _contact_hash(contact)
|
||||||
|
with self.assertRaisesRegex(ValueError, "address-list or governance references"):
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run.id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Retain the newly referenced recipient point."),
|
||||||
|
)
|
||||||
|
self.assertEqual(before, _contact_hash(contact))
|
||||||
|
self.assertEqual(contact.emails[0].id, entry.contact_email_id)
|
||||||
|
self.assertEqual("applied", run.status)
|
||||||
|
|
||||||
|
def test_version_one_point_incomplete_evidence_is_not_accepted(self) -> None:
|
||||||
|
import copy
|
||||||
|
|
||||||
|
self._apply(self._preview_rows())
|
||||||
|
run = self._preview_rows(organization="Changed")
|
||||||
|
self._apply(run)
|
||||||
|
evidence = copy.deepcopy(run.result_evidence)
|
||||||
|
evidence["updated_contacts"][0]["before"]["version"] = 1
|
||||||
|
run.result_evidence = evidence
|
||||||
|
self.session.commit()
|
||||||
|
with self.assertRaisesRegex(ValueError, "incomplete legacy rollback evidence"):
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run.id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Do not infer missing point evidence."),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_or_incomplete_before_images_fail_before_any_rollback_mutation(self) -> None:
|
||||||
|
self._apply(self._preview_rows())
|
||||||
|
run = self._preview_rows(2, organization="Changed")
|
||||||
|
self._apply(run)
|
||||||
|
evidence = dict(run.result_evidence)
|
||||||
|
updates = [dict(item) for item in evidence["updated_contacts"]]
|
||||||
|
updates[0]["before"] = updates[0]["before"]["contact"]
|
||||||
|
evidence["updated_contacts"] = updates
|
||||||
|
run.result_evidence = evidence
|
||||||
|
self.session.commit()
|
||||||
|
with self.assertRaisesRegex(ValueError, "incomplete legacy rollback evidence"):
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run.id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Reject an incomplete previous state."),
|
||||||
|
)
|
||||||
|
self.assertEqual("applied", run.status)
|
||||||
|
self.assertEqual(2, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
|
||||||
|
self.assertTrue(all(contact.organization == "Changed" for contact in self.session.query(Contact)))
|
||||||
|
|
||||||
|
def test_preview_queries_are_batched_and_relationships_are_eager(self) -> None:
|
||||||
|
queries = []
|
||||||
|
|
||||||
|
def capture(conn, cursor, statement, parameters, context, executemany):
|
||||||
|
if statement.lstrip().upper().startswith("SELECT"):
|
||||||
|
queries.append(statement)
|
||||||
|
|
||||||
|
event.listen(self.session.bind, "before_cursor_execute", capture)
|
||||||
|
try:
|
||||||
|
first = self._preview_rows(50)
|
||||||
|
self.assertEqual(50, first.statistics["create"])
|
||||||
|
self.assertLessEqual(len(queries), 3)
|
||||||
|
self._apply(first)
|
||||||
|
self.session.expunge_all()
|
||||||
|
queries.clear()
|
||||||
|
repeat = self._preview_rows(50)
|
||||||
|
self.assertEqual(50, repeat.statistics["unchanged"])
|
||||||
|
self.assertLessEqual(len(queries), 6)
|
||||||
|
finally:
|
||||||
|
event.remove(self.session.bind, "before_cursor_execute", capture)
|
||||||
|
|
||||||
|
def test_preview_batches_preserve_first_source_match_and_book_scope(self) -> None:
|
||||||
|
self._apply(self._preview_rows(5))
|
||||||
|
original = self.session.query(Contact).order_by(Contact.created_at, Contact.id).first()
|
||||||
|
other_book = AddressBook(tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Other", source_kind="local", read_only=False)
|
||||||
|
self.session.add(other_book)
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add_all([
|
||||||
|
Contact(tenant_id="tenant-1", address_book_id=original.address_book_id, display_name="Later duplicate", source_ref=original.source_ref),
|
||||||
|
Contact(tenant_id="tenant-1", address_book_id=other_book.id, display_name="Other book", source_ref=original.source_ref),
|
||||||
|
])
|
||||||
|
self.session.commit()
|
||||||
|
with patch("govoplan_addresses.backend.imports.CONTACT_LOOKUP_BATCH_SIZE", 2):
|
||||||
|
repeat = self._preview_rows(5)
|
||||||
|
self.assertEqual(5, repeat.statistics["unchanged"])
|
||||||
|
|
||||||
|
def test_missing_created_after_hash_blocks_rollback(self) -> None:
|
||||||
|
run = self._preview_rows()
|
||||||
|
self._apply(run)
|
||||||
|
run.plan_data = [{key: value for key, value in item.items() if key != "after_hash"} for item in run.plan_data]
|
||||||
|
self.session.commit()
|
||||||
|
with self.assertRaisesRegex(ValueError, "evidence is incomplete"):
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run.id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Incomplete evidence must not delete contacts."),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
|
||||||
|
|
||||||
|
def test_applied_effect_hashes_survive_commit_and_reload(self) -> None:
|
||||||
|
run = self._preview_rows()
|
||||||
|
self._apply(run)
|
||||||
|
run_id, plan_hash = run.id, run.plan_hash
|
||||||
|
self.session.expunge_all()
|
||||||
|
reloaded = get_import_run(self.session, self.principal, run_id)
|
||||||
|
self.assertTrue(reloaded.plan_data[0]["contact_id"])
|
||||||
|
self.assertEqual(64, len(reloaded.plan_data[0]["after_hash"]))
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run_id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=plan_hash, reason="Durable after-images guard rollback."),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertEqual(0, self.session.query(Contact).filter(Contact.deleted_at.is_(None)).count())
|
||||||
|
|
||||||
|
def test_replayed_apply_still_requires_the_reviewed_plan_hash(self) -> None:
|
||||||
|
run = self._preview_rows()
|
||||||
|
self._apply(run)
|
||||||
|
with self.assertRaisesRegex(ValueError, "reviewed import plan changed"):
|
||||||
|
apply_address_import(self.session, self.principal, run.id, expected_plan_hash="0" * 64)
|
||||||
|
|
||||||
|
def test_rollback_rejects_a_moved_target_without_archiving_it(self) -> None:
|
||||||
|
run = self._preview_rows()
|
||||||
|
self._apply(run)
|
||||||
|
other_book = AddressBook(tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Other", source_kind="local", read_only=False)
|
||||||
|
self.session.add(other_book)
|
||||||
|
self.session.flush()
|
||||||
|
contact = self.session.query(Contact).one()
|
||||||
|
contact.address_book_id = other_book.id
|
||||||
|
self.session.commit()
|
||||||
|
with self.assertRaisesRegex(ValueError, "moved to another address book"):
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run.id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Moved contacts require manual reconciliation."),
|
||||||
|
)
|
||||||
|
self.assertIsNone(contact.deleted_at)
|
||||||
|
self.assertEqual("applied", run.status)
|
||||||
|
|
||||||
|
def test_rollback_retains_post_import_edits(self) -> None:
|
||||||
|
run = self._preview_rows()
|
||||||
|
self._apply(run)
|
||||||
|
contact = self.session.query(Contact).one()
|
||||||
|
contact.organization = "Later manual edit"
|
||||||
|
self.session.commit()
|
||||||
|
with self.assertRaisesRegex(ValueError, "changed after import"):
|
||||||
|
rollback_address_import(
|
||||||
|
self.session, self.principal, run.id,
|
||||||
|
AddressImportRollbackRequest(expected_plan_hash=run.plan_hash, reason="Later changes must remain untouched."),
|
||||||
|
)
|
||||||
|
self.assertIsNone(contact.deleted_at)
|
||||||
|
self.assertEqual("Later manual edit", contact.organization)
|
||||||
|
|
||||||
def test_preview_apply_repeat_and_guarded_rollback(self) -> None:
|
def test_preview_apply_repeat_and_guarded_rollback(self) -> None:
|
||||||
payload = AddressImportPreviewRequest(
|
payload = AddressImportPreviewRequest(
|
||||||
profile_id=self.profile.id,
|
profile_id=self.profile.id,
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/addresses-webui",
|
"name": "@govoplan/addresses-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"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"
|
"test:import-run": "rm -rf .import-run-test-build && mkdir -p .import-run-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-run-test-build/package.json && ../../govoplan-core/webui/node_modules/.bin/tsc -p tsconfig.import-run-tests.json && node .import-run-test-build/tests/import-run-state.test.js"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -17,5 +17,14 @@ assert.match(page, /<SegmentedControl<ConflictMergeChoice>[\s\S]*role="group"[\s
|
|||||||
assert.doesNotMatch(page, /<button[\s\S]{0,160}(?:address-contact-row|address-sync-result-row)/);
|
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-conflict-choice button/);
|
||||||
assert.doesNotMatch(styles, /\.address-contact-row:(?:hover|focus-visible)/);
|
assert.doesNotMatch(styles, /\.address-contact-row:(?:hover|focus-visible)/);
|
||||||
|
assert.match(page, /<PageActionBar[\s\S]*variant="collection"[\s\S]*reloadAction=[\s\S]*createAction=/);
|
||||||
|
assert.doesNotMatch(page, /renderSelectedBookActions|address-icon-actions/);
|
||||||
|
assert.match(page, /renderAddressActions\(\)/);
|
||||||
|
assert.match(page, /<FormSection variant="separated" title="i18n:govoplan-addresses\.explorer\.archive_section"/);
|
||||||
|
const openTreeNode = page.slice(page.indexOf(" function openTreeNode("), page.indexOf(" function toggleTreeNode("));
|
||||||
|
assert.doesNotMatch(openTreeNode, /toggleTreeNode\(/, "Labels only select, never expand or collapse.");
|
||||||
|
assert.match(openTreeNode, /setSelectedTreeGroup\(\{ id: node\.id, label: node\.label \}\)/);
|
||||||
|
assert.match(page, /selectedTreeGroup\?\.id \?\?/);
|
||||||
|
assert.match(page, /if \(!previousBranchIds\.has\(id\)\) next\.add\(id\)/, "Reload preserves collapsed branches.");
|
||||||
|
|
||||||
console.log("Address-book flat selections use central components.");
|
console.log("Address-book flat selections use central components.");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MetricGrid } from "@govoplan/core-webui";
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
|
import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, RotateCcw, Save, Search, Settings2, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import { DialogSection, DialogForm, FormGrid, ActionToolbar,
|
import { DialogSection, DialogForm, FormGrid, ActionToolbar,
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -18,6 +18,7 @@ import { DialogSection, DialogForm, FormGrid, ActionToolbar,
|
|||||||
FormSection,
|
FormSection,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
|
PageActionBar,
|
||||||
PasswordField,
|
PasswordField,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
SelectionList,
|
SelectionList,
|
||||||
@@ -659,7 +660,7 @@ function canMergeConflict(conflict: AddressSyncConflict): boolean {
|
|||||||
return Boolean(conflictPayload(conflict.local_value) && conflictRemotePayload(conflict));
|
return Boolean(conflictPayload(conflict.local_value) && conflictRemotePayload(conflict));
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultConflictMergeChoices(conflict: AddressSyncConflict): Record<string, ConflictMergeChoice> {
|
function defaultConflictMergeChoices(_conflict: AddressSyncConflict): Record<string, ConflictMergeChoice> {
|
||||||
return Object.fromEntries(CONFLICT_PAYLOAD_FIELDS.map((field) => [field, "local" as ConflictMergeChoice]));
|
return Object.fromEntries(CONFLICT_PAYLOAD_FIELDS.map((field) => [field, "local" as ConflictMergeChoice]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,7 +964,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
const [selectedBookId, setSelectedBookId] = useState("");
|
const [selectedBookId, setSelectedBookId] = useState("");
|
||||||
const [selectedListId, setSelectedListId] = useState("");
|
const [selectedListId, setSelectedListId] = useState("");
|
||||||
const [selectedContactId, setSelectedContactId] = useState("");
|
const [selectedContactId, setSelectedContactId] = useState("");
|
||||||
|
const [selectedTreeGroup, setSelectedTreeGroup] = useState<Pick<AddressTreeNode, "id" | "label"> | null>(null);
|
||||||
const [expandedTreeIds, setExpandedTreeIds] = useState<Set<string>>(() => new Set());
|
const [expandedTreeIds, setExpandedTreeIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const knownTreeBranchIds = useRef(new Set<string>());
|
||||||
|
const [addressActionsOpen, setAddressActionsOpen] = useState<"book" | "transfer" | "connections" | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -1136,8 +1140,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
.then((run) => {
|
.then((run) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setImportRun(run);
|
setImportRun(run);
|
||||||
|
setSelectedTreeGroup(null);
|
||||||
setSelectedBookId(run.address_book_id);
|
setSelectedBookId(run.address_book_id);
|
||||||
setSelectedImportProfileId(run.profile_id);
|
setSelectedImportProfileId(run.profile_id ?? "");
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -1161,9 +1166,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
return () => {active = false;};
|
return () => {active = false;};
|
||||||
}, [auth.groups_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
}, [auth.groups_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
const selectedBook = books.find((book) => book.id === selectedBookId) ?? books[0] ?? null;
|
const selectedBook = selectedTreeGroup ? null : books.find((book) => book.id === selectedBookId) ?? books[0] ?? null;
|
||||||
const importLifecycle = importRun ? importRunLifecycle(importRun.status) : null;
|
const importLifecycle = importRun ? importRunLifecycle(importRun.status) : null;
|
||||||
const selectedList = addressLists.find((list) => list.id === selectedListId) ?? null;
|
const selectedList = selectedTreeGroup ? null : addressLists.find((list) => list.id === selectedListId) ?? null;
|
||||||
const selectedBookSyncSources = useMemo(
|
const selectedBookSyncSources = useMemo(
|
||||||
() => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [],
|
() => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [],
|
||||||
[selectedBook, syncSources]
|
[selectedBook, syncSources]
|
||||||
@@ -1172,7 +1177,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]);
|
const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]);
|
||||||
const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]);
|
const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]);
|
||||||
const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]);
|
const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]);
|
||||||
const visibleContacts = contacts;
|
const visibleContacts = selectedTreeGroup ? [] : contacts;
|
||||||
const memberCandidateContacts = useMemo(() => {
|
const memberCandidateContacts = useMemo(() => {
|
||||||
const normalizedQuery = memberQuery.trim().toLowerCase();
|
const normalizedQuery = memberQuery.trim().toLowerCase();
|
||||||
return memberCandidates
|
return memberCandidates
|
||||||
@@ -1208,7 +1213,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
return [];
|
return [];
|
||||||
}, [channelRuleForm.channel, governanceContact]);
|
}, [channelRuleForm.channel, governanceContact]);
|
||||||
const selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : [];
|
const selectedContactListEntries = selectedContact && selectedList ? contactListEntries(addressListEntries, selectedContact.id) : [];
|
||||||
const activeTreeId = selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : "";
|
const activeTreeId = selectedTreeGroup?.id ?? (selectedList ? `list:${selectedList.id}` : selectedBook ? `book:${selectedBook.id}` : "");
|
||||||
const loadingReason = loading ? "Address books are loading." : "";
|
const loadingReason = loading ? "Address books are loading." : "";
|
||||||
const savingReason = saving ? "An address-book action is already in progress." : "";
|
const savingReason = saving ? "An address-book action is already in progress." : "";
|
||||||
const createBookReason = disabledReason(
|
const createBookReason = disabledReason(
|
||||||
@@ -1570,10 +1575,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const defaultExpanded = expandedAddressTreeIds(addressTreeNodes);
|
const defaultExpanded = expandedAddressTreeIds(addressTreeNodes);
|
||||||
if (defaultExpanded.size === 0) return;
|
const previousBranchIds = knownTreeBranchIds.current;
|
||||||
|
knownTreeBranchIds.current = defaultExpanded;
|
||||||
setExpandedTreeIds((current) => {
|
setExpandedTreeIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set([...current].filter((id) => defaultExpanded.has(id)));
|
||||||
for (const id of defaultExpanded) next.add(id);
|
for (const id of defaultExpanded) {
|
||||||
|
if (!previousBranchIds.has(id)) next.add(id);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [addressTreeNodes]);
|
}, [addressTreeNodes]);
|
||||||
@@ -1950,6 +1958,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
|
|
||||||
function openTreeNode(node: AddressTreeNode) {
|
function openTreeNode(node: AddressTreeNode) {
|
||||||
if (node.kind === "book" && node.book) {
|
if (node.kind === "book" && node.book) {
|
||||||
|
setSelectedTreeGroup(null);
|
||||||
setContactPage(1);
|
setContactPage(1);
|
||||||
setSelectedBookId(node.book.id);
|
setSelectedBookId(node.book.id);
|
||||||
setSelectedListId("");
|
setSelectedListId("");
|
||||||
@@ -1957,13 +1966,16 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node.kind === "list" && node.list) {
|
if (node.kind === "list" && node.list) {
|
||||||
|
setSelectedTreeGroup(null);
|
||||||
setContactPage(1);
|
setContactPage(1);
|
||||||
setSelectedBookId(node.list.address_book_id);
|
setSelectedBookId(node.list.address_book_id);
|
||||||
setSelectedListId(node.list.id);
|
setSelectedListId(node.list.id);
|
||||||
setSelectedContactId("");
|
setSelectedContactId("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toggleTreeNode(node);
|
setSelectedTreeGroup({ id: node.id, label: node.label });
|
||||||
|
setSelectedListId("");
|
||||||
|
setSelectedContactId("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTreeNode(node: AddressTreeNode) {
|
function toggleTreeNode(node: AddressTreeNode) {
|
||||||
@@ -2450,8 +2462,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
try {
|
try {
|
||||||
const run = await getAddressImportRun(settings, importRun?.id || requestedImportRunId);
|
const run = await getAddressImportRun(settings, importRun?.id || requestedImportRunId);
|
||||||
retainImportRun(run);
|
retainImportRun(run);
|
||||||
|
setSelectedTreeGroup(null);
|
||||||
setSelectedBookId(run.address_book_id);
|
setSelectedBookId(run.address_book_id);
|
||||||
setSelectedImportProfileId(run.profile_id);
|
setSelectedImportProfileId(run.profile_id ?? "");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setImportRun(null);
|
setImportRun(null);
|
||||||
setImportRunUnavailable(
|
setImportRunUnavailable(
|
||||||
@@ -2555,7 +2568,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
|
|
||||||
function downloadImportCorrections() {
|
function downloadImportCorrections() {
|
||||||
if (!importRun) return;
|
if (!importRun) return;
|
||||||
const quote = (value: unknown) => `"${String(value ?? "").replaceAll('"', '""')}"`;
|
const quote = (value: unknown) => `"${String(value ?? "").replace(/"/g, '""')}"`;
|
||||||
const lines = [
|
const lines = [
|
||||||
["severity", "row", "field", "code", "message"].map(quote).join(","),
|
["severity", "row", "field", "code", "message"].map(quote).join(","),
|
||||||
...importRun.diagnostics.map((item) => [item.severity, item.row_number ?? "", item.field ?? "", item.code, item.message].map(quote).join(","))
|
...importRun.diagnostics.map((item) => [item.severity, item.row_number ?? "", item.field ?? "", item.code, item.message].map(quote).join(","))
|
||||||
@@ -2911,6 +2924,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSelectedBookPanel() {
|
function renderSelectedBookPanel() {
|
||||||
|
if (selectedTreeGroup) return <div className="address-source-summary">
|
||||||
|
<strong>{selectedTreeGroup.label}</strong>
|
||||||
|
<p>i18n:govoplan-addresses.explorer.choose_book_in_group</p>
|
||||||
|
</div>;
|
||||||
if (!selectedBook) return <p className="address-empty-note">No address book selected.</p>;
|
if (!selectedBook) return <p className="address-empty-note">No address book selected.</p>;
|
||||||
return (
|
return (
|
||||||
<div className="address-source-summary">
|
<div className="address-source-summary">
|
||||||
@@ -2952,40 +2969,68 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSelectedBookActions() {
|
function chooseAddressAction(action: () => void) {
|
||||||
return (
|
setAddressActionsOpen(null);
|
||||||
<>
|
action();
|
||||||
<Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></Button>
|
}
|
||||||
<Button type="button" title="Review address quality" aria-label="Review address quality" onClick={() => void openQualityReview()} disabledReason={qualityDashboardReason}><ShieldCheck size={15} /></Button>
|
|
||||||
<Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
|
function renderAddressActions() {
|
||||||
<Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
|
return <Dialog
|
||||||
<Button type="button" title="Import contacts" aria-label="Import contacts" onClick={openImportDialog} disabledReason={importBookReason}><Upload size={15} /></Button>
|
open={addressActionsOpen !== null}
|
||||||
<select aria-label="vCard export version" value={vcardExportVersion} onChange={(event) => setVcardExportVersion(event.target.value as "3.0" | "4.0")}>
|
title={addressActionsOpen === "transfer" ? "i18n:govoplan-addresses.explorer.transfer" : addressActionsOpen === "connections" ? "i18n:govoplan-addresses.explorer.connections" : "i18n:govoplan-addresses.explorer.manage_selection"}
|
||||||
<option value="4.0">vCard 4.0</option>
|
description={selectedList?.name ?? selectedBook?.name ?? "i18n:govoplan-addresses.explorer.no_book"}
|
||||||
<option value="3.0">vCard 3.0</option>
|
onClose={() => setAddressActionsOpen(null)}
|
||||||
</select>
|
footer={<Button type="button" onClick={() => setAddressActionsOpen(null)}>i18n:govoplan-core.close.bbfa773e</Button>}>
|
||||||
<Button type="button" title={`Export selected ${selectedList ? "address list" : "address book"} as vCard ${vcardExportVersion}`} aria-label={`Export selected ${selectedList ? "address list" : "address book"} as vCard ${vcardExportVersion}`} onClick={() => void exportSelectedBook()} disabledReason={exportBookReason}><Download size={15} /></Button>
|
{addressActionsOpen === "book" && <>
|
||||||
<Button type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></Button>
|
<FormSection title="i18n:govoplan-addresses.explorer.manage_selection">
|
||||||
<Button type="button" title="Connect LDAP or Active Directory" aria-label="Connect LDAP or Active Directory" onClick={openLdapDialog} disabledReason={connectLdapReason}><Network size={15} /></Button>
|
<ActionToolbar surface="plain">
|
||||||
<Button type="button" title="Inspect sync source" aria-label="Inspect sync source" onClick={() => selectedSyncSource && void openSyncInspector(selectedSyncSource)} disabledReason={inspectSyncReason}><Search size={15} /></Button>
|
<Button type="button" onClick={() => chooseAddressAction(openCreateListDialog)} disabledReason={createListReason}><Plus size={16} aria-hidden="true" /> Add address list</Button>
|
||||||
<Button type="button" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</Button>
|
{selectedList?.deleted_at ?
|
||||||
<Button type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</Button>
|
<Button type="button" disabledReason={restoreListReason(selectedList)} onClick={() => chooseAddressAction(() => void restoreDeletedList(selectedList))}><RotateCcw size={16} aria-hidden="true" /> Restore address list</Button> : selectedList ?
|
||||||
{selectedList ?
|
<Button type="button" disabledReason={editListReason(selectedList)} onClick={() => chooseAddressAction(() => openEditListDialog(selectedList))}><Edit3 size={16} aria-hidden="true" /> Edit address list</Button> : selectedBook?.deleted_at ?
|
||||||
selectedList.deleted_at ?
|
<Button type="button" disabledReason={restoreBookReason(selectedBook)} onClick={() => chooseAddressAction(() => void restoreBook(selectedBook))}><RotateCcw size={16} aria-hidden="true" /> Restore address book</Button> :
|
||||||
<Button type="button" title="Restore address list" aria-label="Restore address list" disabledReason={restoreListReason(selectedList)} onClick={() => void restoreDeletedList(selectedList)}><RotateCcw size={15} /></Button> :
|
<Button type="button" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && chooseAddressAction(() => openEditBookDialog(selectedBook))}><Edit3 size={16} aria-hidden="true" /> Edit address book</Button>}
|
||||||
<>
|
</ActionToolbar>
|
||||||
<Button type="button" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></Button>
|
</FormSection>
|
||||||
<Button type="button" variant="danger" title="Delete address list" aria-label="Delete address list" disabledReason={deleteListReason(selectedList)} onClick={() => setConfirmState({ kind: "list", list: selectedList })}><Trash2 size={15} /></Button>
|
{(selectedList ? !selectedList.deleted_at : selectedBook && !selectedBook.deleted_at) && <FormSection variant="separated" title="i18n:govoplan-addresses.explorer.archive_section">
|
||||||
</> :
|
<ActionToolbar surface="plain">
|
||||||
selectedBook?.deleted_at ?
|
{selectedList ?
|
||||||
<Button type="button" title="Restore address book" aria-label="Restore address book" disabledReason={restoreBookReason(selectedBook)} onClick={() => void restoreBook(selectedBook)}><RotateCcw size={15} /></Button> :
|
<Button type="button" variant="danger" helpContextId="addresses.action.archive" helpModuleId="addresses" disabledReason={deleteListReason(selectedList)} onClick={() => chooseAddressAction(() => setConfirmState({ kind: "list", list: selectedList }))}><Trash2 size={16} aria-hidden="true" /> Delete address list</Button> : selectedBook &&
|
||||||
<>
|
<Button type="button" variant="danger" helpContextId="addresses.action.archive" helpModuleId="addresses" disabledReason={deleteBookReason(selectedBook)} onClick={() => chooseAddressAction(() => setConfirmState({ kind: "book", book: selectedBook }))}><Trash2 size={16} aria-hidden="true" /> Delete address book</Button>}
|
||||||
<Button type="button" title="Edit address book" aria-label="Edit address book" disabledReason={selectedBook ? editBookReason(selectedBook) : "Select an address book before editing."} onClick={() => selectedBook && openEditBookDialog(selectedBook)}><Edit3 size={15} /></Button>
|
</ActionToolbar>
|
||||||
<Button type="button" variant="danger" title="Delete address book" aria-label="Delete address book" disabledReason={selectedBook ? deleteBookReason(selectedBook) : "Select an address book before deleting."} onClick={() => selectedBook && setConfirmState({ kind: "book", book: selectedBook })}><Trash2 size={15} /></Button>
|
</FormSection>}
|
||||||
</>
|
</>}
|
||||||
}
|
{addressActionsOpen === "transfer" && <>
|
||||||
</>
|
<FormSection title="i18n:govoplan-addresses.explorer.import_section">
|
||||||
);
|
<ActionToolbar surface="plain"><Button type="button" onClick={() => chooseAddressAction(openImportDialog)} disabledReason={importBookReason}><Upload size={16} aria-hidden="true" /> Import contacts</Button></ActionToolbar>
|
||||||
|
</FormSection>
|
||||||
|
<FormSection variant="separated" title="i18n:govoplan-addresses.explorer.export_section">
|
||||||
|
<FormGrid columns={2} collapseAt="standard">
|
||||||
|
<FormField label="i18n:govoplan-addresses.explorer.vcard_version">
|
||||||
|
<select value={vcardExportVersion} onChange={(event) => setVcardExportVersion(event.target.value as "3.0" | "4.0")}>
|
||||||
|
<option value="4.0">vCard 4.0</option><option value="3.0">vCard 3.0</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<ActionToolbar surface="plain"><Button type="button" onClick={() => chooseAddressAction(() => void exportSelectedBook())} disabledReason={exportBookReason}><Download size={16} aria-hidden="true" /> {selectedList ? "i18n:govoplan-addresses.explorer.export_list" : "i18n:govoplan-addresses.explorer.export_book"}</Button></ActionToolbar>
|
||||||
|
</FormSection>
|
||||||
|
</>}
|
||||||
|
{addressActionsOpen === "connections" && <>
|
||||||
|
<FormSection title="i18n:govoplan-addresses.explorer.connect_section">
|
||||||
|
<ActionToolbar surface="plain">
|
||||||
|
<Button type="button" onClick={() => chooseAddressAction(openCardDavDialog)} disabledReason={connectCardDavReason}><Link2 size={16} aria-hidden="true" /> Connect CardDAV</Button>
|
||||||
|
<Button type="button" onClick={() => chooseAddressAction(openLdapDialog)} disabledReason={connectLdapReason}><Network size={16} aria-hidden="true" /> Connect LDAP or Active Directory</Button>
|
||||||
|
</ActionToolbar>
|
||||||
|
</FormSection>
|
||||||
|
<FormSection variant="separated" title="i18n:govoplan-addresses.explorer.sync_section" description={selectedSyncSource ? syncSourceLabel(selectedSyncSource) : "i18n:govoplan-addresses.explorer.no_sync_source"}>
|
||||||
|
<ActionToolbar surface="plain">
|
||||||
|
<Button type="button" onClick={() => selectedSyncSource && chooseAddressAction(() => void openSyncInspector(selectedSyncSource))} disabledReason={inspectSyncReason}><Search size={16} aria-hidden="true" /> Inspect sync source</Button>
|
||||||
|
<Button type="button" onClick={() => chooseAddressAction(() => void previewSelectedSync())} disabledReason={previewSyncReason}>Preview sync</Button>
|
||||||
|
<Button type="button" onClick={() => chooseAddressAction(() => void runSelectedSync())} disabledReason={runSyncReason}><RefreshCw size={16} aria-hidden="true" /> Run sync</Button>
|
||||||
|
</ActionToolbar>
|
||||||
|
</FormSection>
|
||||||
|
</>}
|
||||||
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContactRow(contact: Contact) {
|
function renderContactRow(contact: Contact) {
|
||||||
@@ -3050,7 +3095,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</Button>
|
</Button>
|
||||||
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
|
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
|
||||||
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
|
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
|
||||||
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
|
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" helpContextId="addresses.action.archive" helpModuleId="addresses" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -3196,6 +3241,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
|
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
|
||||||
|
<PageActionBar
|
||||||
|
title="Address books"
|
||||||
|
titleHelp={<DocumentationHelpLink reference={ADDRESSES_DOCUMENTATION} />}
|
||||||
|
variant="collection"
|
||||||
|
label="i18n:govoplan-addresses.explorer.page_actions"
|
||||||
|
className="address-page-actions"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void refreshAll(), loading, disabledReason: refreshReason }}
|
||||||
|
contextActions={<>
|
||||||
|
<Button type="button" helpContextId="addresses.explorer.transfer" helpModuleId="addresses" helpTopicId="addresses.boundary" onClick={() => setAddressActionsOpen("transfer")} disabledReason={savingReason}><Upload size={16} aria-hidden="true" /> i18n:govoplan-addresses.explorer.transfer</Button>
|
||||||
|
<Button type="button" onClick={() => setAddressActionsOpen("connections")} disabledReason={savingReason}><Link2 size={16} aria-hidden="true" /> i18n:govoplan-addresses.explorer.connections</Button>
|
||||||
|
<Button type="button" onClick={() => void openQualityReview()} disabledReason={qualityDashboardReason}><ShieldCheck size={16} aria-hidden="true" /> i18n:govoplan-addresses.explorer.quality</Button>
|
||||||
|
</>}
|
||||||
|
createAction={<Button type="button" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={16} aria-hidden="true" /> Add address book</Button>}
|
||||||
|
/>
|
||||||
{error && <DismissibleAlert className="address-error" compact tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert className="address-error" compact tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
{notice && !error && <DismissibleAlert className="address-error" compact tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
{notice && !error && <DismissibleAlert className="address-error" compact tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||||
{!canWriteBooks && !canWriteLists && !canWriteContacts && <ActionBlockerHint
|
{!canWriteBooks && !canWriteLists && !canWriteContacts && <ActionBlockerHint
|
||||||
@@ -3219,10 +3279,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<h2>Address books</h2>
|
<h2>Address books</h2>
|
||||||
<p>{books.length} book{books.length === 1 ? "" : "s"}</p>
|
<p>{books.length} book{books.length === 1 ? "" : "s"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions address-icon-actions">
|
<Button type="button" onClick={() => setAddressActionsOpen("book")} disabledReason={savingReason || (!selectedBook ? "i18n:govoplan-addresses.explorer.no_book" : "")}><Settings2 size={16} aria-hidden="true" /> i18n:govoplan-addresses.explorer.manage</Button>
|
||||||
<DocumentationHelpLink reference={ADDRESSES_DOCUMENTATION} />
|
|
||||||
{renderSelectedBookActions()}
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div className="address-tree-filter-row">
|
<div className="address-tree-filter-row">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
@@ -3266,7 +3323,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<div>
|
<div>
|
||||||
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
|
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
|
||||||
<p>
|
<p>
|
||||||
{contactTotal} contact{contactTotal === 1 ? "" : "s"}
|
{selectedTreeGroup ? 0 : contactTotal} contact{!selectedTreeGroup && contactTotal === 1 ? "" : "s"}
|
||||||
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
|
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -3274,7 +3331,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
{selectedList &&
|
{selectedList &&
|
||||||
<Button type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</Button>
|
<Button type="button" onClick={() => void openAddMembersDialog()} disabledReason={addMembersReason}><UserPlus size={16} /> Add to list</Button>
|
||||||
}
|
}
|
||||||
<Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button>
|
<Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Add contact</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<ActionToolbar className="address-contact-toolbar">
|
<ActionToolbar className="address-contact-toolbar">
|
||||||
@@ -3299,7 +3356,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<DataGridPaginationBar
|
<DataGridPaginationBar
|
||||||
page={contactPage}
|
page={contactPage}
|
||||||
pageSize={contactPageSize}
|
pageSize={contactPageSize}
|
||||||
totalRows={contactTotal}
|
totalRows={selectedTreeGroup ? 0 : contactTotal}
|
||||||
pageSizeOptions={[25, 50, 100, 200]}
|
pageSizeOptions={[25, 50, 100, 200]}
|
||||||
disabled={loading || saving || !selectedBook}
|
disabled={loading || saving || !selectedBook}
|
||||||
className="address-contact-pagination"
|
className="address-contact-pagination"
|
||||||
@@ -3317,6 +3374,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|
||||||
|
{renderAddressActions()}
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={Boolean(bookDialog)}
|
open={Boolean(bookDialog)}
|
||||||
title={bookDialog?.mode === "edit" ? "Edit address book" : "Add address book"}
|
title={bookDialog?.mode === "edit" ? "Edit address book" : "Add address book"}
|
||||||
@@ -3480,7 +3539,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<article className="address-governance-rule" key={rule.id}>
|
<article className="address-governance-rule" key={rule.id}>
|
||||||
<div className="address-governance-rule-main">
|
<div className="address-governance-rule-main">
|
||||||
<span className="address-governance-rule-heading">
|
<span className="address-governance-rule-heading">
|
||||||
<strong>{rule.channel.replace("_", " ")} · {rule.decision.replaceAll("_", " ")}</strong>
|
<strong>{rule.channel.replace("_", " ")} · {rule.decision.replace(/_/g, " ")}</strong>
|
||||||
<StatusBadge status={state} />
|
<StatusBadge status={state} />
|
||||||
</span>
|
</span>
|
||||||
<span>{rule.purpose || "All purposes"}{rule.reason ? ` · ${rule.reason}` : ""}</span>
|
<span>{rule.purpose || "All purposes"}{rule.reason ? ` · ${rule.reason}` : ""}</span>
|
||||||
@@ -4196,7 +4255,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</FormField>
|
</FormField>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
{cardDavForm.auth_type !== "none" &&
|
{cardDavForm.auth_type !== "none" &&
|
||||||
<FormField label="Reusable credential">
|
<FormField label="Reusable credential" helpContextId="addresses.sources" helpModuleId="addresses">
|
||||||
<select
|
<select
|
||||||
value={cardDavForm.credential_envelope_id}
|
value={cardDavForm.credential_envelope_id}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -4227,15 +4286,15 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} />
|
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
{!cardDavForm.credential_envelope_id &&
|
{!cardDavForm.credential_envelope_id &&
|
||||||
<FormField label="Password">
|
<FormField label="Password" helpContextId="addresses.sources" helpModuleId="addresses">
|
||||||
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
|
<PasswordField helpContextId="addresses.sources" helpModuleId="addresses" value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
|
||||||
</FormField>
|
</FormField>
|
||||||
}
|
}
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
}
|
}
|
||||||
{cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
|
{cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
|
||||||
<FormField label="Bearer token">
|
<FormField label="Bearer token" helpContextId="addresses.sources" helpModuleId="addresses">
|
||||||
<PasswordField value={cardDavForm.bearer_token} onValueChange={(value) => setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" />
|
<PasswordField helpContextId="addresses.sources" helpModuleId="addresses" value={cardDavForm.bearer_token} onValueChange={(value) => setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" />
|
||||||
</FormField>
|
</FormField>
|
||||||
}
|
}
|
||||||
{cardDavDiscovery.length > 0 &&
|
{cardDavDiscovery.length > 0 &&
|
||||||
@@ -4277,7 +4336,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<FormField label="Display name">
|
<FormField label="Display name">
|
||||||
<input value={ldapForm.display_name} onChange={(event) => setLdapForm((current) => ({ ...current, display_name: event.target.value }))} placeholder="Corporate directory" />
|
<input value={ldapForm.display_name} onChange={(event) => setLdapForm((current) => ({ ...current, display_name: event.target.value }))} placeholder="Corporate directory" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Reusable credential">
|
<FormField label="Reusable credential" helpContextId="addresses.sources" helpModuleId="addresses">
|
||||||
<select
|
<select
|
||||||
value={ldapForm.credential_envelope_id}
|
value={ldapForm.credential_envelope_id}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -4480,7 +4539,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<StatusBadge status={conflictDialog.status} />
|
<StatusBadge status={conflictDialog.status} />
|
||||||
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
|
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
|
||||||
</div>
|
</div>
|
||||||
{conflictDialog.metadata?.message && <p className="muted">{String(conflictDialog.metadata.message)}</p>}
|
{Boolean(conflictDialog.metadata?.message) && <p className="muted">{String(conflictDialog.metadata.message)}</p>}
|
||||||
{!canApplyRemoteConflict(conflictDialog) && <DismissibleAlert tone="warning" dismissible={false}>This conflict predates stored field payloads or came from a stale write. It can be marked resolved or ignored, but the remote value cannot be applied automatically.</DismissibleAlert>}
|
{!canApplyRemoteConflict(conflictDialog) && <DismissibleAlert tone="warning" dismissible={false}>This conflict predates stored field payloads or came from a stale write. It can be marked resolved or ignored, but the remote value cannot be applied automatically.</DismissibleAlert>}
|
||||||
</div>
|
</div>
|
||||||
<div className="address-conflict-grid">
|
<div className="address-conflict-grid">
|
||||||
|
|||||||
@@ -2,6 +2,33 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
|||||||
|
|
||||||
export const generatedTranslations: PlatformTranslations = {
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
en: {
|
en: {
|
||||||
|
"i18n:govoplan-addresses.explorer.page_actions": "Address book actions",
|
||||||
|
"i18n:govoplan-addresses.explorer.transfer": "Import / export",
|
||||||
|
"i18n:govoplan-addresses.explorer.connections": "Connections",
|
||||||
|
"i18n:govoplan-addresses.explorer.quality": "Address quality",
|
||||||
|
"i18n:govoplan-addresses.explorer.manage": "Manage",
|
||||||
|
"i18n:govoplan-addresses.explorer.manage_selection": "Manage selected book or list",
|
||||||
|
"i18n:govoplan-addresses.explorer.no_book": "Select an address book first.",
|
||||||
|
"i18n:govoplan-addresses.explorer.choose_book_in_group": "Select an address book in this group. Use the folder icon to expand or collapse it; clicking the label only selects it.",
|
||||||
|
"i18n:govoplan-addresses.explorer.archive_section": "Archive selected book or list",
|
||||||
|
"i18n:govoplan-addresses.explorer.import_section": "Import into the selected book",
|
||||||
|
"i18n:govoplan-addresses.explorer.export_section": "Export the selected book or list",
|
||||||
|
"i18n:govoplan-addresses.explorer.vcard_version": "vCard export version",
|
||||||
|
"i18n:govoplan-addresses.explorer.export_list": "Export address list",
|
||||||
|
"i18n:govoplan-addresses.explorer.export_book": "Export address book",
|
||||||
|
"i18n:govoplan-addresses.explorer.connect_section": "Connect an address source",
|
||||||
|
"i18n:govoplan-addresses.explorer.sync_section": "Synchronization for the selected book",
|
||||||
|
"i18n:govoplan-addresses.explorer.no_sync_source": "The selected book has no connected synchronization source.",
|
||||||
|
"Restore address list": "Restore address list",
|
||||||
|
"Restore address book": "Restore address book",
|
||||||
|
"Delete address list": "Delete address list",
|
||||||
|
"Delete address book": "Delete address book",
|
||||||
|
"Import contacts": "Import contacts",
|
||||||
|
"Connect CardDAV": "Connect CardDAV",
|
||||||
|
"Connect LDAP or Active Directory": "Connect LDAP or Active Directory",
|
||||||
|
"Inspect sync source": "Inspect sync source",
|
||||||
|
"Preview sync": "Preview sync",
|
||||||
|
"Run sync": "Run sync",
|
||||||
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Add contact",
|
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Add contact",
|
||||||
"i18n:govoplan-addresses.address_book.f6327f59": "Address Book",
|
"i18n:govoplan-addresses.address_book.f6327f59": "Address Book",
|
||||||
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Address book scopes",
|
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Address book scopes",
|
||||||
@@ -88,6 +115,33 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"Note": "Note"
|
"Note": "Note"
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
|
"i18n:govoplan-addresses.explorer.page_actions": "Adressbuchaktionen",
|
||||||
|
"i18n:govoplan-addresses.explorer.transfer": "Import / Export",
|
||||||
|
"i18n:govoplan-addresses.explorer.connections": "Verbindungen",
|
||||||
|
"i18n:govoplan-addresses.explorer.quality": "Adressqualität",
|
||||||
|
"i18n:govoplan-addresses.explorer.manage": "Verwalten",
|
||||||
|
"i18n:govoplan-addresses.explorer.manage_selection": "Ausgewähltes Adressbuch oder Liste verwalten",
|
||||||
|
"i18n:govoplan-addresses.explorer.no_book": "Wählen Sie zuerst ein Adressbuch aus.",
|
||||||
|
"i18n:govoplan-addresses.explorer.choose_book_in_group": "Wählen Sie ein Adressbuch in dieser Gruppe aus. Das Ordnersymbol klappt auf oder zu; ein Klick auf die Beschriftung wählt nur aus.",
|
||||||
|
"i18n:govoplan-addresses.explorer.archive_section": "Ausgewähltes Adressbuch oder Liste archivieren",
|
||||||
|
"i18n:govoplan-addresses.explorer.import_section": "In das ausgewählte Adressbuch importieren",
|
||||||
|
"i18n:govoplan-addresses.explorer.export_section": "Ausgewähltes Adressbuch oder Liste exportieren",
|
||||||
|
"i18n:govoplan-addresses.explorer.vcard_version": "vCard-Exportversion",
|
||||||
|
"i18n:govoplan-addresses.explorer.export_list": "Adressliste exportieren",
|
||||||
|
"i18n:govoplan-addresses.explorer.export_book": "Adressbuch exportieren",
|
||||||
|
"i18n:govoplan-addresses.explorer.connect_section": "Eine Adressquelle verbinden",
|
||||||
|
"i18n:govoplan-addresses.explorer.sync_section": "Synchronisierung des ausgewählten Adressbuchs",
|
||||||
|
"i18n:govoplan-addresses.explorer.no_sync_source": "Das ausgewählte Adressbuch hat keine verbundene Synchronisierungsquelle.",
|
||||||
|
"Restore address list": "Adressliste wiederherstellen",
|
||||||
|
"Restore address book": "Adressbuch wiederherstellen",
|
||||||
|
"Delete address list": "Adressliste löschen",
|
||||||
|
"Delete address book": "Adressbuch löschen",
|
||||||
|
"Import contacts": "Kontakte importieren",
|
||||||
|
"Connect CardDAV": "CardDAV verbinden",
|
||||||
|
"Connect LDAP or Active Directory": "LDAP oder Active Directory verbinden",
|
||||||
|
"Inspect sync source": "Synchronisierungsquelle prüfen",
|
||||||
|
"Preview sync": "Synchronisierungsvorschau",
|
||||||
|
"Run sync": "Synchronisierung starten",
|
||||||
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Kontakt hinzufügen",
|
"i18n:govoplan-addresses.add_contact.6da0b4b8": "Kontakt hinzufügen",
|
||||||
"i18n:govoplan-addresses.address_book.f6327f59": "Adressbuch",
|
"i18n:govoplan-addresses.address_book.f6327f59": "Adressbuch",
|
||||||
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Adressbuch-Bereiche",
|
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Adressbuch-Bereiche",
|
||||||
|
|||||||
@@ -34,8 +34,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.address-book-page.address-book-fullscreen {
|
.address-book-page.address-book-fullscreen {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: 1fr;
|
flex-direction: column;
|
||||||
height: calc(100vh - 115px);
|
height: calc(100vh - 115px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -43,7 +43,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.address-workspace-frame {
|
.address-workspace-frame {
|
||||||
height: 100%;
|
flex: 1 1 auto;
|
||||||
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,22 +74,10 @@
|
|||||||
background: var(--panel-header);
|
background: var(--panel-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
.address-tree-header .button-row {
|
.address-page-actions {
|
||||||
flex-wrap: wrap;
|
border-bottom: var(--border-line);
|
||||||
}
|
flex: 0 0 auto;
|
||||||
|
padding: var(--space-3);
|
||||||
.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 {
|
.address-tree-filter-row {
|
||||||
|
|||||||
Reference in New Issue
Block a user