Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8d3190b4c | ||
|
|
c337f2f1fa | ||
|
|
d1b2a05c91 | ||
|
|
5e85c9fc02 | ||
|
|
b89d9fb434 | ||
|
|
bcc6bdd2d7 | ||
|
|
3f1177abbd | ||
|
|
dfbb92752b | ||
|
|
52a51d29db | ||
|
|
0ecffa2547 |
@@ -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)
|
- [Distribution lists architecture](docs/DISTRIBUTION_LISTS_ARCHITECTURE.md)
|
||||||
- [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
|
- [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
|
||||||
- [AdreMa capability assessment](docs/ADREMA_CAPABILITY_ASSESSMENT.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]
|
[project]
|
||||||
name = "govoplan-dist-lists"
|
name = "govoplan-dist-lists"
|
||||||
version = "0.1.17"
|
version = "0.1.21"
|
||||||
description = "GovOPlaN operational distribution lists and governed audience expansion."
|
description = "GovOPlaN operational distribution lists and governed audience expansion."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.17",
|
"govoplan-core>=0.1.45",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__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"]
|
||||||
@@ -30,6 +30,8 @@ from govoplan_core.core.module_guards import (
|
|||||||
persistent_table_uninstall_guard,
|
persistent_table_uninstall_guard,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
@@ -40,6 +42,7 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
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.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_dist_lists.backend.db import models as dist_list_models
|
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_ID = "dist_lists"
|
||||||
MODULE_NAME = "Distribution Lists"
|
MODULE_NAME = "Distribution Lists"
|
||||||
MODULE_VERSION = "0.1.17"
|
MODULE_VERSION = "0.1.21"
|
||||||
|
|
||||||
READ_SCOPE = "dist_lists:list:read"
|
READ_SCOPE = "dist_lists:list:read"
|
||||||
WRITE_SCOPE = "dist_lists:list:write"
|
WRITE_SCOPE = "dist_lists:list:write"
|
||||||
@@ -72,9 +79,21 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission(READ_SCOPE, "View distribution lists", "Read operational distribution lists and expansion previews."),
|
_permission(
|
||||||
_permission(WRITE_SCOPE, "Manage distribution lists", "Create and edit operational distribution lists."),
|
READ_SCOPE,
|
||||||
_permission(ADMIN_SCOPE, "Administer distribution lists", "Configure distribution-list policies and provider integrations."),
|
"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 = (
|
ROLE_TEMPLATES = (
|
||||||
@@ -87,6 +106,84 @@ ROLE_TEMPLATES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
DOCUMENTATION = (
|
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(
|
DocumentationTopic(
|
||||||
id=f"{MODULE_ID}.boundary",
|
id=f"{MODULE_ID}.boundary",
|
||||||
title="Distribution list boundary",
|
title="Distribution list boundary",
|
||||||
@@ -94,13 +191,26 @@ DOCUMENTATION = (
|
|||||||
body=(
|
body=(
|
||||||
"Distribution Lists owns reusable mixed recipient definitions and expansion snapshots. "
|
"Distribution Lists owns reusable mixed recipient definitions and expansion snapshots. "
|
||||||
"Address lists remain in the addresses module and represent address-domain groupings only. "
|
"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",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
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={
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"dist_lists.page",
|
"dist_lists.page",
|
||||||
@@ -109,6 +219,20 @@ DOCUMENTATION = (
|
|||||||
"dist_lists.picker",
|
"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(
|
DocumentationTopic(
|
||||||
id=f"{MODULE_ID}.address-contact-resolution",
|
id=f"{MODULE_ID}.address-contact-resolution",
|
||||||
@@ -127,6 +251,20 @@ DOCUMENTATION = (
|
|||||||
audience=("operator", "module_admin", "product_owner"),
|
audience=("operator", "module_admin", "product_owner"),
|
||||||
related_modules=("addresses", "campaigns", "policy"),
|
related_modules=("addresses", "campaigns", "policy"),
|
||||||
metadata={"seed": True},
|
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(
|
DocumentationTopic(
|
||||||
id=f"{MODULE_ID}.idm-group-resolution",
|
id=f"{MODULE_ID}.idm-group-resolution",
|
||||||
@@ -145,6 +283,19 @@ DOCUMENTATION = (
|
|||||||
audience=("operator", "module_admin", "product_owner"),
|
audience=("operator", "module_admin", "product_owner"),
|
||||||
related_modules=("identity", "idm"),
|
related_modules=("identity", "idm"),
|
||||||
metadata={"seed": True},
|
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(
|
DocumentationTopic(
|
||||||
id=f"{MODULE_ID}.reference.fields-and-consequences",
|
id=f"{MODULE_ID}.reference.fields-and-consequences",
|
||||||
@@ -162,8 +313,17 @@ DOCUMENTATION = (
|
|||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
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={
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"dist_lists.field.definition-kind",
|
"dist_lists.field.definition-kind",
|
||||||
@@ -182,6 +342,37 @@ DOCUMENTATION = (
|
|||||||
"delete_definition": "Retires the reusable definition without rewriting retained frozen snapshots.",
|
"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)
|
return capability(context)
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(_context: ModuleContext) -> DistributionListsDsarProvider:
|
||||||
|
return DistributionListsDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
return {
|
return {
|
||||||
"distribution_lists": (
|
"distribution_lists": (
|
||||||
@@ -268,6 +463,10 @@ manifest = ModuleManifest(
|
|||||||
name=CAPABILITY_DISTRIBUTION_LIST_WRITER,
|
name=CAPABILITY_DISTRIBUTION_LIST_WRITER,
|
||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=DIST_LISTS_DSAR_CAPABILITY,
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -320,6 +519,20 @@ manifest = ModuleManifest(
|
|||||||
order=74,
|
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=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="dist_lists.page",
|
id="dist_lists.page",
|
||||||
@@ -356,6 +569,17 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_DISTRIBUTION_LIST_SOURCE: _capability,
|
CAPABILITY_DISTRIBUTION_LIST_SOURCE: _capability,
|
||||||
CAPABILITY_DISTRIBUTION_LIST_EXPAND: _capability,
|
CAPABILITY_DISTRIBUTION_LIST_EXPAND: _capability,
|
||||||
CAPABILITY_DISTRIBUTION_LIST_WRITER: _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,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
@@ -391,10 +615,21 @@ manifest = ModuleManifest(
|
|||||||
maturity="vertical_slice",
|
maturity="vertical_slice",
|
||||||
documentation_ref="docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",
|
documentation_ref="docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",
|
||||||
test_ref="tests/test_service.py",
|
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"),
|
supported_authority_modes=("native_authoritative", "external_mirror"),
|
||||||
owned_concepts=("distribution list", "distribution-list revision", "frozen expansion"),
|
owned_concepts=(
|
||||||
non_owned_concepts=("contact point", "campaign recipient snapshot", "identity", "dataflow output"),
|
"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",),
|
recovery_docs=("docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",),
|
||||||
security_docs=("docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",),
|
security_docs=("docs/DISTRIBUTION_LISTS_ARCHITECTURE.md",),
|
||||||
operations_docs=("README.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
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
documentation_structured_translation_issues,
|
||||||
|
localizable_documentation_metadata_keys,
|
||||||
|
)
|
||||||
from govoplan_dist_lists.backend.manifest import manifest
|
from govoplan_dist_lists.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +46,25 @@ class DistributionListsInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
reference.metadata["consequence_classes"],
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/dist-lists-webui",
|
"name": "@govoplan/dist-lists-webui",
|
||||||
"version": "0.1.17",
|
"version": "0.1.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,10 +14,11 @@
|
|||||||
"./styles/dist-lists.css": "./src/styles/dist-lists.css"
|
"./styles/dist-lists.css": "./src/styles/dist-lists.css"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:action-layout": "node scripts/test-action-layout.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.17",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -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,
|
useRef,
|
||||||
useState
|
useState
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import { DialogSection, FormGrid, ActionToolbar,
|
||||||
ApiError,
|
ApiError,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
ContentSection,
|
||||||
DataGrid,
|
DataGrid,
|
||||||
DataGridRowActions,
|
DataGridRowActions,
|
||||||
Dialog,
|
Dialog,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
FilterBar,
|
||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
|
MetricCard,
|
||||||
|
MetricGrid,
|
||||||
SearchableSelect,
|
SearchableSelect,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
WorkspaceLayout,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
hasScope,
|
hasScope,
|
||||||
useUnsavedChanges,
|
useUnsavedChanges,
|
||||||
@@ -401,6 +413,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
|
columnType: "actions",
|
||||||
width: 205,
|
width: 205,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (entry, index) => (
|
render: (entry, index) => (
|
||||||
@@ -476,15 +489,16 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Details",
|
header: "Details",
|
||||||
|
columnType: "actions",
|
||||||
width: 90,
|
width: 90,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (row) => (
|
render: (row) => (
|
||||||
<IconButton
|
<TableActionGroup actions={[{
|
||||||
label="Explain this result"
|
id: "explain",
|
||||||
icon={<Eye size={16} />}
|
label: "Explain this result",
|
||||||
variant="ghost"
|
icon: <Eye size={16} aria-hidden="true" />,
|
||||||
onClick={() => setExplanationTarget(row)}
|
onClick: () => setExplanationTarget(row)
|
||||||
/>
|
}]} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
], []);
|
], []);
|
||||||
@@ -528,31 +542,33 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="dist-lists-page">
|
<WorkspaceFrame as="main" height="viewport" surface="plain" className="dist-lists-page" label="Distribution lists workspace">
|
||||||
<div className="dist-lists-shell">
|
<WorkspaceActionBar
|
||||||
<aside className="dist-lists-sidebar" aria-label="Distribution lists">
|
scope="workspace"
|
||||||
<div className="dist-lists-sidebar-toolbar">
|
variant="collection"
|
||||||
<strong>Distribution Lists</strong>
|
refreshable
|
||||||
<span>
|
reloadAction={{ onReload: () => void reload(selectedId), loading: loading || busy }}
|
||||||
<IconButton
|
contextActions={<strong>Distribution Lists</strong>}
|
||||||
label="Refresh"
|
createAction={<IconButton
|
||||||
icon={<RefreshCw size={16} />}
|
label="New distribution list"
|
||||||
variant="ghost"
|
icon={<Plus size={17} />}
|
||||||
disabled={loading || busy}
|
variant="primary"
|
||||||
disabledReason={loading ? DIST_LISTS_I18N.loading : busy ? DIST_LISTS_I18N.busy : undefined}
|
disabled={!canWrite}
|
||||||
onClick={() => void reload(selectedId)}
|
disabledReason={!canWrite ? DIST_LISTS_I18N.writeReason : undefined}
|
||||||
/>
|
onClick={() => requestDiscard(() => setCreateOpen(true))}
|
||||||
<IconButton
|
/>}
|
||||||
label="New distribution list"
|
/>
|
||||||
icon={<Plus size={17} />}
|
<WorkspaceLayout
|
||||||
variant="primary"
|
variant="split"
|
||||||
disabled={!canWrite}
|
primarySize="compact"
|
||||||
disabledReason={!canWrite ? DIST_LISTS_I18N.writeReason : undefined}
|
surface="contained"
|
||||||
onClick={() => requestDiscard(() => setCreateOpen(true))}
|
primaryScrollable={false}
|
||||||
/>
|
contentScrollable={false}
|
||||||
</span>
|
primaryLabel="Distribution lists"
|
||||||
</div>
|
contentLabel="Distribution list workspace"
|
||||||
<div className="dist-lists-search">
|
contentClassName="dist-lists-workspace"
|
||||||
|
primary={<>
|
||||||
|
<FilterBar surface="panel">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -560,37 +576,35 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
|||||||
placeholder="Search lists"
|
placeholder="Search lists"
|
||||||
aria-label="Search distribution lists"
|
aria-label="Search distribution lists"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FilterBar>
|
||||||
<LoadingFrame loading={loading} className="dist-lists-list-frame">
|
<LoadingFrame loading={loading} className="dist-lists-list-frame">
|
||||||
<div className="dist-lists-list">
|
<SelectionList variant="navigation" label="Distribution lists">
|
||||||
{visibleItems.map((item) => (
|
{visibleItems.map((item) => (
|
||||||
<button
|
<SelectionListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
selected={item.id === selectedId}
|
||||||
className={item.id === selectedId ? "is-selected" : ""}
|
|
||||||
onClick={() => selectItem(item)}
|
onClick={() => selectItem(item)}
|
||||||
>
|
>
|
||||||
<span>
|
<SelectionListItemContent title={item.name} description={`${item.revision.entries.length} entries · revision ${item.current_revision}`} />
|
||||||
<strong>{item.name}</strong>
|
|
||||||
<small>{item.revision.entries.length} entries · revision {item.current_revision}</small>
|
|
||||||
</span>
|
|
||||||
<StatusBadge status={item.revision.definition_kind} label={item.revision.definition_kind} />
|
<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}
|
{!visibleItems.length ? <StatePanel size="compact" description="No distribution lists" /> : null}
|
||||||
</div>
|
</SelectionList>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</aside>
|
</>}
|
||||||
|
>
|
||||||
<section className="dist-lists-workspace">
|
<WorkspaceActionBar
|
||||||
<div className="dist-lists-workspace-toolbar">
|
scope="editor-pane"
|
||||||
<span className="dist-lists-current-title">
|
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>
|
<strong>{selected?.name ?? "No distribution list selected"}</strong>
|
||||||
{selected ? <small>Revision {selected.current_revision} · {selected.scope_type} scope</small> : null}
|
{selected ? <small>Revision {selected.current_revision} · {selected.scope_type} scope</small> : null}
|
||||||
</span>
|
</span>}
|
||||||
<span className="dist-lists-toolbar-actions">
|
helpAction={<DocumentationHelpLink reference={DIST_LISTS_DOCUMENTATION} />}
|
||||||
<DocumentationHelpLink reference={DIST_LISTS_DOCUMENTATION} />
|
primaryActions={<SegmentedControl<WorkspaceView>
|
||||||
<SegmentedControl<WorkspaceView>
|
|
||||||
ariaLabel="Distribution-list workspace"
|
ariaLabel="Distribution-list workspace"
|
||||||
value={view}
|
value={view}
|
||||||
onChange={(next) => next === "snapshots" ? void openSnapshots() : setView(next)}
|
onChange={(next) => next === "snapshots" ? void openSnapshots() : setView(next)}
|
||||||
@@ -599,25 +613,29 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
|||||||
{ id: "preview", label: "Preview" },
|
{ id: "preview", label: "Preview" },
|
||||||
{ id: "snapshots", label: "Snapshots" }
|
{ id: "snapshots", label: "Snapshots" }
|
||||||
]}
|
]}
|
||||||
/>
|
/>}
|
||||||
<Button
|
destructiveActions={<IconButton
|
||||||
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
|
|
||||||
label="Delete distribution list"
|
label="Delete distribution list"
|
||||||
|
helpContextId="dist_lists.action.delete"
|
||||||
|
helpModuleId="dist_lists"
|
||||||
icon={<Trash2 size={16} />}
|
icon={<Trash2 size={16} />}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={!selected || !canWrite}
|
disabled={!selected || !canWrite}
|
||||||
disabledReason={!canWrite ? DIST_LISTS_I18N.writeReason : !selected ? DIST_LISTS_I18N.noSelection : undefined}
|
disabledReason={!canWrite ? DIST_LISTS_I18N.writeReason : !selected ? DIST_LISTS_I18N.noSelection : undefined}
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
/>
|
/>}
|
||||||
</span>
|
discardAction={{
|
||||||
</div>
|
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">
|
<div className="dist-lists-alerts">
|
||||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
{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">
|
<LoadingFrame loading={loading} className="dist-lists-content-frame">
|
||||||
{!selected ? (
|
{!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" ? (
|
) : view === "definition" ? (
|
||||||
<DefinitionEditor
|
<DefinitionEditor
|
||||||
draft={draft}
|
draft={draft}
|
||||||
@@ -671,12 +689,12 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
{preview ? (
|
{preview ? (
|
||||||
<>
|
<>
|
||||||
<div className="dist-lists-metrics">
|
<MetricGrid columns={4} density="compact" minimum="compact">
|
||||||
<Metric label="Included" value={preview.recipients.length} />
|
<MetricCard density="compact" label="Included" value={preview.recipients.length} />
|
||||||
<Metric label="Excluded" value={preview.excluded.length} />
|
<MetricCard density="compact" label="Excluded" value={preview.excluded.length} />
|
||||||
<Metric label="Providers" value={preview.provider_evidence.length} />
|
<MetricCard density="compact" label="Providers" value={preview.provider_evidence.length} />
|
||||||
<Metric label="Source state" value={preview.stale ? "Stale" : "Current"} />
|
<MetricCard density="compact" label="Source state" value={preview.stale ? "Stale" : "Current"} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
{preview.diagnostics.map((diagnostic, index) => (
|
{preview.diagnostics.map((diagnostic, index) => (
|
||||||
<DismissibleAlert
|
<DismissibleAlert
|
||||||
key={`${diagnostic.code}-${index}`}
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="dist-lists-content">
|
<div className="dist-lists-content">
|
||||||
<div className="dist-lists-section-heading">
|
<ActionToolbar surface="section-header" className="dist-lists-section-heading">
|
||||||
<span>
|
<span>
|
||||||
<strong>Frozen snapshots</strong>
|
<strong>Frozen snapshots</strong>
|
||||||
<small>Immutable recipient evidence held by consumers</small>
|
<small>Immutable recipient evidence held by consumers</small>
|
||||||
</span>
|
</span>
|
||||||
<IconButton label="Refresh snapshots" icon={<RefreshCw size={16} />} variant="ghost" onClick={() => void openSnapshots()} />
|
<IconButton label="Refresh snapshots" icon={<RefreshCw size={16} />} variant="ghost" onClick={() => void openSnapshots()} />
|
||||||
</div>
|
</ActionToolbar>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id="distribution-list-snapshots"
|
id="distribution-list-snapshots"
|
||||||
rows={snapshots}
|
rows={snapshots}
|
||||||
@@ -731,8 +749,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</section>
|
</WorkspaceLayout>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={createOpen}
|
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="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}>
|
<FormField label="Definition type" documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||||
<select value={createKind} onChange={(event) => setCreateKind(event.target.value as DefinitionKind)}>
|
<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>
|
<option value="template">Template (not runnable)</option>
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</DialogSection>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<EntryEditorDialog
|
<EntryEditorDialog
|
||||||
@@ -808,7 +825,7 @@ export default function DistributionListsPage({ settings, auth }: Props) {
|
|||||||
onConfirm={() => void removeItem()}
|
onConfirm={() => void removeItem()}
|
||||||
onCancel={() => setDeleteOpen(false)}
|
onCancel={() => setDeleteOpen(false)}
|
||||||
/>
|
/>
|
||||||
</main>
|
</WorkspaceFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,8 +873,8 @@ function DefinitionEditor({
|
|||||||
</FormField>
|
</FormField>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="dist-lists-section">
|
<ContentSection>
|
||||||
<div className="dist-lists-section-heading">
|
<ActionToolbar surface="section-header" className="dist-lists-section-heading">
|
||||||
<span>
|
<span>
|
||||||
<strong>Parameters</strong>
|
<strong>Parameters</strong>
|
||||||
<small>Typed values supplied by Campaign, Reporting, or Workflow</small>
|
<small>Typed values supplied by Campaign, Reporting, or Workflow</small>
|
||||||
@@ -869,7 +886,7 @@ function DefinitionEditor({
|
|||||||
>
|
>
|
||||||
<Plus size={15} /> Add parameter
|
<Plus size={15} /> Add parameter
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
<div className="dist-lists-parameter-list">
|
<div className="dist-lists-parameter-list">
|
||||||
{draft.parameters.map((parameter, index) => (
|
{draft.parameters.map((parameter, index) => (
|
||||||
<div className="dist-lists-parameter-row" key={`${parameter.key}-${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) })} />
|
<IconButton label="Remove parameter" icon={<Trash2 size={15} />} variant="ghost" disabled={!canWrite} onClick={() => onChange({ ...draft, parameters: draft.parameters.filter((_, itemIndex) => itemIndex !== index) })} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</ContentSection>
|
||||||
|
|
||||||
<section className="dist-lists-section dist-lists-entry-section">
|
<ContentSection className="dist-lists-entry-section">
|
||||||
<div className="dist-lists-section-heading">
|
<ActionToolbar surface="section-header" className="dist-lists-section-heading">
|
||||||
<span>
|
<span>
|
||||||
<strong>Audience entries</strong>
|
<strong>Audience entries</strong>
|
||||||
<small>Mixed local and provider-owned references; revisions are immutable after save</small>
|
<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}>
|
<Button variant="primary" disabled={!canWrite} onClick={onAddEntry}>
|
||||||
<Plus size={15} /> Add entry
|
<Plus size={15} /> Add entry
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id="distribution-list-entries"
|
id="distribution-list-entries"
|
||||||
rows={draft.entries}
|
rows={draft.entries}
|
||||||
@@ -911,7 +928,7 @@ function DefinitionEditor({
|
|||||||
emptyText="No audience entries."
|
emptyText="No audience entries."
|
||||||
className="dist-lists-grid"
|
className="dist-lists-grid"
|
||||||
/>
|
/>
|
||||||
</section>
|
</ContentSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -954,7 +971,7 @@ function EntryEditorDialog({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="dist-lists-dialog-form">
|
<DialogSection className="dist-lists-dialog-form">
|
||||||
<FormField label="Source type">
|
<FormField label="Source type">
|
||||||
<SegmentedControl<SourceMode>
|
<SegmentedControl<SourceMode>
|
||||||
value={state.sourceMode}
|
value={state.sourceMode}
|
||||||
@@ -1001,7 +1018,7 @@ function EntryEditorDialog({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="dist-lists-form-grid">
|
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||||
<FormField label="Mode" documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
<FormField label="Mode" documentation={DIST_LISTS_FIELD_DOCUMENTATION}>
|
||||||
<select value={state.mode} onChange={(event) => onChange({ ...state, mode: event.target.value as EntryMode })}>
|
<select value={state.mode} onChange={(event) => onChange({ ...state, mode: event.target.value as EntryMode })}>
|
||||||
<option value="include">Include</option>
|
<option value="include">Include</option>
|
||||||
@@ -1071,7 +1088,7 @@ function EntryEditorDialog({
|
|||||||
</FormField>
|
</FormField>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</FormGrid>
|
||||||
<fieldset className="dist-lists-channel-fieldset">
|
<fieldset className="dist-lists-channel-fieldset">
|
||||||
<legend>Requested channels</legend>
|
<legend>Requested channels</legend>
|
||||||
{(["email", "postal", "internal_mail", "portal"] as DistributionChannel[]).map((channel) => (
|
{(["email", "postal", "internal_mail", "portal"] as DistributionChannel[]).map((channel) => (
|
||||||
@@ -1088,7 +1105,7 @@ function EntryEditorDialog({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</DialogSection>
|
||||||
</Dialog>
|
</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 {
|
function emptyPayload(): DistributionListPayload {
|
||||||
return {
|
return {
|
||||||
name: "",
|
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-list-frame,
|
||||||
.dist-lists-content-frame,
|
.dist-lists-content-frame,
|
||||||
.dist-lists-content {
|
.dist-lists-content {
|
||||||
@@ -36,32 +5,6 @@
|
|||||||
min-height: 0;
|
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-toolbar-actions,
|
||||||
.dist-lists-row-actions,
|
.dist-lists-row-actions,
|
||||||
.dist-lists-preview-actions,
|
.dist-lists-preview-actions,
|
||||||
@@ -71,62 +14,11 @@
|
|||||||
gap: 7px;
|
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 {
|
.dist-lists-list-frame {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: hidden;
|
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 strong,
|
||||||
.dist-lists-current-title small,
|
.dist-lists-current-title small,
|
||||||
.dist-lists-source-cell strong,
|
.dist-lists-source-cell strong,
|
||||||
@@ -137,14 +29,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dist-lists-list strong,
|
|
||||||
.dist-lists-current-title strong,
|
.dist-lists-current-title strong,
|
||||||
.dist-lists-source-cell strong {
|
.dist-lists-source-cell strong {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dist-lists-list small,
|
|
||||||
.dist-lists-current-title small,
|
.dist-lists-current-title small,
|
||||||
.dist-lists-source-cell small {
|
.dist-lists-source-cell small {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
@@ -152,16 +42,8 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dist-lists-workspace {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dist-lists-workspace-toolbar {
|
.dist-lists-workspace-toolbar {
|
||||||
min-height: 58px;
|
min-width: 0;
|
||||||
padding: 8px 10px 8px 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dist-lists-current-title {
|
.dist-lists-current-title {
|
||||||
@@ -202,17 +84,6 @@
|
|||||||
padding: 14px;
|
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 {
|
.dist-lists-definition-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 1.4fr) minmax(160px, 0.7fr) minmax(160px, 0.7fr);
|
grid-template-columns: minmax(220px, 1.4fr) minmax(160px, 0.7fr) minmax(160px, 0.7fr);
|
||||||
@@ -240,22 +111,10 @@
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dist-lists-section {
|
|
||||||
min-width: 0;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
border: var(--border-line);
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dist-lists-entry-section {
|
.dist-lists-entry-section {
|
||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dist-lists-section-heading {
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 7px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dist-lists-section-heading > span {
|
.dist-lists-section-heading > span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -323,39 +182,6 @@
|
|||||||
padding-bottom: 1px;
|
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 {
|
.dist-lists-content > .alert {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
@@ -391,13 +217,7 @@
|
|||||||
|
|
||||||
.dist-lists-entry-dialog {
|
.dist-lists-entry-dialog {
|
||||||
width: min(780px, calc(100vw - 32px));
|
width: min(780px, calc(100vw - 32px));
|
||||||
max-width: 780px;
|
max-width: 760px;
|
||||||
}
|
|
||||||
|
|
||||||
.dist-lists-form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dist-lists-provider-notices {
|
.dist-lists-provider-notices {
|
||||||
@@ -480,11 +300,7 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1040px) {
|
@media (max-width: 1100px) {
|
||||||
.dist-lists-shell {
|
|
||||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dist-lists-workspace-toolbar {
|
.dist-lists-workspace-toolbar {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
@@ -511,20 +327,6 @@
|
|||||||
overflow: auto;
|
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 {
|
.dist-lists-workspace {
|
||||||
min-height: 560px;
|
min-height: 560px;
|
||||||
}
|
}
|
||||||
@@ -543,8 +345,6 @@
|
|||||||
|
|
||||||
.dist-lists-definition-fields,
|
.dist-lists-definition-fields,
|
||||||
.dist-lists-preview-inputs,
|
.dist-lists-preview-inputs,
|
||||||
.dist-lists-form-grid,
|
|
||||||
.dist-lists-metrics,
|
|
||||||
.dist-lists-channel-fieldset,
|
.dist-lists-channel-fieldset,
|
||||||
.dist-lists-parameter-row {
|
.dist-lists-parameter-row {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user