5 Commits
Author SHA1 Message Date
zemion 53490e7be7 Release govoplan-addresses v0.1.22: unify interface contracts and documentation
Module Package Release / publish-packages (push) Successful in 13s
2026-09-08 01:32:21 +02:00
zemion 3f1ff79e87 fix(webui): bind consequential address actions to help
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 11:36:31 +02:00
zemion 5cdf0ae9ff docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 01:15:30 +02:00
zemion 3458306e04 docs(addresses): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:11:47 +02:00
zemion 8740fb33f8 feat(addresses): add governed DSAR coverage 2026-08-21 01:19:17 +02:00
13 changed files with 2597 additions and 154 deletions
+11
View File
@@ -104,6 +104,17 @@ The module exposes core-mediated capabilities for:
pickers.
- `distribution.recipient_channel_facts`: current channel, governance, and
quality facts for distribution and Policy consumers.
- `privacy.dsar.addresses`: tenant-bounded, minimized data-subject discovery
across contacts, contact points, list use, governance, provenance,
synchronization evidence, and operator attribution.
The DSAR provider accepts corroborated email/account selectors and namespaced
Addresses references. It does not export connector state, raw import or sync
payloads, opaque metadata, snapshot payloads, or merge before/after payloads.
Reusable contacts are never deleted automatically: shared/synchronized contact
changes require an authorized dependency review through the ordinary Addresses
workflows, while governance, quality, merge, sync, import, and attribution
evidence is retained with an explicit reason.
`addresses.recipient_source` returns:
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.18",
"version": "0.1.22",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"README.md"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-addresses"
version = "0.1.18"
version = "0.1.22"
description = "GovOPlaN reusable address and recipient-source module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"defusedxml>=0.7.1",
"govoplan-core>=0.1.18",
"govoplan-core>=0.1.45",
"ldap3>=2.9.1,<3",
"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.'}}}
+573 -64
View File
@@ -1,5 +1,8 @@
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 pathlib import Path
@@ -11,12 +14,22 @@ from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
)
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.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.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.contact_points import (
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
from govoplan_core.core.distribution_lists import CAPABILITY_RECIPIENT_CHANNEL_FACTS
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationTopic,
FrontendModule,
FrontendRoute,
@@ -38,6 +51,7 @@ from govoplan_core.core.provider_governance import (
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_addresses.backend.dsar_provider import ADDRESSES_DSAR_CAPABILITY
from govoplan_addresses.backend.provider_state import (
CARDDAV_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_models.AddressImportRun,
addresses_models.AddressImportProfile,
@@ -77,10 +98,18 @@ def _addresses_retirement_provider(session: object | None, module_id: str):
return plan
def executor(execute_session: object, execute_module_id: str) -> None:
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
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
if not hasattr(execute_session, "get_bind") or not hasattr(
execute_session, "query"
):
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)
base_executor(execute_session, execute_module_id)
@@ -110,21 +139,77 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
PERMISSIONS = (
_permission("addresses:address_book:read", "View address books", "List address books visible to the current principal."),
_permission("addresses:address_book:write", "Manage address books", "Create and edit local address books."),
_permission("addresses:address_book:delete", "Delete address books", "Soft-delete local address books."),
_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."),
_permission(
"addresses:address_book:read",
"View address books",
"List address books visible to the current principal.",
),
_permission(
"addresses:address_book:write",
"Manage address books",
"Create and edit local address books.",
),
_permission(
"addresses:address_book:delete",
"Delete address books",
"Soft-delete local address books.",
),
_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",
name="Address book reader",
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 {
"address_books": session.query(AddressBook).filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None)).count(),
"address_lists": session.query(AddressList).filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None)).count(),
"contacts": session.query(Contact).filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None)).count(),
"active_contact_merges": session.query(ContactMergeRecord).filter(ContactMergeRecord.tenant_id == tenant_id, ContactMergeRecord.status == "active").count(),
"contact_quality_decisions": session.query(ContactPointQualityDecision).filter(ContactPointQualityDecision.tenant_id == tenant_id).count(),
"contact_point_snapshots": session.query(ContactPointSnapshot).filter(ContactPointSnapshot.tenant_id == tenant_id).count(),
"sync_sources": session.query(AddressSyncSource).filter(AddressSyncSource.tenant_id == tenant_id, AddressSyncSource.enabled.is_(True)).count(),
"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(),
"address_books": session.query(AddressBook)
.filter(AddressBook.tenant_id == tenant_id, AddressBook.deleted_at.is_(None))
.count(),
"address_lists": session.query(AddressList)
.filter(AddressList.tenant_id == tenant_id, AddressList.deleted_at.is_(None))
.count(),
"contacts": session.query(Contact)
.filter(Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None))
.count(),
"active_contact_merges": session.query(ContactMergeRecord)
.filter(
ContactMergeRecord.tenant_id == tenant_id,
ContactMergeRecord.status == "active",
)
.count(),
"contact_quality_decisions": session.query(ContactPointQualityDecision)
.filter(ContactPointQualityDecision.tenant_id == tenant_id)
.count(),
"contact_point_snapshots": session.query(ContactPointSnapshot)
.filter(ContactPointSnapshot.tenant_id == tenant_id)
.count(),
"sync_sources": session.query(AddressSyncSource)
.filter(
AddressSyncSource.tenant_id == tenant_id,
AddressSyncSource.enabled.is_(True),
)
.count(),
"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(
object_type="address_book",
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",
),
ProviderObjectDeclaration(
object_type="contact",
field_groups=("identity", "name", "postal", "email", "phone", "source_metadata"),
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
field_groups=(
"identity",
"name",
"postal",
"email",
"phone",
"source_metadata",
),
authority_modes=(
"external_authoritative",
"external_mirror",
"governed_sync",
),
default_authority_mode="governed_sync",
),
),
@@ -252,7 +385,15 @@ LDAP_PROVIDER = ExternalProviderDeclaration(
objects=(
ProviderObjectDeclaration(
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"),
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.",
outage="Existing contacts remain available and visibly stale; an unavailable directory never causes deletes.",
classifications=("personal", "confidential", "restricted"),
purposes=("directory projection", "recipient resolution", "identity-linked contact discovery"),
purposes=(
"directory projection",
"recipient resolution",
"identity-linked contact discovery",
),
retention="Address, audit, and records policies govern local projections and tombstone evidence.",
secret_handling="Bind secrets remain in reusable credential envelopes; URLs, previews, and diagnostics contain no credentials.",
),
@@ -293,27 +438,72 @@ LDAP_PROVIDER = ExternalProviderDeclaration(
manifest = ModuleManifest(
id="addresses",
name="Addresses",
version="0.1.18",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox", "connectors"),
version="0.1.22",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
optional_dependencies=(
"campaigns",
"mail",
"forms",
"reporting",
"portal",
"postbox",
"connectors",
),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, version="1.0.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"),
ModuleInterfaceProvider(
name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"
),
ModuleInterfaceProvider(
name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.9"
),
ModuleInterfaceProvider(
name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, version="1.0.0"
),
ModuleInterfaceProvider(
name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"
),
ModuleInterfaceProvider(
name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, version="0.1.0"
),
ModuleInterfaceProvider(name=ADDRESSES_DSAR_CAPABILITY, version="0.1.0"),
),
permissions=PERMISSIONS,
route_factory=_addresses_router,
role_templates=ROLE_TEMPLATES,
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(
module_id="addresses",
package_name="@govoplan/addresses-webui",
routes=(FrontendRoute(path="/address-book", component="AddressBookPage", required_any=("addresses:contact:read",), order=80),),
nav_items=(NavItem(path="/address-book", label="Address Book", icon="book-user", required_any=("addresses:contact:read",), order=80),),
routes=(
FrontendRoute(
path="/address-book",
component="AddressBookPage",
required_any=("addresses:contact:read",),
order=80,
),
),
nav_items=(
NavItem(
path="/address-book",
label="Address Book",
icon="book-user",
required_any=("addresses:contact:read",),
order=80,
),
),
product_areas=(
ProductAreaContribution(
id="people-responsibility",
@@ -321,17 +511,56 @@ manifest = ModuleManifest(
label="i18n:govoplan-core.product_area.people_responsibility",
icon="users",
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,
),
),
view_surfaces=(
ViewSurface(id="addresses.page", module_id="addresses", kind="route", label="Address Book", order=80),
ViewSurface(id="addresses.sources", module_id="addresses", kind="section", label="Address sources", order=10),
ViewSurface(id="addresses.contacts", module_id="addresses", kind="section", label="Contacts", order=20),
ViewSurface(id="addresses.detail", module_id="addresses", kind="section", label="Contact detail", order=30),
ViewSurface(id="addresses.governance", module_id="addresses", kind="action", label="Communication governance", order=40),
ViewSurface(id="addresses.sync", module_id="addresses", kind="action", label="Address synchronization", order=50),
ViewSurface(
id="addresses.page",
module_id="addresses",
kind="route",
label="Address Book",
order=80,
),
ViewSurface(
id="addresses.sources",
module_id="addresses",
kind="section",
label="Address sources",
order=10,
),
ViewSurface(
id="addresses.contacts",
module_id="addresses",
kind="section",
label="Contacts",
order=20,
),
ViewSurface(
id="addresses.detail",
module_id="addresses",
kind="section",
label="Contact detail",
order=30,
),
ViewSurface(
id="addresses.governance",
module_id="addresses",
kind="action",
label="Communication governance",
order=40,
),
ViewSurface(
id="addresses.sync",
module_id="addresses",
kind="action",
label="Address synchronization",
order=50,
),
),
),
migration_spec=MigrationSpec(
@@ -343,8 +572,13 @@ manifest = ModuleManifest(
retirement_notes="Destructive retirement drops address-owned database tables after the installer captures a database snapshot.",
),
capability_factories={
CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("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_LOOKUP: lambda context: __import__(
"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__(
"govoplan_addresses.backend.capabilities",
fromlist=["recipient_source_capability"],
@@ -361,6 +595,20 @@ manifest = ModuleManifest(
"govoplan_addresses.backend.capabilities",
fromlist=["contact_point_resolution_capability"],
).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=(
persistent_table_uninstall_guard(
@@ -387,6 +635,80 @@ manifest = ModuleManifest(
),
),
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(
id="addresses.boundary",
title="Reusable address ownership",
@@ -394,17 +716,55 @@ manifest = ModuleManifest(
body=(
"Campaigns may keep immutable campaign-local recipient snapshots, but durable address directories, "
"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",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
related_modules=(
"campaigns",
"mail",
"forms",
"reporting",
"portal",
"postbox",
),
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": (
"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={
"seed": True,
"help_contexts": [
"addresses.page",
"addresses.explorer.transfer",
"addresses.sources",
"addresses.contacts",
"addresses.detail",
@@ -427,6 +787,18 @@ manifest = ModuleManifest(
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("dist_lists", "campaigns", "policy", "templates"),
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={
"seed": True,
"help_contexts": [
@@ -457,8 +829,45 @@ manifest = ModuleManifest(
layer="configured",
documentation_types=("admin", "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"),
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; 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(
id="addresses.vcard-batches",
@@ -480,9 +889,33 @@ manifest = ModuleManifest(
audience=("tenant_admin", "operator", "module_admin", "power_user"),
related_modules=("files", "audit", "connectors"),
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={
"seed": True,
"help_contexts": ["addresses.action.import", "addresses.contacts", "addresses.sources"],
"help_contexts": [
"addresses.action.import",
"addresses.contacts",
"addresses.sources",
],
},
),
DocumentationTopic(
@@ -501,6 +934,22 @@ manifest = ModuleManifest(
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("connectors", "idm", "access", "policy", "audit"),
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(
id="addresses.quality-and-merge",
@@ -520,6 +969,21 @@ manifest = ModuleManifest(
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("campaigns", "dist_lists", "policy", "audit"),
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(
id="addresses.reference.fields-and-consequences",
@@ -537,9 +1001,37 @@ manifest = ModuleManifest(
layer="configured",
documentation_types=("admin", "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,
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={
"kind": "reference",
"seed": True,
"help_contexts": [
"addresses.field.book-scope",
@@ -579,15 +1071,27 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="docs/ADDRESS_MODULE_ARCHITECTURE.md",
test_ref="tests/test_addresses_service.py",
known_limits=("External address-book synchronization remains a bounded connector slice rather than a supported provider profile.",),
known_limits=(
"External address-book synchronization remains a bounded connector slice rather than a supported provider profile.",
),
supported_authority_modes=(
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
),
owned_concepts=("contact point", "address book", "contact consent", "recipient source"),
non_owned_concepts=("identity", "organization", "campaign recipient snapshot", "procedure party"),
owned_concepts=(
"contact point",
"address book",
"contact consent",
"recipient source",
),
non_owned_concepts=(
"identity",
"organization",
"campaign recipient snapshot",
"procedure party",
),
target_tested_providers=(CARDDAV_PROVIDER_ID,),
security_docs=("docs/ADDRESS_MODULE_ARCHITECTURE.md",),
operations_docs=("README.md",),
@@ -595,5 +1099,10 @@ manifest = ModuleManifest(
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
+588
View File
@@ -0,0 +1,588 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, User
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressImportRun,
AddressList,
AddressListEntry,
AddressSyncConflict,
AddressSyncSource,
AddressSyncTombstone,
Contact,
ContactChannelRule,
ContactEmail,
ContactFieldProvenance,
ContactMergeRecord,
ContactPhone,
ContactPointQualityDecision,
ContactPostalAddress,
ContactRedirect,
)
from govoplan_addresses.backend.dsar_provider import (
ADDRESSES_DSAR_CAPABILITY,
AddressesDsarProvider,
)
from govoplan_addresses.backend.manifest import manifest
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
class _Registry:
def __init__(
self,
provider: AddressesDsarProvider,
*,
addresses_active: bool = True,
) -> None:
self.provider = provider
self.addresses_active = addresses_active
def capability_names(self):
return (ADDRESSES_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "addresses"
def tenant_entitlement_resolver(self):
addresses_active = self.addresses_active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": (("addresses",) if addresses_active else ())},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "addresses"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != ADDRESSES_DSAR_CAPABILITY:
raise KeyError(name)
class AddressesDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(bind=self.engine)
self.session = sessionmaker(bind=self.engine, future=True)()
now = datetime.now(timezone.utc)
self.account = Account(
id="account-1",
email="subject@example.test",
normalized_email="subject@example.test",
display_name="Subject",
password_hash="password-secret-do-not-export",
)
self.user = User(
id="membership-1",
tenant_id="tenant-1",
account_id=self.account.id,
email=self.account.email,
display_name="Subject",
)
self.book = AddressBook(
id="book-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Residents",
created_by_account_id=self.account.id,
metadata_={"secret": "book-metadata-do-not-export"},
)
self.contact = Contact(
id="contact-1",
tenant_id="tenant-1",
address_book_id=self.book.id,
display_name="Subject Person",
given_name="Subject",
family_name="Person",
organization="Example household",
note="A bounded subject note",
tags=["resident"],
source_kind="carddav",
source_ref="https://source.invalid/private/contact.vcf",
source_payload_kind="vcard",
source_payload_raw="raw-source-payload-do-not-export",
source_revision="revision-7",
provenance={"secret": "contact-provenance-do-not-export"},
created_by_account_id=self.account.id,
metadata_={"secret": "contact-metadata-do-not-export"},
)
self.email = ContactEmail(
id="email-1",
contact_id=self.contact.id,
label="private",
email="Subject@Example.test",
original_email="Subject@Example.test",
normalized_email="subject@example.test",
provenance={"secret": "email-provenance-do-not-export"},
is_primary=True,
)
self.phone = ContactPhone(
id="phone-1",
contact_id=self.contact.id,
label="mobile",
phone="+49 30 123456",
original_phone="030 123456",
normalized_phone="+4930123456",
provenance={"secret": "phone-provenance-do-not-export"},
is_primary=True,
)
self.postal = ContactPostalAddress(
id="postal-1",
contact_id=self.contact.id,
label="home",
street="Example Street 1",
postal_code="10115",
locality="Berlin",
country="DE",
original_value={"secret": "postal-original-do-not-export"},
normalized_value={"secret": "postal-normalized-do-not-export"},
provenance={"secret": "postal-provenance-do-not-export"},
is_primary=True,
)
self.address_list = AddressList(
id="list-1",
tenant_id="tenant-1",
address_book_id=self.book.id,
name="District residents",
created_by_account_id="another-account",
)
self.list_entry = AddressListEntry(
id="entry-1",
address_list_id=self.address_list.id,
contact_id=self.contact.id,
contact_email_id=self.email.id,
target_kind="email",
metadata_={"secret": "list-entry-metadata-do-not-export"},
)
self.channel_rule = ContactChannelRule(
id="rule-1",
tenant_id="tenant-1",
contact_id=self.contact.id,
channel="email",
purpose="resident-notice",
contact_point_id=self.email.id,
decision="allow",
legal_basis="public task",
evidence_ref="records://consent/evidence-1",
reason="Current resident preference",
effective_from=now,
created_by_account_id="another-account",
metadata_={"secret": "rule-metadata-do-not-export"},
)
self.quality = ContactPointQualityDecision(
id="quality-1",
tenant_id="tenant-1",
contact_id=self.contact.id,
channel="email",
contact_point_id=self.email.id,
state="valid",
reason_code="verified",
reason="Verified by operator",
evidence_ref="files://private/evidence",
effective_from=now,
created_by_account_id="another-account",
metadata_={"secret": "quality-metadata-do-not-export"},
)
self.provenance = ContactFieldProvenance(
id="provenance-1",
tenant_id="tenant-1",
contact_id=self.contact.id,
field_path="emails[0].email",
value={"secret": "field-value-do-not-export"},
source_kind="carddav",
source_ref="https://source.invalid/private",
source_revision="revision-7",
precedence=10,
selected=True,
reason_code="source_authority",
explanation="Selected from the authoritative source",
visibility="operator",
created_by_account_id="another-account",
metadata_={"secret": "field-metadata-do-not-export"},
)
self.sync_source = AddressSyncSource(
id="source-1",
tenant_id="tenant-1",
address_book_id=self.book.id,
connector_type="carddav",
display_name="Residents CardDAV",
external_account_ref="private-account-ref-do-not-export",
external_address_book_ref="private-book-ref-do-not-export",
sync_token="sync-token-do-not-export",
etag="private-etag-do-not-export",
remote_revision="private-remote-revision-do-not-export",
last_diagnostic={"secret": "diagnostic-do-not-export"},
created_by_account_id=self.account.id,
metadata_={"secret": "source-metadata-do-not-export"},
)
self.tombstone = AddressSyncTombstone(
id="tombstone-1",
tenant_id="tenant-1",
sync_source_id=self.sync_source.id,
address_book_id=self.book.id,
contact_id=self.contact.id,
remote_uid="private-uid-do-not-export",
resource_href="private-href-do-not-export",
synced_at=now,
metadata_={"secret": "tombstone-metadata-do-not-export"},
)
self.conflict = AddressSyncConflict(
id="conflict-1",
tenant_id="tenant-1",
sync_source_id=self.sync_source.id,
address_book_id=self.book.id,
contact_id=self.contact.id,
remote_uid="private-conflict-uid-do-not-export",
resource_href="private-conflict-href-do-not-export",
field_path="family_name",
local_value={"secret": "local-value-do-not-export"},
remote_value={"secret": "remote-value-do-not-export"},
status="resolved",
resolution="local",
resolved_at=now,
resolved_by_account_id=self.account.id,
metadata_={"secret": "conflict-metadata-do-not-export"},
)
self.import_run = AddressImportRun(
id="import-1",
tenant_id="tenant-1",
address_book_id=self.book.id,
source_filename="contacts.csv",
source_format="csv",
input_hash="a" * 64,
plan_hash="b" * 64,
status="applied",
row_count=1,
statistics={"secret": "statistics-do-not-export"},
diagnostics=[{"secret": "import-diagnostic-do-not-export"}],
plan_data=[{"secret": "import-plan-do-not-export"}],
result_evidence={"secret": "import-result-do-not-export"},
created_by_account_id=self.account.id,
applied_at=now,
)
self.merge = ContactMergeRecord(
id="merge-1",
tenant_id="tenant-1",
address_book_id=self.book.id,
winner_contact_id=self.contact.id,
loser_contact_ids=["old-contact-1"],
status="active",
reason="Duplicate contact",
survivorship={"secret": "survivorship-do-not-export"},
decisions=[{"secret": "merge-decisions-do-not-export"}],
before_payload={"secret": "merge-before-do-not-export"},
after_payload={"secret": "merge-after-do-not-export"},
before_hash="c" * 64,
after_hash="d" * 64,
created_by_account_id="another-account",
provenance={"secret": "merge-provenance-do-not-export"},
)
self.redirect = ContactRedirect(
id="redirect-1",
tenant_id="tenant-1",
source_contact_id="old-contact-1",
target_contact_id=self.contact.id,
merge_record_id=self.merge.id,
)
self.unrelated = Contact(
id="contact-unrelated",
tenant_id="tenant-1",
address_book_id=self.book.id,
display_name="Unrelated Person",
note="unrelated-person-do-not-export",
)
unrelated_email = ContactEmail(
id="email-unrelated",
contact_id=self.unrelated.id,
email="unrelated@example.test",
original_email="unrelated@example.test",
normalized_email="unrelated@example.test",
)
tenant_two_book = AddressBook(
id="book-tenant-2",
tenant_id="tenant-2",
scope_type="tenant",
scope_id="tenant-2",
name="Other tenant",
)
tenant_two_contact = Contact(
id="contact-tenant-2",
tenant_id="tenant-2",
address_book_id=tenant_two_book.id,
display_name="Other Tenant Subject",
note="other-tenant-do-not-export",
)
tenant_two_email = ContactEmail(
id="email-tenant-2",
contact_id=tenant_two_contact.id,
email="subject@example.test",
original_email="subject@example.test",
normalized_email="subject@example.test",
)
self.session.add_all(
[
self.account,
self.user,
self.book,
self.contact,
self.email,
self.phone,
self.postal,
self.address_list,
self.list_entry,
self.channel_rule,
self.quality,
self.provenance,
self.sync_source,
self.tombstone,
self.conflict,
self.import_run,
self.merge,
self.redirect,
self.unrelated,
unrelated_email,
tenant_two_book,
tenant_two_contact,
tenant_two_email,
]
)
self.session.commit()
self.provider = AddressesDsarProvider()
self.subject = DsarSubjectRef(
account_id=self.account.id,
email=self.account.email,
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
provided_names = {item.name for item in manifest.provides_interfaces}
self.assertIn(ADDRESSES_DSAR_CAPABILITY, provided_names)
provider = manifest.capability_factories[ADDRESSES_DSAR_CAPABILITY](None)
self.assertIsInstance(provider, DsarProvider)
def test_search_is_tenant_scoped_related_and_minimized(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
resource_types = {record.resource_type for record in records}
self.assertTrue(
{
"addresses_contact",
"addresses_contact_email",
"addresses_contact_phone",
"addresses_contact_postal_address",
"addresses_list_membership",
"addresses_channel_rule",
"addresses_quality_decision",
"addresses_field_provenance",
"addresses_merge_record",
"addresses_contact_redirect",
"addresses_sync_tombstone",
"addresses_sync_conflict",
"addresses_address_book_attribution",
"addresses_sync_source_attribution",
"addresses_import_run_attribution",
}.issubset(resource_types)
)
serialized = repr([record.to_dict() for record in records])
excluded_values = (
"password-secret-do-not-export",
"raw-source-payload-do-not-export",
"contact-provenance-do-not-export",
"book-metadata-do-not-export",
"field-value-do-not-export",
"sync-token-do-not-export",
"private-account-ref-do-not-export",
"private-remote-revision-do-not-export",
"local-value-do-not-export",
"remote-value-do-not-export",
"import-plan-do-not-export",
"merge-before-do-not-export",
"merge-after-do-not-export",
"unrelated-person-do-not-export",
"other-tenant-do-not-export",
)
for value in excluded_values:
self.assertNotIn(value, serialized)
def test_conflicting_selectors_and_uncorroborated_reference_fail_closed(
self,
) -> None:
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"addresses.email": "other@example.test"},
),
)
uncorroborated = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"addresses.contact": self.unrelated.id},
),
)
self.assertEqual((), conflict)
self.assertEqual((), uncorroborated)
def test_plan_retains_evidence_and_routes_contact_data_to_review(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=records,
)
kinds = {action.kind for action in actions}
self.assertEqual({"manual_review", "retain"}, kinds)
self.assertFalse(any(action.executable for action in actions))
retained = [action for action in actions if action.kind == "retain"]
self.assertTrue(retained)
self.assertTrue(all(action.rationale for action in retained))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=actions,
request_id="dsar-addresses-1",
)
self.assertEqual({"blocked"}, {result.status for result in results})
self.assertIsNotNone(self.session.get(Contact, self.contact.id))
def test_execution_rejects_foreign_or_forged_executable_actions(self) -> None:
foreign = DsarErasureActionRef(
action_id="mail:delete:contact:contact-1",
provider_id="mail",
module_id="mail",
kind="delete",
resource_type="addresses_contact",
resource_id=self.contact.id,
title="Foreign delete",
rationale="Must be rejected",
executable=True,
)
forged = DsarErasureActionRef(
action_id="addresses:delete:addresses_contact:contact-1",
provider_id="addresses",
module_id="addresses",
kind="delete",
resource_type="addresses_contact",
resource_id=self.contact.id,
title="Forged delete",
rationale="Must be rejected",
executable=True,
)
with self.assertRaises(ValueError):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(foreign,),
request_id="dsar-addresses-2",
)
with self.assertRaises(ValueError):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(forged,),
request_id="dsar-addresses-2",
)
def test_core_workflow_discovers_active_and_inactive_provider(self) -> None:
request = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-ADDRESSES-1",
request_kind="access",
subject=self.subject,
purpose="Respond to an authorized privacy request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=request,
expected_revision=1,
)
self.assertEqual("searched", request.status)
self.assertEqual(["addresses"], request.coverage["covered_modules"])
self.assertEqual([], request.coverage["modules_without_provider"])
disabled = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-ADDRESSES-DISABLED",
request_kind="access",
subject=self.subject,
purpose="Verify disabled-module coverage.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, addresses_active=False),
row=disabled,
expected_revision=1,
)
self.assertEqual(0, disabled.search_result["record_count"])
self.assertEqual(
[ADDRESSES_DSAR_CAPABILITY],
disabled.coverage["inactive_provider_capabilities"],
)
if __name__ == "__main__":
unittest.main()
+16 -1
View File
@@ -6,6 +6,18 @@ from govoplan_addresses.backend.manifest import manifest
class AddressesInterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in manifest.documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual(
{"title", "summary", "body"},
set(german),
topic.id,
)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
def test_route_and_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
@@ -29,7 +41,10 @@ class AddressesInterfaceDocumentationContractTests(unittest.TestCase):
reference = topics["addresses.reference.fields-and-consequences"]
self.assertIn("addresses.state.read-only", boundary.metadata["help_contexts"])
self.assertIn("addresses.field.communication-purpose", governance.metadata["help_contexts"])
self.assertIn(
"addresses.field.communication-purpose",
governance.metadata["help_contexts"],
)
self.assertIn("addresses.action.sync", reference.metadata["help_contexts"])
self.assertIn("merge", reference.metadata["consequence_classes"])
self.assertIn("governance_fact", reference.metadata["consequence_classes"])
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/addresses-webui",
"version": "0.1.18",
"version": "0.1.22",
"private": true,
"type": "module",
"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"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=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(styles, /\.address-conflict-choice button/);
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.");
@@ -1,6 +1,6 @@
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 { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "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, useRef, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import { useSearchParams } from "react-router";
import { DialogSection, DialogForm, FormGrid, ActionToolbar,
ApiError,
@@ -18,6 +18,7 @@ import { DialogSection, DialogForm, FormGrid, ActionToolbar,
FormSection,
LoadingFrame,
MetricCard,
PageActionBar,
PasswordField,
SegmentedControl,
SelectionList,
@@ -659,7 +660,7 @@ function canMergeConflict(conflict: AddressSyncConflict): boolean {
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]));
}
@@ -963,7 +964,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const [selectedBookId, setSelectedBookId] = useState("");
const [selectedListId, setSelectedListId] = useState("");
const [selectedContactId, setSelectedContactId] = useState("");
const [selectedTreeGroup, setSelectedTreeGroup] = useState<Pick<AddressTreeNode, "id" | "label"> | null>(null);
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 [showArchived, setShowArchived] = useState(false);
const [loading, setLoading] = useState(false);
@@ -1136,8 +1140,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
.then((run) => {
if (!active) return;
setImportRun(run);
setSelectedTreeGroup(null);
setSelectedBookId(run.address_book_id);
setSelectedImportProfileId(run.profile_id);
setSelectedImportProfileId(run.profile_id ?? "");
})
.catch((err) => {
if (!active) return;
@@ -1161,9 +1166,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return () => {active = false;};
}, [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 selectedList = addressLists.find((list) => list.id === selectedListId) ?? null;
const selectedList = selectedTreeGroup ? null : addressLists.find((list) => list.id === selectedListId) ?? null;
const selectedBookSyncSources = useMemo(
() => selectedBook ? syncSources.filter((source) => source.address_book_id === selectedBook.id) : [],
[selectedBook, syncSources]
@@ -1172,7 +1177,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]);
const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]);
const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]);
const visibleContacts = contacts;
const visibleContacts = selectedTreeGroup ? [] : contacts;
const memberCandidateContacts = useMemo(() => {
const normalizedQuery = memberQuery.trim().toLowerCase();
return memberCandidates
@@ -1208,7 +1213,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return [];
}, [channelRuleForm.channel, governanceContact]);
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 savingReason = saving ? "An address-book action is already in progress." : "";
const createBookReason = disabledReason(
@@ -1570,10 +1575,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
useEffect(() => {
const defaultExpanded = expandedAddressTreeIds(addressTreeNodes);
if (defaultExpanded.size === 0) return;
const previousBranchIds = knownTreeBranchIds.current;
knownTreeBranchIds.current = defaultExpanded;
setExpandedTreeIds((current) => {
const next = new Set(current);
for (const id of defaultExpanded) next.add(id);
const next = new Set([...current].filter((id) => defaultExpanded.has(id)));
for (const id of defaultExpanded) {
if (!previousBranchIds.has(id)) next.add(id);
}
return next;
});
}, [addressTreeNodes]);
@@ -1950,6 +1958,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function openTreeNode(node: AddressTreeNode) {
if (node.kind === "book" && node.book) {
setSelectedTreeGroup(null);
setContactPage(1);
setSelectedBookId(node.book.id);
setSelectedListId("");
@@ -1957,13 +1966,16 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return;
}
if (node.kind === "list" && node.list) {
setSelectedTreeGroup(null);
setContactPage(1);
setSelectedBookId(node.list.address_book_id);
setSelectedListId(node.list.id);
setSelectedContactId("");
return;
}
toggleTreeNode(node);
setSelectedTreeGroup({ id: node.id, label: node.label });
setSelectedListId("");
setSelectedContactId("");
}
function toggleTreeNode(node: AddressTreeNode) {
@@ -2450,8 +2462,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
try {
const run = await getAddressImportRun(settings, importRun?.id || requestedImportRunId);
retainImportRun(run);
setSelectedTreeGroup(null);
setSelectedBookId(run.address_book_id);
setSelectedImportProfileId(run.profile_id);
setSelectedImportProfileId(run.profile_id ?? "");
} catch (err) {
setImportRun(null);
setImportRunUnavailable(
@@ -2555,7 +2568,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function downloadImportCorrections() {
if (!importRun) return;
const quote = (value: unknown) => `"${String(value ?? "").replaceAll('"', '""')}"`;
const quote = (value: unknown) => `"${String(value ?? "").replace(/"/g, '""')}"`;
const lines = [
["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(","))
@@ -2911,6 +2924,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
}
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>;
return (
<div className="address-source-summary">
@@ -2952,40 +2969,68 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
);
}
function renderSelectedBookActions() {
return (
<>
<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>
<Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
<Button type="button" title="Import contacts" aria-label="Import contacts" onClick={openImportDialog} disabledReason={importBookReason}><Upload size={15} /></Button>
<select aria-label="vCard export version" 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>
<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>
<Button type="button" title="Connect CardDAV" aria-label="Connect CardDAV" onClick={openCardDavDialog} disabledReason={connectCardDavReason}><Link2 size={15} /></Button>
<Button type="button" title="Connect LDAP or Active Directory" aria-label="Connect LDAP or Active Directory" onClick={openLdapDialog} disabledReason={connectLdapReason}><Network size={15} /></Button>
<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" title="Preview sync" aria-label="Preview sync" onClick={() => void previewSelectedSync()} disabledReason={previewSyncReason}>Preview</Button>
<Button type="button" title="Run sync" aria-label="Run sync" onClick={() => void runSelectedSync()} disabledReason={runSyncReason}>Sync</Button>
{selectedList ?
selectedList.deleted_at ?
<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" title="Edit address list" aria-label="Edit address list" disabledReason={editListReason(selectedList)} onClick={() => openEditListDialog(selectedList)}><Edit3 size={15} /></Button>
<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>
</> :
selectedBook?.deleted_at ?
<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" 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>
<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>
</>
}
</>
);
function chooseAddressAction(action: () => void) {
setAddressActionsOpen(null);
action();
}
function renderAddressActions() {
return <Dialog
open={addressActionsOpen !== null}
title={addressActionsOpen === "transfer" ? "i18n:govoplan-addresses.explorer.transfer" : addressActionsOpen === "connections" ? "i18n:govoplan-addresses.explorer.connections" : "i18n:govoplan-addresses.explorer.manage_selection"}
description={selectedList?.name ?? selectedBook?.name ?? "i18n:govoplan-addresses.explorer.no_book"}
onClose={() => setAddressActionsOpen(null)}
footer={<Button type="button" onClick={() => setAddressActionsOpen(null)}>i18n:govoplan-core.close.bbfa773e</Button>}>
{addressActionsOpen === "book" && <>
<FormSection title="i18n:govoplan-addresses.explorer.manage_selection">
<ActionToolbar surface="plain">
<Button type="button" onClick={() => chooseAddressAction(openCreateListDialog)} disabledReason={createListReason}><Plus size={16} aria-hidden="true" /> Add address list</Button>
{selectedList?.deleted_at ?
<Button type="button" disabledReason={restoreListReason(selectedList)} onClick={() => chooseAddressAction(() => void restoreDeletedList(selectedList))}><RotateCcw size={16} aria-hidden="true" /> Restore address list</Button> : selectedList ?
<Button type="button" disabledReason={editListReason(selectedList)} onClick={() => chooseAddressAction(() => openEditListDialog(selectedList))}><Edit3 size={16} aria-hidden="true" /> Edit address list</Button> : selectedBook?.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" 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>
</FormSection>
{(selectedList ? !selectedList.deleted_at : selectedBook && !selectedBook.deleted_at) && <FormSection variant="separated" title="i18n:govoplan-addresses.explorer.archive_section">
<ActionToolbar surface="plain">
{selectedList ?
<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>}
</ActionToolbar>
</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) {
@@ -3050,7 +3095,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</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" 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>
@@ -3196,6 +3241,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
return (
<div className="workspace-data-page module-entry-page address-book-page address-book-fullscreen">
<PageActionBar
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>
</>}
helpAction={<DocumentationHelpLink reference={ADDRESSES_DOCUMENTATION} />}
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>}
{notice && !error && <DismissibleAlert className="address-error" compact tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
{!canWriteBooks && !canWriteLists && !canWriteContacts && <ActionBlockerHint
@@ -3219,10 +3278,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<h2>Address books</h2>
<p>{books.length} book{books.length === 1 ? "" : "s"}</p>
</div>
<div className="button-row compact-actions address-icon-actions">
<DocumentationHelpLink reference={ADDRESSES_DOCUMENTATION} />
{renderSelectedBookActions()}
</div>
<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>
</header>
<div className="address-tree-filter-row">
<ToggleSwitch
@@ -3266,7 +3322,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<div>
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
<p>
{contactTotal} contact{contactTotal === 1 ? "" : "s"}
{selectedTreeGroup ? 0 : contactTotal} contact{!selectedTreeGroup && contactTotal === 1 ? "" : "s"}
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
</p>
</div>
@@ -3274,7 +3330,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedList &&
<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>
</header>
<ActionToolbar className="address-contact-toolbar">
@@ -3299,7 +3355,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<DataGridPaginationBar
page={contactPage}
pageSize={contactPageSize}
totalRows={contactTotal}
totalRows={selectedTreeGroup ? 0 : contactTotal}
pageSizeOptions={[25, 50, 100, 200]}
disabled={loading || saving || !selectedBook}
className="address-contact-pagination"
@@ -3317,6 +3373,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div>
</LoadingFrame>
{renderAddressActions()}
<Dialog
open={Boolean(bookDialog)}
title={bookDialog?.mode === "edit" ? "Edit address book" : "Add address book"}
@@ -3480,7 +3538,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<article className="address-governance-rule" key={rule.id}>
<div className="address-governance-rule-main">
<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} />
</span>
<span>{rule.purpose || "All purposes"}{rule.reason ? ` · ${rule.reason}` : ""}</span>
@@ -4196,7 +4254,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</FormField>
</FormGrid>
{cardDavForm.auth_type !== "none" &&
<FormField label="Reusable credential">
<FormField label="Reusable credential" helpContextId="addresses.sources" helpModuleId="addresses">
<select
value={cardDavForm.credential_envelope_id}
onChange={(event) => {
@@ -4227,15 +4285,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)} />
</FormField>
{!cardDavForm.credential_envelope_id &&
<FormField label="Password">
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
<FormField label="Password" helpContextId="addresses.sources" helpModuleId="addresses">
<PasswordField helpContextId="addresses.sources" helpModuleId="addresses" value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
</FormField>
}
</FormGrid>
}
{cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
<FormField label="Bearer token">
<PasswordField value={cardDavForm.bearer_token} onValueChange={(value) => setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" />
<FormField label="Bearer token" helpContextId="addresses.sources" helpModuleId="addresses">
<PasswordField helpContextId="addresses.sources" helpModuleId="addresses" value={cardDavForm.bearer_token} onValueChange={(value) => setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" />
</FormField>
}
{cardDavDiscovery.length > 0 &&
@@ -4277,7 +4335,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Display name">
<input value={ldapForm.display_name} onChange={(event) => setLdapForm((current) => ({ ...current, display_name: event.target.value }))} placeholder="Corporate directory" />
</FormField>
<FormField label="Reusable credential">
<FormField label="Reusable credential" helpContextId="addresses.sources" helpModuleId="addresses">
<select
value={ldapForm.credential_envelope_id}
onChange={(event) => {
@@ -4480,7 +4538,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<StatusBadge status={conflictDialog.status} />
{conflictDialog.resolution && <StatusBadge status={conflictDialog.resolution} />}
</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>}
</div>
<div className="address-conflict-grid">
+54
View File
@@ -2,6 +2,33 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
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.address_book.f6327f59": "Address Book",
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Address book scopes",
@@ -88,6 +115,33 @@ export const generatedTranslations: PlatformTranslations = {
"Note": "Note"
},
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.address_book.f6327f59": "Adressbuch",
"i18n:govoplan-addresses.address_book_scopes.b0d0efde": "Adressbuch-Bereiche",
+8 -19
View File
@@ -34,8 +34,8 @@
}
.address-book-page.address-book-fullscreen {
display: grid;
grid-template-rows: 1fr;
display: flex;
flex-direction: column;
height: calc(100vh - 115px);
overflow: hidden;
padding: 0;
@@ -43,7 +43,8 @@
}
.address-workspace-frame {
height: 100%;
flex: 1 1 auto;
height: auto;
min-height: 0;
}
@@ -73,22 +74,10 @@
background: var(--panel-header);
}
.address-tree-header .button-row {
flex-wrap: wrap;
}
.address-icon-actions {
max-width: 150px;
}
.address-icon-actions .btn {
align-items: center;
aspect-ratio: 1;
display: inline-flex;
justify-content: center;
min-height: 30px;
padding: 0;
width: 30px;
.address-page-actions {
border-bottom: var(--border-line);
flex: 0 0 auto;
padding: var(--space-3);
}
.address-tree-filter-row {