Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8d3190b4c | ||
|
|
c337f2f1fa | ||
|
|
d1b2a05c91 | ||
|
|
5e85c9fc02 | ||
|
|
b89d9fb434 | ||
|
|
bcc6bdd2d7 | ||
|
|
3f1177abbd | ||
|
|
dfbb92752b | ||
|
|
52a51d29db | ||
|
|
0ecffa2547 | ||
|
|
d9fa18164e | ||
|
|
eeebf0de9a |
@@ -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.
|
||||
|
||||
@@ -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
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-dist-lists"
|
||||
version = "0.1.15"
|
||||
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.15",
|
||||
"govoplan-core>=0.1.45",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.15"
|
||||
__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"]
|
||||
@@ -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.15"
|
||||
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,84 @@ 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="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": "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 +191,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 +219,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 +251,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 +283,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 +313,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 +342,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 +389,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 +463,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 +519,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 +569,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 +615,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",),
|
||||
|
||||
@@ -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(5, 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/dist-lists-webui",
|
||||
"version": "0.1.15",
|
||||
"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.15",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -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,33 @@ 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
|
||||
scope="workspace"
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void reload(selectedId), loading: loading || busy }}
|
||||
contextActions={<strong>Distribution Lists</strong>}
|
||||
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 +576,35 @@ 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>}
|
||||
helpAction={<DocumentationHelpLink reference={DIST_LISTS_DOCUMENTATION} />}
|
||||
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: "",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user