11 Commits
Author SHA1 Message Date
zemion 31e3899490 fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:40 +02:00
zemion f8d3190b4c fix(packaging): expose immutable WebUI Git package for v0.1.21
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:09 +02:00
zemion c337f2f1fa Release govoplan-dist-lists v0.1.21: unify interface contracts and documentation 2026-09-08 01:32:32 +02:00
zemion d1b2a05c91 fix(webui): bind list deletion to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:37 +02:00
zemion 5e85c9fc02 docs(dist-lists): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 19:31:21 +02:00
zemion b89d9fb434 feat(dist-lists): add governed DSAR coverage 2026-08-21 13:33:30 +02:00
zemion bcc6bdd2d7 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion 3f1177abbd feat: align distribution lists with shared UI foundations 2026-08-18 21:32:34 +02:00
zemion dfbb92752b Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion 52a51d29db Adopt shared WebUI layout primitives 2026-08-18 10:42:51 +02:00
zemion 0ecffa2547 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:51 +02:00
12 changed files with 1453 additions and 316 deletions
+18
View File
@@ -87,3 +87,21 @@ PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan/.venv/bin/
- [Distribution lists architecture](docs/DISTRIBUTION_LISTS_ARCHITECTURE.md)
- [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
- [AdreMa capability assessment](docs/ADREMA_CAPABILITY_ASSESSMENT.md)
## Git-source WebUI package
The repository root exposes `@govoplan/dist-lists-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/dist-lists-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@govoplan/dist-lists-webui",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/dist-lists.css": "./webui/src/styles/dist-lists.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-dist-lists"
version = "0.1.17"
version = "0.1.21"
description = "GovOPlaN operational distribution lists and governed audience expansion."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.17",
"govoplan-core>=0.1.45",
]
[tool.setuptools.packages.find]
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.17"
__version__ = "0.1.21"
@@ -0,0 +1,718 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parseaddr
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_dist_lists.backend.db.models import (
DistributionList,
DistributionListEntry,
DistributionListRevision,
DistributionListSnapshot,
)
DIST_LISTS_DSAR_CAPABILITY = dsar_capability_name("dist_lists")
_MAX_RECORDS = 5_000
_MAX_SNAPSHOTS = 5_000
_MAX_SNAPSHOT_RECIPIENTS = 20_000
_MAX_CHANNELS = 20
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _Selectors:
account_id: str | None
identity_id: str | None
email: str | None
contact_id: str | None
list_id: str | None
snapshot_id: str | None
entry_id: str | None
@property
def has_subject_identity(self) -> bool:
return bool(
self.account_id or self.identity_id or self.email or self.contact_id
)
class DistributionListsDsarProvider:
provider_id = "dist_lists"
module_id = "dist_lists"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _selectors(subject)
if selectors is None or not selectors.has_subject_identity:
return ()
records: list[DsarRecordRef] = []
seen: set[tuple[str, str]] = set()
def append(record: DsarRecordRef) -> None:
key = (record.resource_type, record.resource_id)
if key in seen:
return
if len(records) >= _MAX_RECORDS:
raise ValueError(
"Distribution Lists DSAR result limit exceeded; narrow selectors."
)
seen.add(key)
records.append(record)
self._append_subject_scopes(
db, append=append, tenant_id=tenant_id, selectors=selectors
)
self._append_definition_entries(
db, append=append, tenant_id=tenant_id, selectors=selectors
)
self._append_snapshot_recipients(
db, append=append, tenant_id=tenant_id, selectors=selectors
)
self._append_actor_attribution(
db, append=append, tenant_id=tenant_id, selectors=selectors
)
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
selectors = _selectors(subject)
if selectors is None or not selectors.has_subject_identity:
raise ValueError("Distribution Lists DSAR subject selectors are invalid.")
actions = []
for record in records:
_validate_record(record)
review = record.resource_type in {
"distribution_list_subject_scope",
"distribution_list_definition_entry",
}
actions.append(
DsarErasureActionRef(
action_id=(
f"dist_lists:{'manual_review' if review else 'retain'}:"
f"{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="manual_review" if review else "retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=("Review " if review else "Retain ") + record.title,
rationale=(
"Create a new list revision or retire the subject-owned list only "
"after reviewing downstream consumers; prior revisions remain evidence."
if review
else record.retention_reason
or "Distribution-list execution evidence remains retained."
),
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
selectors = _selectors(subject)
if selectors is None or not selectors.has_subject_identity:
raise ValueError("Distribution Lists DSAR subject selectors are invalid.")
results = []
for action in actions:
_validate_action(action)
if action.executable or action.kind not in {"manual_review", "retain"}:
raise ValueError(
"Distribution Lists DSAR publishes non-executable actions only."
)
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Distribution-list data remains unchanged pending a governed "
"revision and downstream-consumer review."
if action.kind == "manual_review"
else "Immutable distribution-list evidence remains retained."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _append_subject_scopes(
self,
db: Session,
*,
append,
tenant_id: str,
selectors: _Selectors,
) -> None:
if not selectors.account_id or selectors.snapshot_id or selectors.entry_id:
return
query = db.query(DistributionList).filter(
DistributionList.tenant_id == tenant_id,
DistributionList.scope_type == "user",
DistributionList.scope_id == selectors.account_id,
)
query = _list_filter(query, DistributionList, selectors)
for row in _limited(query, DistributionList.updated_at, DistributionList.id):
append(
_record(
"distribution_list_subject_scope",
row.id,
"subject_owned_distribution_list",
"Subject-scoped distribution list",
{
"distribution_list_id": row.id,
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"status": row.status,
"current_revision": row.current_revision,
"deleted_at": _iso(row.deleted_at),
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
},
observed_at=row.updated_at,
retention_reason=(
"List retirement requires downstream-consumer and retained-snapshot review."
),
)
)
def _append_definition_entries(
self,
db: Session,
*,
append,
tenant_id: str,
selectors: _Selectors,
) -> None:
if selectors.snapshot_id:
return
query = db.query(DistributionListEntry).filter(
DistributionListEntry.tenant_id == tenant_id
)
if selectors.entry_id:
query = query.filter(DistributionListEntry.id == selectors.entry_id)
if selectors.list_id:
query = query.join(DistributionListRevision).filter(
DistributionListRevision.distribution_list_id == selectors.list_id
)
for row in _limited(
query, DistributionListEntry.created_at, DistributionListEntry.id
):
if not _entry_matches(row, selectors):
continue
append(
_record(
"distribution_list_definition_entry",
row.id,
"distribution_list_recipient_definition",
"Distribution-list recipient definition",
{
"entry_id": row.id,
"revision_id": row.revision_id,
"kind": row.kind,
"mode": row.mode,
"source_provider": row.source_provider,
"source_resource_type": row.source_resource_type,
"source_resource_id": _bounded(row.source_resource_id, 500),
"purpose": _bounded(row.purpose, 120),
"requested_channels": [
str(value)[:40] for value in row.requested_channels[:10]
],
"effective_from": _iso(row.effective_from),
"effective_until": _iso(row.effective_until),
"created_at": _iso(row.created_at),
},
observed_at=row.created_at,
immutable=True,
retention_reason=(
"Saved distribution-list revisions remain evidence for existing snapshots."
),
)
)
def _append_snapshot_recipients(
self,
db: Session,
*,
append,
tenant_id: str,
selectors: _Selectors,
) -> None:
if selectors.entry_id:
return
query = db.query(DistributionListSnapshot).filter(
DistributionListSnapshot.tenant_id == tenant_id
)
query = _list_filter(query, DistributionListSnapshot, selectors)
if selectors.snapshot_id:
query = query.filter(DistributionListSnapshot.id == selectors.snapshot_id)
snapshots = (
query.order_by(
DistributionListSnapshot.created_at, DistributionListSnapshot.id
)
.limit(_MAX_SNAPSHOTS + 1)
.all()
)
if len(snapshots) > _MAX_SNAPSHOTS:
raise ValueError(
"Distribution Lists DSAR snapshot scan limit exceeded; narrow selectors."
)
for snapshot in snapshots:
total = len(snapshot.recipients) + len(snapshot.excluded)
if total > _MAX_SNAPSHOT_RECIPIENTS:
raise ValueError(
"Distribution Lists DSAR snapshot recipient limit exceeded."
)
for placement, rows in (
("included", snapshot.recipients),
("excluded", snapshot.excluded),
):
for index, recipient in enumerate(rows):
if not isinstance(recipient, dict) or not _recipient_matches(
recipient, selectors
):
continue
append(
_snapshot_recipient_record(
snapshot,
recipient=recipient,
placement=placement,
index=index,
)
)
def _append_actor_attribution(
self,
db: Session,
*,
append,
tenant_id: str,
selectors: _Selectors,
) -> None:
if not selectors.account_id:
return
if selectors.entry_id:
return
account_id = selectors.account_id
if not selectors.snapshot_id:
lists = db.query(DistributionList).filter(
DistributionList.tenant_id == tenant_id,
or_(
DistributionList.created_by_account_id == account_id,
DistributionList.updated_by_account_id == account_id,
),
)
lists = _list_filter(lists, DistributionList, selectors)
for row in _limited(
lists, DistributionList.updated_at, DistributionList.id
):
fields = []
if row.created_by_account_id == account_id:
fields.append("created_by_account_id")
if row.updated_by_account_id == account_id:
fields.append("updated_by_account_id")
append(
_attribution_record(
"distribution_list_actor_attribution",
row.id,
"Distribution-list actor attribution",
{
"distribution_list_id": row.id,
"activities": fields,
"status": row.status,
"current_revision": row.current_revision,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
},
row.updated_at,
)
)
revisions = db.query(DistributionListRevision).filter(
DistributionListRevision.tenant_id == tenant_id,
DistributionListRevision.created_by_account_id == account_id,
)
if selectors.list_id:
revisions = revisions.filter(
DistributionListRevision.distribution_list_id == selectors.list_id
)
for row in _limited(
revisions,
DistributionListRevision.created_at,
DistributionListRevision.id,
):
append(
_attribution_record(
"distribution_list_revision_actor_attribution",
row.id,
"Distribution-list revision actor attribution",
{
"distribution_list_id": row.distribution_list_id,
"revision_id": row.id,
"revision": row.revision,
"definition_kind": row.definition_kind,
"activity": "created_revision",
"created_at": _iso(row.created_at),
},
row.created_at,
)
)
snapshots = db.query(DistributionListSnapshot).filter(
DistributionListSnapshot.tenant_id == tenant_id,
DistributionListSnapshot.created_by_account_id == account_id,
)
snapshots = _list_filter(snapshots, DistributionListSnapshot, selectors)
if selectors.snapshot_id:
snapshots = snapshots.filter(
DistributionListSnapshot.id == selectors.snapshot_id
)
for row in _limited(
snapshots, DistributionListSnapshot.created_at, DistributionListSnapshot.id
):
append(
_attribution_record(
"distribution_list_snapshot_actor_attribution",
row.id,
"Distribution-list snapshot actor attribution",
{
"distribution_list_id": row.distribution_list_id,
"snapshot_id": row.id,
"revision_number": row.revision_number,
"effective_at": _iso(row.effective_at),
"activity": "froze_expansion_snapshot",
"created_at": _iso(row.created_at),
},
row.created_at,
)
)
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("dist_lists.account"),
references.get("access.account"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("dist_lists.identity"),
references.get("identity.id"),
),
"email": _coalesce_email(subject.email, references.get("dist_lists.email")),
"contact_id": _coalesce(
references.get("dist_lists.contact"),
references.get("addresses.contact"),
),
"list_id": _coalesce(
references.get("dist_lists.list"),
references.get("dist_lists.list_id"),
),
"snapshot_id": _coalesce(
references.get("dist_lists.snapshot"),
references.get("dist_lists.snapshot_id"),
),
"entry_id": _coalesce(
references.get("dist_lists.entry"),
references.get("dist_lists.entry_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
return _Selectors(**{key: _optional(value) for key, value in values.items()})
def _entry_matches(row: DistributionListEntry, selectors: _Selectors) -> bool:
resource_id = row.source_resource_id.strip()
if row.kind == "raw_email" and selectors.email:
return _normalized_email(resource_id) == selectors.email
if row.kind == "internal_mail" and selectors.account_id:
return resource_id == selectors.account_id
if row.kind == "idm_identity" and selectors.identity_id:
return resource_id == selectors.identity_id
if row.kind == "address_contact" and selectors.contact_id:
return resource_id == selectors.contact_id
return False
def _recipient_matches(recipient: Mapping[str, object], selectors: _Selectors) -> bool:
if (
selectors.account_id
and _text(recipient.get("account_id")) == selectors.account_id
):
return True
if (
selectors.identity_id
and _text(recipient.get("identity_id")) == selectors.identity_id
):
return True
if (
selectors.contact_id
and _text(recipient.get("contact_id")) == selectors.contact_id
):
return True
if not selectors.email:
return False
if _normalized_recipient_key(recipient.get("recipient_key")) == selectors.email:
return True
channels = recipient.get("channels")
if not isinstance(channels, list) or len(channels) > _MAX_CHANNELS:
return False
return any(
isinstance(channel, dict)
and (
_normalized_email(channel.get("target")) == selectors.email
or _normalized_recipient_key(channel.get("target_key")) == selectors.email
)
for channel in channels
)
def _snapshot_recipient_record(
snapshot: DistributionListSnapshot,
*,
recipient: Mapping[str, object],
placement: str,
index: int,
) -> DsarRecordRef:
channels = recipient.get("channels")
safe_channels = []
if isinstance(channels, list):
if len(channels) > _MAX_CHANNELS:
raise ValueError(
"Distribution Lists DSAR recipient channel limit exceeded."
)
for channel in channels:
if not isinstance(channel, dict):
continue
safe_channels.append(
{
"channel": _bounded(channel.get("channel"), 40),
"target": _bounded(channel.get("target"), 1_000),
"target_key": _bounded(channel.get("target_key"), 1_000),
"status": _bounded(channel.get("status"), 40),
"contact_point_id": _bounded(channel.get("contact_point_id"), 120),
"locale": _bounded(channel.get("locale"), 40),
"reason_code": _bounded(channel.get("reason_code"), 120),
}
)
return _record(
"distribution_list_snapshot_recipient",
f"{snapshot.id}:{placement}:{index}",
"frozen_recipient_projection",
"Frozen distribution-list recipient",
{
"snapshot_id": snapshot.id,
"distribution_list_id": snapshot.distribution_list_id,
"revision_number": snapshot.revision_number,
"effective_at": _iso(snapshot.effective_at),
"placement": placement,
"recipient_key": _bounded(recipient.get("recipient_key"), 1_000),
"display_name": _bounded(recipient.get("display_name"), 500),
"status": _bounded(recipient.get("status"), 40),
"account_id": _bounded(recipient.get("account_id"), 120),
"identity_id": _bounded(recipient.get("identity_id"), 120),
"contact_id": _bounded(recipient.get("contact_id"), 120),
"channels": safe_channels,
},
observed_at=snapshot.effective_at,
immutable=True,
retention_reason=(
"Frozen recipient decisions are immutable downstream execution evidence."
),
)
def _attribution_record(
resource_type: str,
resource_id: str,
title: str,
data: dict[str, object],
observed_at: datetime | None,
) -> DsarRecordRef:
return _record(
resource_type,
resource_id,
"distribution_list_governance_attribution",
title,
data,
observed_at=observed_at,
immutable=True,
retention_reason=(
"Distribution-list lifecycle attribution is retained for accountability."
),
)
def _record(
resource_type: str,
resource_id: str,
category: str,
title: str,
data: dict[str, object],
*,
observed_at: datetime | None,
immutable: bool = False,
retention_reason: str | None = None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="dist_lists",
module_id="dist_lists",
resource_type=resource_type,
resource_id=resource_id,
category=category,
title=title,
data=data,
observed_at=_aware(observed_at),
immutable_evidence=immutable,
retention_reason=retention_reason,
source_path="/distribution-lists",
)
def _list_filter(query, model, selectors: _Selectors):
if selectors.list_id:
column = model.id if model is DistributionList else model.distribution_list_id
query = query.filter(column == selectors.list_id)
return query
def _limited(query, first, second):
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Distribution Lists DSAR query limit exceeded; narrow selectors."
)
return rows
def _coalesce(*values: object) -> str | None | object:
normalized = {_text(value) for value in values if _text(value)}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _coalesce_email(*values: object) -> str | None | object:
normalized = {
_normalized_email(value) for value in values if _normalized_email(value)
}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _normalized_email(value: object) -> str | None:
text = _text(value)
if not text:
return None
_, address = parseaddr(text)
normalized = (address or text).strip().casefold()
return normalized if "@" in normalized and len(normalized) <= 320 else None
def _normalized_recipient_key(value: object) -> str | None:
text = _text(value)
if text and text.casefold().startswith("email:"):
return _normalized_email(text[6:])
return None
def _text(value: object) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
def _bounded(value: object, limit: int) -> str | None:
text = _text(value)
return text[:limit] if text else None
def _optional(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Distribution Lists DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"distribution_list_subject_scope",
"distribution_list_definition_entry",
"distribution_list_snapshot_recipient",
"distribution_list_actor_attribution",
"distribution_list_revision_actor_attribution",
"distribution_list_snapshot_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "dist_lists" or record.module_id != "dist_lists":
raise ValueError("Distribution Lists DSAR cannot plan a foreign record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Distribution Lists DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "dist_lists" or action.module_id != "dist_lists":
raise ValueError("Distribution Lists DSAR cannot execute a foreign action.")
if not action.action_id.startswith("dist_lists:"):
raise ValueError("Distribution Lists DSAR action identity is invalid.")
__all__ = ["DIST_LISTS_DSAR_CAPABILITY", "DistributionListsDsarProvider"]
+249 -10
View File
@@ -30,6 +30,8 @@ from govoplan_core.core.module_guards import (
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationTopic,
FrontendModule,
FrontendRoute,
@@ -40,6 +42,7 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
@@ -47,10 +50,14 @@ from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_dist_lists.backend.db import models as dist_list_models
from govoplan_dist_lists.backend.dsar_provider import (
DIST_LISTS_DSAR_CAPABILITY,
DistributionListsDsarProvider,
)
MODULE_ID = "dist_lists"
MODULE_NAME = "Distribution Lists"
MODULE_VERSION = "0.1.17"
MODULE_VERSION = "0.1.21"
READ_SCOPE = "dist_lists:list:read"
WRITE_SCOPE = "dist_lists:list:write"
@@ -72,9 +79,21 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
PERMISSIONS = (
_permission(READ_SCOPE, "View distribution lists", "Read operational distribution lists and expansion previews."),
_permission(WRITE_SCOPE, "Manage distribution lists", "Create and edit operational distribution lists."),
_permission(ADMIN_SCOPE, "Administer distribution lists", "Configure distribution-list policies and provider integrations."),
_permission(
READ_SCOPE,
"View distribution lists",
"Read operational distribution lists and expansion previews.",
),
_permission(
WRITE_SCOPE,
"Manage distribution lists",
"Create and edit operational distribution lists.",
),
_permission(
ADMIN_SCOPE,
"Administer distribution lists",
"Configure distribution-list policies and provider integrations.",
),
)
ROLE_TEMPLATES = (
@@ -87,6 +106,88 @@ ROLE_TEMPLATES = (
)
DOCUMENTATION = (
DocumentationTopic(
id="dist_lists.workspace-layout",
title="Distribution Lists workspace actions",
summary="Find collection-wide commands in their consistent workspace position.",
body="Open the documentation book beside Distribution Lists for workspace guidance; field "
"documentation stays beside the relevant label. "
"Reload and New distribution list use the persistent full-width workspace header at the upper right; Reload sits immediately before creation. Selecting a record, changing filters, or opening an editor does not move these collection-wide commands into the left pane. Member editing and saving stay with the selected list; membership, provenance, and delivery semantics are unchanged. Existing permissions, disabled-state rules, and unsaved-change guards still apply. Administrators configure authority through the existing permission system; no new permission or automatic operation is introduced.",
layer="static",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator"),
order=5,
translations={"de": {
"title": "Verteilerlisten: Aktionen im Arbeitsbereich",
"summary": "Sammlungsweite Aktionen an ihrer einheitlichen Position im Arbeitsbereich finden.",
"body": "Öffnen Sie das Dokumentationsbuch neben Verteilerlisten für Hinweise zum Arbeitsbereich; "
"Felddokumentation bleibt neben der jeweiligen Feldbezeichnung. "
"Neu laden und Neue Verteilerliste stehen oben rechts in der dauerhaft sichtbaren, arbeitsbereichsweiten Leiste; Neu laden steht unmittelbar vor dem Anlegen. Auswahl, Filterwechsel und Bearbeitung verschieben diese sammlungsweiten Aktionen nicht in den linken Bereich. Bearbeiten der Mitglieder und Speichern bleiben bei der ausgewählten Liste; Mitgliedschaft, Herkunftsnachweise und Zustellungssemantik ändern sich nicht. Bestehende Berechtigungen, Deaktivierungsregeln und der Schutz ungespeicherter Änderungen gelten weiterhin. Administratoren konfigurieren Rechte im bestehenden Berechtigungssystem; es entstehen weder neue Rechte noch automatische Vorgänge.",
}},
),
DocumentationTopic(
id=f"{MODULE_ID}.data-subject-requests",
title="Distribution-list data-subject requests",
summary="Export exact recipient projections without exposing the rest of an audience.",
body=(
"Distribution Lists matches exact account, identity, contact, and normalized "
"email selectors against saved recipient entries and frozen expansion snapshots. "
"A list, snapshot, or entry reference may only narrow a verified subject match; "
"it never establishes subject identity by itself. Each frozen snapshot result "
"contains only the matching recipient and minimized channel facts, never other "
"recipients, request parameters, diagnostics, provider evidence, provenance, or "
"expansion hashes. Exact account attribution for list creation, revisions, and "
"snapshot creation is exported separately. Frozen snapshots, saved revisions, "
"and lifecycle attribution remain evidence. Removing a current recipient entry "
"or subject-scoped list requires a new revision and downstream-consumer review."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "addresses", "identity", "campaigns", "audit"),
order=90,
metadata={
"kind": "reference",
"seed": True,
"help_contexts": [
"dist_lists.page",
"dist_lists.preview",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_subject_recipient": "Returns only the exactly matched recipient projection.",
"review_recipient_removal": "Requires a new revision and consumer-impact review.",
"retain_frozen_snapshot": "Preserves immutable expansion and execution evidence.",
},
},
translations={
"de": {
"title": "Datenschutzanfragen zu Verteilerlisten",
"summary": "Exakte Empfängerprojektionen ausgeben, ohne die übrige Zielgruppe offenzulegen.",
"body": (
"Distribution Lists gleicht exakte Konto-, Identitäts-, Kontakt- und normalisierte E-Mail-Selektoren "
"mit gespeicherten Empfängereinträgen und eingefrorenen Erweiterungsschnappschüssen ab. Ein Listen-, "
"Schnappschuss- oder Eintragsverweis darf einen verifizierten Personenabgleich nur einschränken und "
"begründet niemals selbst die Identität. Jedes Ergebnis eines eingefrorenen Schnappschusses enthält nur "
"den passenden Empfänger und minimierte Kanalfakten, niemals andere Empfänger, Anfrageparameter, Diagnosen, "
"Anbieternachweise, Provenienz oder Erweiterungsprüfsummen. Exakte Kontozuordnungen für Listenerstellung, "
"Revisionen und Schnappschusserstellung werden getrennt ausgegeben. Eingefrorene Schnappschüsse, gespeicherte "
"Revisionen und Lebenszykluszuordnungen bleiben Nachweise. Das Entfernen eines aktuellen Empfängereintrags "
"oder einer personenbezogenen Liste erfordert eine neue Revision und die Prüfung nachgelagerter Verbraucher."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"export_subject_recipient": "Gibt nur die exakt übereinstimmende Empfängerprojektion zurück.",
"review_recipient_removal": "Erfordert eine neue Revision und die Prüfung der Auswirkungen auf Verbraucher.",
"retain_frozen_snapshot": "Bewahrt unveränderliche Erweiterungs- und Ausführungsnachweise.",
}
}
},
),
DocumentationTopic(
id=f"{MODULE_ID}.boundary",
title="Distribution list boundary",
@@ -94,13 +195,26 @@ DOCUMENTATION = (
body=(
"Distribution Lists owns reusable mixed recipient definitions and expansion snapshots. "
"Address lists remain in the addresses module and represent address-domain groupings only. "
"Workflow and Tasks own Umlauf execution state, ordering, deadlines, escalation, and completion."
"Workflow and Tasks own Umlauf execution state, ordering, deadlines, escalation, and completion. "
"Entry editing and result-explanation controls use the shared action-column layout and stay reachable at the right edge when wide tables are scrolled."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner"),
related_modules=("addresses", "campaigns", "mail", "postbox", "notifications", "scheduling", "poll", "workflow_engine", "tasks"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
related_modules=(
"addresses",
"campaigns",
"mail",
"postbox",
"notifications",
"scheduling",
"poll",
"workflow_engine",
"tasks",
),
metadata={
"kind": "workflow",
"seed": True,
"help_contexts": [
"dist_lists.page",
@@ -109,6 +223,20 @@ DOCUMENTATION = (
"dist_lists.picker",
],
},
translations={
"de": {
"title": "Abgrenzung von Verteilerlisten",
"summary": (
"Distribution Lists bildet operative Verteiler getrennt von Adressbuchlisten und der Workflow-Ausführung von Umläufen ab."
),
"body": (
"Distribution Lists führt wiederverwendbare gemischte Empfängerdefinitionen und Erweiterungsschnappschüsse. "
"Adresslisten verbleiben im Modul Addresses und bilden ausschließlich Gruppierungen des Adressbereichs ab. "
"Workflow und Tasks führen Ausführungszustand, Reihenfolge, Fristen, Eskalation und Abschluss von Umläufen. "
"Die Steuerelemente zum Bearbeiten von Einträgen und Erläutern von Ergebnissen verwenden das gemeinsame Aktionsspaltenlayout und bleiben beim Scrollen breiter Tabellen am rechten Rand erreichbar."
),
}
},
),
DocumentationTopic(
id=f"{MODULE_ID}.address-contact-resolution",
@@ -127,6 +255,20 @@ DOCUMENTATION = (
audience=("operator", "module_admin", "product_owner"),
related_modules=("addresses", "campaigns", "policy"),
metadata={"seed": True},
translations={
"de": {
"title": "Kontaktpunktauflösung aus Addresses",
"summary": "Adresskontakte und -listen in zweckbezogene E-Mail- und Postkandidaten auflösen.",
"body": (
"Wenn Addresses aktiviert ist, verwendet Distribution Lists dessen versionierten Kontaktpunktvertrag, um "
"Kommunikationszweck, angeforderte Kanäle, Adresszweck, Rückfall, Sprache, Qualität, Präferenz, Einwilligung "
"und Unterdrückung aufzulösen. Erweiterungsvorschauen erklären abgelehnte Kontaktpunkte. Eingefrorene "
"Verteilerschnappschüsse bewahren das exakt gerenderte E-Mail- oder Postziel, die Kontaktpunktkennung, "
"Quellrevision und den Fingerabdruck sowie die Entscheidungsprovenienz. Ist der neue Vertrag nicht verfügbar, "
"bleiben ältere Nachschlage- und E-Mail-Quellfähigkeiten von Addresses als Kompatibilitätsrückfall bestehen."
),
}
},
),
DocumentationTopic(
id=f"{MODULE_ID}.idm-group-resolution",
@@ -145,6 +287,19 @@ DOCUMENTATION = (
audience=("operator", "module_admin", "product_owner"),
related_modules=("identity", "idm"),
metadata={"seed": True},
translations={
"de": {
"title": "Wirksame IDM-Gruppenzielgruppen",
"summary": "Typisierte IDM-Gruppen in erklärbare, zeitbezogen wirksame Identitätsempfänger erweitern.",
"body": (
"Wenn IDM aktiviert ist, wird ein IDM-Gruppeneintrag zum Wirksamkeitszeitpunkt der Erweiterung über die "
"Fähigkeit idm.relationships aufgelöst. Wirksame Identitäten mit verknüpften Konten werden Kandidaten für "
"interne Nachrichten. Zukünftige, abgelaufene, widerrufene, inaktive und kontolose Beziehungen verbleiben "
"mit Quellrevisionen und Provenienz im Ausschlussnachweis. Ohne IDM wird der Anbieter als nicht verfügbar "
"gemeldet; lokale oder andere anbietergestützte Listen funktionieren weiter."
),
}
},
),
DocumentationTopic(
id=f"{MODULE_ID}.reference.fields-and-consequences",
@@ -162,8 +317,17 @@ DOCUMENTATION = (
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner"),
related_modules=("addresses", "campaigns", "dataflow", "idm", "policy", "reporting", "workflow_engine"),
related_modules=(
"addresses",
"campaigns",
"dataflow",
"idm",
"policy",
"reporting",
"workflow_engine",
),
metadata={
"kind": "reference",
"seed": True,
"help_contexts": [
"dist_lists.field.definition-kind",
@@ -182,6 +346,37 @@ DOCUMENTATION = (
"delete_definition": "Retires the reusable definition without rewriting retained frozen snapshots.",
},
},
translations={
"de": {
"title": "Felder und Folgen von Verteilerlisten",
"summary": (
"Definitionsarten, Zielgruppenmodi, Wirksamkeitszeiten, Kanalanforderungen, Erweiterung und Folgen eingefrorener Schnappschüsse."
),
"body": (
"Statische Definitionen enthalten ausdrückliche Einträge; parametrisierte und dynamische Definitionen "
"verwenden typisierte Werte oder Anbieterresultate. Vorlagen sind wiederverwendbare Definitionen, die nicht "
"eigenständig ausgeführt werden können. Einschlusszeilen fügen Zielgruppen hinzu, Ausschlüsse entfernen sie, "
"und manuelle Übersteuerungen erfordern einen prüfbaren Grund. Wirksamkeitszeiten begrenzen die Teilnahme "
"eines Eintrags. Angeforderte Kanäle schränken Zustellmöglichkeiten ein, umgehen aber weder Policy noch "
"Einwilligung, Unterdrückung oder Anbieterentscheidungen. Speichern erzeugt eine unveränderliche Revision. "
"Die Vorschau erklärt aktuelle Empfänger und Ausschlüsse, ohne Nachweise aufzubewahren. Einfrieren hält "
"exakte Revision, Parameter, Anbieterrevisionen, Empfänger, Ausschlüsse, Provenienz und Erweiterungsprüfsumme "
"als unveränderlichen Verbrauchernachweis fest. Das Löschen einer Definition schreibt die nach ihrer Regel "
"aufbewahrten Schnappschüsse nicht um."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"save_revision": "Erzeugt eine neue unveränderliche Revision und bewahrt frühere Revisionen für bestehende Nachweise.",
"expand_preview": "Löst die gespeicherte Revision auf, ohne dauerhafte Zustellnachweise zu erzeugen.",
"freeze_snapshot": "Speichert exakte Erweiterungseingaben, Entscheidungen, Empfänger, Ausschlüsse, Provenienz und Prüfsumme.",
"delete_definition": "Setzt die wiederverwendbare Definition außer Betrieb, ohne aufbewahrte Schnappschüsse umzuschreiben.",
}
}
},
),
)
@@ -198,6 +393,10 @@ def _capability(context: ModuleContext):
return capability(context)
def _dsar_provider(_context: ModuleContext) -> DistributionListsDsarProvider:
return DistributionListsDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"distribution_lists": (
@@ -268,6 +467,10 @@ manifest = ModuleManifest(
name=CAPABILITY_DISTRIBUTION_LIST_WRITER,
version=MODULE_VERSION,
),
ModuleInterfaceProvider(
name=DIST_LISTS_DSAR_CAPABILITY,
version="0.1.0",
),
),
requires_interfaces=(
ModuleInterfaceRequirement(
@@ -320,6 +523,20 @@ manifest = ModuleManifest(
order=74,
),
),
product_areas=(
ProductAreaContribution(
id="communication",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.communication",
icon="mail",
description="i18n:govoplan-core.product_area.communication_description",
surface_ids=(
"dist_lists.nav.distribution.lists",
"dist_lists.route.distribution.lists",
),
order=40,
),
),
view_surfaces=(
ViewSurface(
id="dist_lists.page",
@@ -356,6 +573,17 @@ manifest = ModuleManifest(
CAPABILITY_DISTRIBUTION_LIST_SOURCE: _capability,
CAPABILITY_DISTRIBUTION_LIST_EXPAND: _capability,
CAPABILITY_DISTRIBUTION_LIST_WRITER: _capability,
DIST_LISTS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
DIST_LISTS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Distribution Lists data-subject request provider",
summary=(
"Exports exact recipient projections and minimized lifecycle attribution "
"without disclosing other audience members."
),
contract_version="0.1.0",
),
},
tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec(
@@ -391,10 +619,21 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",
test_ref="tests/test_service.py",
known_limits=("Portal self-service and every AdreMa migration path are not yet implemented.",),
known_limits=(
"Portal self-service and every AdreMa migration path are not yet implemented.",
),
supported_authority_modes=("native_authoritative", "external_mirror"),
owned_concepts=("distribution list", "distribution-list revision", "frozen expansion"),
non_owned_concepts=("contact point", "campaign recipient snapshot", "identity", "dataflow output"),
owned_concepts=(
"distribution list",
"distribution-list revision",
"frozen expansion",
),
non_owned_concepts=(
"contact point",
"campaign recipient snapshot",
"identity",
"dataflow output",
),
recovery_docs=("docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",),
security_docs=("docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",),
operations_docs=("README.md",),
+282
View File
@@ -0,0 +1,282 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_dist_lists.backend.db.models import (
DistributionList,
DistributionListEntry,
DistributionListRevision,
DistributionListSnapshot,
)
from govoplan_dist_lists.backend.dsar_provider import (
DIST_LISTS_DSAR_CAPABILITY,
DistributionListsDsarProvider,
)
from govoplan_dist_lists.backend.manifest import manifest
NOW = datetime(2026, 8, 22, 16, 0, tzinfo=UTC)
def _recipient(
*,
key: str,
email: str,
account_id: str | None = None,
identity_id: str | None = None,
) -> dict[str, object]:
return {
"recipient_key": key,
"display_name": f"Recipient {email}",
"status": "usable",
"channels": [
{
"channel": "email",
"target": email,
"target_key": f"email:{email.casefold()}",
"status": "usable",
"contact_point_id": f"contact-point-{key}",
"decision_provenance": {"secret": "do-not-export-provenance"},
}
],
"account_id": account_id,
"identity_id": identity_id,
"contact_id": None,
"source_entry_ids": ["entry-1"],
"attributes": {"secret": "do-not-export-attributes"},
"provenance": {"secret": "do-not-export-recipient-provenance"},
}
class DistributionListsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = DistributionListsDsarProvider()
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
distribution_list = DistributionList(
id="list-1",
tenant_id="tenant-1",
scope_type="user",
scope_id="account-1",
name="Sensitive list name do not export",
description="Sensitive description do not export",
status="active",
current_revision_id="revision-1",
current_revision=1,
resource_revision=1,
created_by_account_id="account-1",
updated_by_account_id="account-1",
metadata_={"secret": "list-metadata-do-not-export"},
created_at=NOW,
updated_at=NOW,
)
revision = DistributionListRevision(
id="revision-1",
tenant_id="tenant-1",
distribution_list_id="list-1",
revision=1,
definition_kind="static",
definition_hash="definition-hash-do-not-export",
parameter_schema=[{"secret": "parameter-do-not-export"}],
constraints={"secret": "constraint-do-not-export"},
source_fingerprints=[{"secret": "fingerprint-do-not-export"}],
created_by_account_id="account-1",
created_at=NOW,
updated_at=NOW,
)
entry = DistributionListEntry(
id="entry-1",
tenant_id="tenant-1",
revision_id="revision-1",
entry_key="subject-email",
kind="raw_email",
mode="include",
source_provider="local",
source_resource_type="email",
source_resource_id="Subject@Example.test",
source_revision="source-revision-do-not-export",
source_fingerprint="source-fingerprint-do-not-export",
source_label="source-label-do-not-export",
source_metadata={"secret": "source-metadata-do-not-export"},
label="entry-label-do-not-export",
purpose="service_notice",
requested_channels=["email"],
order_index=0,
configuration={"secret": "configuration-do-not-export"},
created_at=NOW,
updated_at=NOW,
)
snapshot = DistributionListSnapshot(
id="snapshot-1",
tenant_id="tenant-1",
distribution_list_id="list-1",
revision_id="revision-1",
revision_number=1,
idempotency_key="idempotency-key-do-not-export",
request_={"secret": "request-do-not-export"},
expansion_hash="expansion-hash-do-not-export",
effective_at=NOW,
recipient_count=2,
excluded_count=0,
recipients=[
_recipient(
key="identity-1",
email="subject@example.test",
account_id="account-1",
identity_id="identity-1",
),
_recipient(
key="identity-other",
email="other-person-do-not-export@example.test",
account_id="account-other-do-not-export",
identity_id="identity-other-do-not-export",
),
],
excluded=[],
diagnostics=[{"secret": "diagnostics-do-not-export"}],
provider_evidence=[{"secret": "provider-evidence-do-not-export"}],
stale=False,
truncated=False,
created_by_account_id="account-1",
provenance={"secret": "snapshot-provenance-do-not-export"},
created_at=NOW,
updated_at=NOW,
)
other_tenant = DistributionList(
id="list-other-tenant",
tenant_id="tenant-2",
scope_type="user",
scope_id="account-1",
name="Other tenant do not export",
status="active",
current_revision_id="revision-other",
current_revision=1,
resource_revision=1,
)
self.session.add_all(
(distribution_list, revision, entry, snapshot, other_tenant)
)
def test_search_exports_only_exact_subject_projection(self) -> None:
self.assertIsInstance(self.provider, DsarProvider)
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
identity_id="identity-1",
email=" SUBJECT@example.test ",
),
)
self.assertEqual(
{
"distribution_list_subject_scope",
"distribution_list_definition_entry",
"distribution_list_snapshot_recipient",
"distribution_list_actor_attribution",
"distribution_list_revision_actor_attribution",
"distribution_list_snapshot_actor_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("subject@example.test", exported.casefold())
for excluded in (
"other-person-do-not-export@example.test",
"account-other-do-not-export",
"identity-other-do-not-export",
"list-metadata-do-not-export",
"definition-hash-do-not-export",
"parameter-do-not-export",
"constraint-do-not-export",
"fingerprint-do-not-export",
"source-revision-do-not-export",
"source-fingerprint-do-not-export",
"source-label-do-not-export",
"source-metadata-do-not-export",
"configuration-do-not-export",
"idempotency-key-do-not-export",
"request-do-not-export",
"expansion-hash-do-not-export",
"diagnostics-do-not-export",
"provider-evidence-do-not-export",
"snapshot-provenance-do-not-export",
"do-not-export-provenance",
"do-not-export-attributes",
):
self.assertNotIn(excluded, exported)
def test_resource_references_only_narrow_verified_subjects(self) -> None:
self.assertEqual(
(),
self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"dist_lists.snapshot": "snapshot-1"}
),
),
)
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"dist_lists.snapshot": "snapshot-1"},
),
)
self.assertEqual(
["distribution_list_snapshot_recipient"],
[record.resource_type for record in narrowed],
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"dist_lists.email": "other@example.test"},
),
)
self.assertEqual((), conflict)
def test_definition_change_requires_review_and_snapshot_is_retained(self) -> None:
subject = DsarSubjectRef(email="subject@example.test")
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=subject
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
by_type = {action.resource_type: action.kind for action in actions}
self.assertEqual("manual_review", by_type["distribution_list_definition_entry"])
self.assertEqual("retain", by_type["distribution_list_snapshot_recipient"])
def test_manifest_registers_provider_and_documentation(self) -> None:
self.assertIn(DIST_LISTS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
"dist_lists.data-subject-requests",
{topic.id for topic in manifest.documentation},
)
if __name__ == "__main__":
unittest.main()
@@ -2,6 +2,10 @@ from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
from govoplan_dist_lists.backend.manifest import manifest
@@ -42,6 +46,25 @@ class DistributionListsInterfaceDocumentationContractTests(unittest.TestCase):
reference.metadata["consequence_classes"],
)
def test_german_reference_documentation_is_complete(self) -> None:
topics = manifest.documentation
self.assertEqual(6, len(topics))
for topic in topics:
translation = topic.translations.get("de", {})
self.assertTrue(translation.get("title"), topic.id)
self.assertTrue(translation.get("summary"), topic.id)
self.assertTrue(translation.get("body"), topic.id)
if localizable_documentation_metadata_keys(topic):
self.assertEqual("1", topic.structured_translation_version, topic.id)
self.assertIn("de", topic.structured_translations, topic.id)
self.assertEqual((), documentation_structured_translation_issues(topic))
kinds = {topic.metadata.get("kind") for topic in topics}
self.assertIn("workflow", kinds)
self.assertIn("reference", kinds)
workflow = next(topic for topic in topics if topic.metadata.get("kind") == "workflow")
self.assertTrue(workflow.conditions)
if __name__ == "__main__":
unittest.main()
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/dist-lists-webui",
"version": "0.1.17",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,10 +14,11 @@
"./styles/dist-lists.css": "./src/styles/dist-lists.css"
},
"scripts": {
"test:action-layout": "node scripts/test-action-layout.mjs",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.17",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+9
View File
@@ -0,0 +1,9 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const page = readFileSync(new URL("../src/features/distributionLists/DistributionListsPage.tsx", import.meta.url), "utf8");
assert.match(page, /id: "actions",\s*header: "Actions",\s*columnType: "actions",\s*width: 205,\s*sticky: "end"/);
assert.match(page, /id: "actions",\s*header: "Details",\s*columnType: "actions",\s*width: 90,\s*sticky: "end"/);
assert.match(page, /<TableActionGroup actions=\{\[\{\s*id: "explain"/);
assert.match(page, /<DataGridRowActions/);
console.log("Distribution List controls use the shared action-column contract.");
@@ -14,22 +14,34 @@ import {
useRef,
useState
} from "react";
import {
import { DialogSection, FormGrid, ActionToolbar,
ApiError,
Button,
ConfirmDialog,
ContentSection,
DataGrid,
DataGridRowActions,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FilterBar,
FormField,
IconButton,
LoadingFrame,
MetricCard,
MetricGrid,
SearchableSelect,
SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
TableActionGroup,
ToggleSwitch,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
formatDateTime,
hasScope,
useUnsavedChanges,
@@ -401,6 +413,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
{
id: "actions",
header: "Actions",
columnType: "actions",
width: 205,
sticky: "end",
render: (entry, index) => (
@@ -476,15 +489,16 @@ export default function DistributionListsPage({ settings, auth }: Props) {
{
id: "actions",
header: "Details",
columnType: "actions",
width: 90,
sticky: "end",
render: (row) => (
<IconButton
label="Explain this result"
icon={<Eye size={16} />}
variant="ghost"
onClick={() => setExplanationTarget(row)}
/>
<TableActionGroup actions={[{
id: "explain",
label: "Explain this result",
icon: <Eye size={16} aria-hidden="true" />,
onClick: () => setExplanationTarget(row)
}]} />
)
}
], []);
@@ -528,31 +542,34 @@ export default function DistributionListsPage({ settings, auth }: Props) {
}
return (
<main className="dist-lists-page">
<div className="dist-lists-shell">
<aside className="dist-lists-sidebar" aria-label="Distribution lists">
<div className="dist-lists-sidebar-toolbar">
<strong>Distribution Lists</strong>
<span>
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
variant="ghost"
disabled={loading || busy}
disabledReason={loading ? DIST_LISTS_I18N.loading : busy ? DIST_LISTS_I18N.busy : undefined}
onClick={() => void reload(selectedId)}
/>
<IconButton
label="New distribution list"
icon={<Plus size={17} />}
variant="primary"
disabled={!canWrite}
disabledReason={!canWrite ? DIST_LISTS_I18N.writeReason : undefined}
onClick={() => requestDiscard(() => setCreateOpen(true))}
/>
</span>
</div>
<div className="dist-lists-search">
<WorkspaceFrame as="main" height="viewport" surface="plain" className="dist-lists-page" label="Distribution lists workspace">
<WorkspaceActionBar
title="Distribution Lists"
titleHelp={<DocumentationHelpLink reference={DIST_LISTS_DOCUMENTATION} />}
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(selectedId), loading: loading || busy }}
createAction={<IconButton
label="New distribution list"
icon={<Plus size={17} />}
variant="primary"
disabled={!canWrite}
disabledReason={!canWrite ? DIST_LISTS_I18N.writeReason : undefined}
onClick={() => requestDiscard(() => setCreateOpen(true))}
/>}
/>
<WorkspaceLayout
variant="split"
primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Distribution lists"
contentLabel="Distribution list workspace"
contentClassName="dist-lists-workspace"
primary={<>
<FilterBar surface="panel">
<input
type="search"
value={search}
@@ -560,37 +577,34 @@ export default function DistributionListsPage({ settings, auth }: Props) {
placeholder="Search lists"
aria-label="Search distribution lists"
/>
</div>
</FilterBar>
<LoadingFrame loading={loading} className="dist-lists-list-frame">
<div className="dist-lists-list">
<SelectionList variant="navigation" label="Distribution lists">
{visibleItems.map((item) => (
<button
<SelectionListItem
key={item.id}
type="button"
className={item.id === selectedId ? "is-selected" : ""}
selected={item.id === selectedId}
onClick={() => selectItem(item)}
>
<span>
<strong>{item.name}</strong>
<small>{item.revision.entries.length} entries · revision {item.current_revision}</small>
</span>
<SelectionListItemContent title={item.name} description={`${item.revision.entries.length} entries · revision ${item.current_revision}`} />
<StatusBadge status={item.revision.definition_kind} label={item.revision.definition_kind} />
</button>
</SelectionListItem>
))}
{!visibleItems.length ? <div className="dist-lists-empty">No distribution lists</div> : null}
</div>
{!visibleItems.length ? <StatePanel size="compact" description="No distribution lists" /> : null}
</SelectionList>
</LoadingFrame>
</aside>
<section className="dist-lists-workspace">
<div className="dist-lists-workspace-toolbar">
<span className="dist-lists-current-title">
</>}
>
<WorkspaceActionBar
scope="editor-pane"
variant="editor"
state={busy ? "saving" : dirty ? "dirty" : "clean"}
className="dist-lists-workspace-toolbar"
contextActions={<span className="dist-lists-current-title">
<strong>{selected?.name ?? "No distribution list selected"}</strong>
{selected ? <small>Revision {selected.current_revision} · {selected.scope_type} scope</small> : null}
</span>
<span className="dist-lists-toolbar-actions">
<DocumentationHelpLink reference={DIST_LISTS_DOCUMENTATION} />
<SegmentedControl<WorkspaceView>
</span>}
primaryActions={<SegmentedControl<WorkspaceView>
ariaLabel="Distribution-list workspace"
value={view}
onChange={(next) => next === "snapshots" ? void openSnapshots() : setView(next)}
@@ -599,25 +613,29 @@ export default function DistributionListsPage({ settings, auth }: Props) {
{ id: "preview", label: "Preview" },
{ id: "snapshots", label: "Snapshots" }
]}
/>
<Button
variant="primary"
disabled={!selected || !dirty || busy || !canWrite}
disabledReason={busy ? DIST_LISTS_I18N.busy : !canWrite ? DIST_LISTS_I18N.writeReason : !selected ? DIST_LISTS_I18N.noSelection : !dirty ? DIST_LISTS_I18N.noChanges : undefined}
onClick={() => void saveItem()}
>
<Save size={16} /> Save revision
</Button>
<IconButton
/>}
destructiveActions={<IconButton
label="Delete distribution list"
helpContextId="dist_lists.action.delete"
helpModuleId="dist_lists"
icon={<Trash2 size={16} />}
variant="danger"
disabled={!selected || !canWrite}
disabledReason={!canWrite ? DIST_LISTS_I18N.writeReason : !selected ? DIST_LISTS_I18N.noSelection : undefined}
onClick={() => setDeleteOpen(true)}
/>
</span>
</div>
/>}
discardAction={{
label: "Discard changes",
onClick: () => requestDiscard(() => void reload(selectedId)),
disabled: !selected
}}
saveAction={{
label: <><Save size={16} /> Save revision</>,
disabled: !selected || busy || !canWrite,
disabledReason: busy ? DIST_LISTS_I18N.busy : !canWrite ? DIST_LISTS_I18N.writeReason : !selected ? DIST_LISTS_I18N.noSelection : undefined,
onClick: () => void saveItem()
}}
/>
<div className="dist-lists-alerts">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
@@ -626,7 +644,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
<LoadingFrame loading={loading} className="dist-lists-content-frame">
{!selected ? (
<div className="dist-lists-empty">Create or select a distribution list.</div>
<StatePanel size="fill" title="Distribution lists" description="Create or select a distribution list." />
) : view === "definition" ? (
<DefinitionEditor
draft={draft}
@@ -671,12 +689,12 @@ export default function DistributionListsPage({ settings, auth }: Props) {
</section>
{preview ? (
<>
<div className="dist-lists-metrics">
<Metric label="Included" value={preview.recipients.length} />
<Metric label="Excluded" value={preview.excluded.length} />
<Metric label="Providers" value={preview.provider_evidence.length} />
<Metric label="Source state" value={preview.stale ? "Stale" : "Current"} />
</div>
<MetricGrid columns={4} density="compact" minimum="compact">
<MetricCard density="compact" label="Included" value={preview.recipients.length} />
<MetricCard density="compact" label="Excluded" value={preview.excluded.length} />
<MetricCard density="compact" label="Providers" value={preview.provider_evidence.length} />
<MetricCard density="compact" label="Source state" value={preview.stale ? "Stale" : "Current"} />
</MetricGrid>
{preview.diagnostics.map((diagnostic, index) => (
<DismissibleAlert
key={`${diagnostic.code}-${index}`}
@@ -708,18 +726,18 @@ export default function DistributionListsPage({ settings, auth }: Props) {
/>
</>
) : (
<div className="dist-lists-empty">Expand the saved revision to inspect recipients and decisions.</div>
<StatePanel size="default" description="Expand the saved revision to inspect recipients and decisions." />
)}
</div>
) : (
<div className="dist-lists-content">
<div className="dist-lists-section-heading">
<ActionToolbar surface="section-header" className="dist-lists-section-heading">
<span>
<strong>Frozen snapshots</strong>
<small>Immutable recipient evidence held by consumers</small>
</span>
<IconButton label="Refresh snapshots" icon={<RefreshCw size={16} />} variant="ghost" onClick={() => void openSnapshots()} />
</div>
</ActionToolbar>
<DataGrid
id="distribution-list-snapshots"
rows={snapshots}
@@ -731,8 +749,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
</div>
)}
</LoadingFrame>
</section>
</div>
</WorkspaceLayout>
<Dialog
open={createOpen}
@@ -746,7 +763,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
</>
)}
>
<div className="dist-lists-dialog-form">
<DialogSection className="dist-lists-dialog-form">
<FormField label="Name" documentation={DIST_LISTS_FIELD_DOCUMENTATION}><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
<FormField label="Definition type" documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
<select value={createKind} onChange={(event) => setCreateKind(event.target.value as DefinitionKind)}>
@@ -756,7 +773,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
<option value="template">Template (not runnable)</option>
</select>
</FormField>
</div>
</DialogSection>
</Dialog>
<EntryEditorDialog
@@ -808,7 +825,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
onConfirm={() => void removeItem()}
onCancel={() => setDeleteOpen(false)}
/>
</main>
</WorkspaceFrame>
);
}
@@ -856,8 +873,8 @@ function DefinitionEditor({
</FormField>
</section>
<section className="dist-lists-section">
<div className="dist-lists-section-heading">
<ContentSection>
<ActionToolbar surface="section-header" className="dist-lists-section-heading">
<span>
<strong>Parameters</strong>
<small>Typed values supplied by Campaign, Reporting, or Workflow</small>
@@ -869,7 +886,7 @@ function DefinitionEditor({
>
<Plus size={15} /> Add parameter
</Button>
</div>
</ActionToolbar>
<div className="dist-lists-parameter-list">
{draft.parameters.map((parameter, index) => (
<div className="dist-lists-parameter-row" key={`${parameter.key}-${index}`}>
@@ -889,12 +906,12 @@ function DefinitionEditor({
<IconButton label="Remove parameter" icon={<Trash2 size={15} />} variant="ghost" disabled={!canWrite} onClick={() => onChange({ ...draft, parameters: draft.parameters.filter((_, itemIndex) => itemIndex !== index) })} />
</div>
))}
{!draft.parameters.length ? <div className="dist-lists-inline-empty">No parameters</div> : null}
{!draft.parameters.length ? <StatePanel size="inline" description="No parameters" /> : null}
</div>
</section>
</ContentSection>
<section className="dist-lists-section dist-lists-entry-section">
<div className="dist-lists-section-heading">
<ContentSection className="dist-lists-entry-section">
<ActionToolbar surface="section-header" className="dist-lists-section-heading">
<span>
<strong>Audience entries</strong>
<small>Mixed local and provider-owned references; revisions are immutable after save</small>
@@ -902,7 +919,7 @@ function DefinitionEditor({
<Button variant="primary" disabled={!canWrite} onClick={onAddEntry}>
<Plus size={15} /> Add entry
</Button>
</div>
</ActionToolbar>
<DataGrid
id="distribution-list-entries"
rows={draft.entries}
@@ -911,7 +928,7 @@ function DefinitionEditor({
emptyText="No audience entries."
className="dist-lists-grid"
/>
</section>
</ContentSection>
</div>
);
}
@@ -954,7 +971,7 @@ function EntryEditorDialog({
</>
)}
>
<div className="dist-lists-dialog-form">
<DialogSection className="dist-lists-dialog-form">
<FormField label="Source type">
<SegmentedControl<SourceMode>
value={state.sourceMode}
@@ -1001,7 +1018,7 @@ function EntryEditorDialog({
))}
</div>
) : null}
<div className="dist-lists-form-grid">
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Mode" documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
<select value={state.mode} onChange={(event) => onChange({ ...state, mode: event.target.value as EntryMode })}>
<option value="include">Include</option>
@@ -1071,7 +1088,7 @@ function EntryEditorDialog({
</FormField>
</>
) : null}
</div>
</FormGrid>
<fieldset className="dist-lists-channel-fieldset">
<legend>Requested channels</legend>
{(["email", "postal", "internal_mail", "portal"] as DistributionChannel[]).map((channel) => (
@@ -1088,7 +1105,7 @@ function EntryEditorDialog({
/>
))}
</fieldset>
</div>
</DialogSection>
</Dialog>
);
@@ -1144,10 +1161,6 @@ function ParameterValueField({ parameter, value, onChange }: { parameter: Distri
);
}
function Metric({ label, value }: { label: string; value: string | number }) {
return <span><small>{label}</small><strong>{value}</strong></span>;
}
function emptyPayload(): DistributionListPayload {
return {
name: "",
+3 -203
View File
@@ -1,34 +1,3 @@
.dist-lists-page {
position: relative;
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.dist-lists-page *,
.dist-lists-page *::before,
.dist-lists-page *::after {
box-sizing: border-box;
}
.dist-lists-shell {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
}
.dist-lists-sidebar,
.dist-lists-workspace,
.dist-lists-list-frame,
.dist-lists-content-frame,
.dist-lists-content {
@@ -36,32 +5,6 @@
min-height: 0;
}
.dist-lists-sidebar {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: var(--border-line);
background: var(--panel-soft);
}
.dist-lists-sidebar-toolbar,
.dist-lists-workspace-toolbar,
.dist-lists-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.dist-lists-sidebar-toolbar {
min-height: 52px;
padding: 8px 10px 8px 14px;
}
.dist-lists-sidebar-toolbar > span,
.dist-lists-toolbar-actions,
.dist-lists-row-actions,
.dist-lists-preview-actions,
@@ -71,62 +14,11 @@
gap: 7px;
}
.dist-lists-search {
padding: 9px;
border-bottom: var(--border-line);
background: var(--panel);
}
.dist-lists-search input {
width: 100%;
min-height: 34px;
padding: 7px 9px;
}
.dist-lists-list-frame {
flex: 1 1 auto;
overflow: hidden;
}
.dist-lists-list {
height: 100%;
overflow: auto;
padding: 6px;
}
.dist-lists-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 56px;
padding: 8px 9px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
text-align: left;
}
.dist-lists-list > button:hover,
.dist-lists-list > button:focus-visible {
background: var(--primary-soft);
outline: none;
}
.dist-lists-list > button.is-selected {
background: var(--primary-soft-strong);
box-shadow: inset 3px 0 0 var(--accent);
}
.dist-lists-list > button > span:first-child {
min-width: 0;
}
.dist-lists-list strong,
.dist-lists-list small,
.dist-lists-current-title strong,
.dist-lists-current-title small,
.dist-lists-source-cell strong,
@@ -137,14 +29,12 @@
white-space: nowrap;
}
.dist-lists-list strong,
.dist-lists-current-title strong,
.dist-lists-source-cell strong {
color: var(--text-strong);
font-size: 13px;
}
.dist-lists-list small,
.dist-lists-current-title small,
.dist-lists-source-cell small {
margin-top: 3px;
@@ -152,16 +42,8 @@
font-size: 11px;
}
.dist-lists-workspace {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.dist-lists-workspace-toolbar {
min-height: 58px;
padding: 8px 10px 8px 14px;
min-width: 0;
}
.dist-lists-current-title {
@@ -202,17 +84,6 @@
padding: 14px;
}
.dist-lists-empty,
.dist-lists-inline-empty {
display: grid;
place-items: center;
min-height: 110px;
padding: 18px;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.dist-lists-definition-fields {
display: grid;
grid-template-columns: minmax(220px, 1.4fr) minmax(160px, 0.7fr) minmax(160px, 0.7fr);
@@ -240,22 +111,10 @@
resize: vertical;
}
.dist-lists-section {
min-width: 0;
margin-bottom: 14px;
border: var(--border-line);
background: var(--panel);
}
.dist-lists-entry-section {
min-height: 260px;
}
.dist-lists-section-heading {
min-height: 44px;
padding: 7px 10px;
}
.dist-lists-section-heading > span {
min-width: 0;
}
@@ -323,39 +182,6 @@
padding-bottom: 1px;
}
.dist-lists-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.dist-lists-metrics > span {
min-height: 62px;
padding: 9px 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel);
}
.dist-lists-metrics small,
.dist-lists-metrics strong {
display: block;
}
.dist-lists-metrics small {
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
}
.dist-lists-metrics strong {
margin-top: 6px;
color: var(--text-strong);
font-size: 15px;
text-transform: capitalize;
}
.dist-lists-content > .alert {
margin: 8px 0;
}
@@ -391,13 +217,7 @@
.dist-lists-entry-dialog {
width: min(780px, calc(100vw - 32px));
max-width: 780px;
}
.dist-lists-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
max-width: 760px;
}
.dist-lists-provider-notices {
@@ -480,11 +300,7 @@
word-break: break-word;
}
@media (max-width: 1040px) {
.dist-lists-shell {
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
}
@media (max-width: 1100px) {
.dist-lists-workspace-toolbar {
align-items: flex-start;
}
@@ -511,20 +327,6 @@
overflow: auto;
}
.dist-lists-shell {
display: flex;
min-height: calc(100vh - 115px);
overflow: visible;
flex-direction: column;
}
.dist-lists-sidebar {
min-height: 230px;
max-height: 36vh;
border-right: 0;
border-bottom: var(--border-line);
}
.dist-lists-workspace {
min-height: 560px;
}
@@ -543,8 +345,6 @@
.dist-lists-definition-fields,
.dist-lists-preview-inputs,
.dist-lists-form-grid,
.dist-lists-metrics,
.dist-lists-channel-fieldset,
.dist-lists-parameter-row {
grid-template-columns: minmax(0, 1fr);