10 Commits
Author SHA1 Message Date
zemion ec240ed219 feat: require explicit dashboard read permission
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:42 +02:00
zemion 1a48724f16 docs(dashboard): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 19:31:21 +02:00
zemion f8305f11f2 feat(dashboard): add governed DSAR coverage 2026-08-21 03:50:08 +02:00
zemion b3f612bfa0 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion c8a7a155aa Adopt semantic dashboard action states 2026-08-19 14:26:26 +02:00
zemion d0df0b146f style: use shared WebUI foundation tokens 2026-08-18 21:32:50 +02:00
zemion 2d70f6521b Adopt shared WebUI layout primitives 2026-08-18 11:30:39 +02:00
zemion dbf46da346 refactor: adopt shared page layout 2026-08-18 01:03:32 +02:00
zemion ede16c5439 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:44 +02:00
zemion d80e4db3ec Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:33:58 +02:00
16 changed files with 1076 additions and 103 deletions
+4
View File
@@ -9,6 +9,10 @@ Configurable dashboard module for GovOPlaN.
The module owns the `/dashboard` route when installed. Core keeps only a minimal
fallback home for installations where this module is absent.
Opening the route and using its personal-layout API requires the grantable
`dashboard:dashboard:read` tenant permission. The `dashboard_user` role template
contains this permission; widgets retain their own provider-specific access checks.
Modules contribute widgets through the `dashboard.widgets` WebUI capability.
Personal widget layouts are stored by the backend for each tenant, account, and
active View. Configure mode supports adding, removing, ordering, sizing, and
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/dashboard-webui",
"version": "0.1.16",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"README.md"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.16",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+3 -4
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-dashboard"
version = "0.1.16"
version = "0.1.20"
description = "GovOPlaN configurable dashboard module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.16",
"govoplan-access>=0.1.16",
"govoplan-core>=0.1.18",
"govoplan-access>=0.1.18",
]
[tool.setuptools.packages.find]
@@ -22,4 +22,3 @@ govoplan_dashboard = ["py.typed"]
[project.entry-points."govoplan.modules"]
dashboard = "govoplan_dashboard.backend.manifest:get_manifest"
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.16"
__version__ = "0.1.20"
@@ -0,0 +1,335 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_dashboard.backend.db.models import DashboardLayout
DASHBOARD_DSAR_CAPABILITY = dsar_capability_name("dashboard")
_MAX_RECORDS = 500
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
account_id: str
layout_id: str | None
view_id: str | None
class DashboardDsarProvider:
provider_id = "dashboard"
module_id = "dashboard"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
query = db.query(DashboardLayout).filter(
DashboardLayout.tenant_id == tenant_id,
DashboardLayout.account_id == selectors.account_id,
)
if selectors.layout_id:
query = query.filter(DashboardLayout.id == selectors.layout_id)
if selectors.view_id:
query = query.filter(DashboardLayout.view_id == selectors.view_id)
rows = (
query.order_by(DashboardLayout.context_key, DashboardLayout.id)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Dashboard DSAR result limit exceeded; narrow the selectors."
)
return tuple(_record(row) for row in rows)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Dashboard DSAR requires one corroborated account.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
layout = (
db.query(DashboardLayout)
.filter(
DashboardLayout.tenant_id == tenant_id,
DashboardLayout.id == record.resource_id,
)
.one_or_none()
)
if (
layout is None
or layout.account_id != selectors.account_id
or (selectors.layout_id and layout.id != selectors.layout_id)
or (selectors.view_id and layout.view_id != selectors.view_id)
):
actions.append(_manual_action(record))
continue
actions.append(
DsarErasureActionRef(
action_id=f"dashboard:delete:dashboard_layout:{layout.id}:r{layout.revision}",
provider_id=self.provider_id,
module_id=self.module_id,
kind="delete",
resource_type="dashboard_layout",
resource_id=layout.id,
title="Delete personal Dashboard layout",
rationale=(
"The layout is an account-owned display preference. Its "
"deletion does not change widgets or their source data."
),
executable=True,
irreversible=True,
metadata={
"account_id": layout.account_id,
"revision": layout.revision,
},
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Dashboard DSAR requires one corroborated account.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if not action.executable or action.kind != "delete":
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary="The Dashboard action is not an executable deletion.",
)
)
continue
if action.resource_type != "dashboard_layout":
raise ValueError("Unsupported executable Dashboard DSAR action.")
layout = (
db.query(DashboardLayout)
.filter(
DashboardLayout.tenant_id == tenant_id,
DashboardLayout.id == action.resource_id,
)
.one_or_none()
)
if layout is None:
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="unchanged",
summary="The personal Dashboard layout was already absent.",
evidence={"request_id": request_id},
)
)
continue
expected_account = str(action.metadata.get("account_id") or "")
expected_revision = action.metadata.get("revision")
if (
layout.account_id != selectors.account_id
or expected_account != selectors.account_id
or (selectors.layout_id and layout.id != selectors.layout_id)
or (selectors.view_id and layout.view_id != selectors.view_id)
):
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The layout is not owned by the corroborated subject "
"account and selector context."
),
)
)
continue
if expected_revision != layout.revision:
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The layout changed after the erasure plan; create a "
"new plan before deleting it."
),
)
)
continue
layout_id = layout.id
db.delete(layout)
db.flush()
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="executed",
summary=(
"Deleted the personal Dashboard layout without changing "
"widget or domain data."
),
evidence={
"request_id": request_id,
"layout_id": layout_id,
},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
account_id = _coalesce(
subject.account_id,
references.get("dashboard.account"),
references.get("access.account"),
)
layout_id = _coalesce(
references.get("dashboard.layout"),
references.get("dashboard.layout_id"),
)
view_id = _coalesce(
references.get("dashboard.view"),
references.get("views.view"),
)
if _CONFLICT in (account_id, layout_id, view_id):
return None
normalized_account = _optional_string(account_id)
if normalized_account is None:
return None
return _SubjectSelectors(
account_id=normalized_account,
layout_id=_optional_string(layout_id),
view_id=_optional_string(view_id),
)
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _record(layout: DashboardLayout) -> DsarRecordRef:
placements = []
for value in layout.placements[:100]:
if not isinstance(value, dict):
continue
placements.append(
{
"instance_id": str(value.get("instance_id") or "")[:120],
"widget_id": str(value.get("widget_id") or "")[:160],
"size": str(value.get("size") or "")[:20],
"column_start": value.get("column_start"),
}
)
return DsarRecordRef(
provider_id="dashboard",
module_id="dashboard",
resource_type="dashboard_layout",
resource_id=layout.id,
category="personal_interface_preference",
title="Personal Dashboard layout",
data={
"view_id": layout.view_id,
"layout_version": layout.layout_version,
"revision": layout.revision,
"placements": placements,
"known_widget_ids": [
str(value)[:160] for value in layout.known_widget_ids[:500]
],
},
observed_at=_aware(layout.updated_at),
)
def _manual_action(record: DsarRecordRef) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=f"dashboard:manual_review:{record.resource_type}:{record.resource_id}",
provider_id="dashboard",
module_id="dashboard",
kind="manual_review",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Review {record.title}",
rationale=(
"The selected layout is absent or no longer belongs to the exact "
"tenant, account, and narrowing selectors."
),
executable=False,
)
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("Dashboard DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "dashboard" or record.module_id != "dashboard":
raise ValueError("Dashboard DSAR cannot plan a foreign provider record.")
if record.resource_type != "dashboard_layout" or not record.resource_id:
raise ValueError("Dashboard DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "dashboard" or action.module_id != "dashboard":
raise ValueError("Dashboard DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("dashboard:"):
raise ValueError("Dashboard DSAR action identity is invalid.")
__all__ = ["DASHBOARD_DSAR_CAPABILITY", "DashboardDsarProvider"]
+232 -5
View File
@@ -8,17 +8,42 @@ from govoplan_core.core.module_guards import (
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_dashboard.backend.db.models import DashboardLayout
from govoplan_dashboard.backend.dsar_provider import (
DASHBOARD_DSAR_CAPABILITY,
DashboardDsarProvider,
)
from govoplan_dashboard.backend.permissions import READ_SCOPE
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Dashboard",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _dashboard_router(_context):
@@ -27,6 +52,10 @@ def _dashboard_router(_context):
return router
def _dsar_provider(_context: ModuleContext) -> DashboardDsarProvider:
return DashboardDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"dashboard_layouts": (
@@ -40,11 +69,40 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
manifest = ModuleManifest(
id="dashboard",
name="Dashboard",
version="0.1.16",
version="0.1.20",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"),
provides_interfaces=(
ModuleInterfaceProvider(name=DASHBOARD_DSAR_CAPABILITY, version="0.1.0"),
),
route_factory=_dashboard_router,
capability_factories={DASHBOARD_DSAR_CAPABILITY: _dsar_provider},
capability_documentation={
DASHBOARD_DSAR_CAPABILITY: CapabilityDocumentation(
label="Dashboard data-subject request provider",
summary=(
"Exports and deletes exact account-owned Dashboard layout "
"preferences without traversing widget data."
),
contract_version="0.1.0",
),
},
tenant_summary_providers=(_tenant_summary,),
permissions=(
_permission(
READ_SCOPE,
"View dashboard",
"Open the Dashboard and read, save, reset, or remove the current user's personal layout.",
),
),
role_templates=(
RoleTemplate(
slug="dashboard_user",
name="Dashboard user",
description="Open and personalize the Dashboard.",
permissions=(READ_SCOPE,),
),
),
migration_spec=MigrationSpec(
module_id="dashboard",
metadata=Base.metadata,
@@ -65,12 +123,35 @@ manifest = ModuleManifest(
label="Dashboard",
),
),
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
nav_items=(
NavItem(
path="/dashboard",
label="Dashboard",
icon="dashboard",
required_all=(READ_SCOPE,),
order=10,
),
),
frontend=FrontendModule(
module_id="dashboard",
package_name="@govoplan/dashboard-webui",
routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),),
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
routes=(
FrontendRoute(
path="/dashboard",
component="DashboardPage",
required_all=(READ_SCOPE,),
order=10,
),
),
nav_items=(
NavItem(
path="/dashboard",
label="Dashboard",
icon="dashboard",
required_all=(READ_SCOPE,),
order=10,
),
),
view_surfaces=(
ViewSurface(
id="dashboard.page",
@@ -122,6 +203,71 @@ manifest = ModuleManifest(
),
),
documentation=(
DocumentationTopic(
id="dashboard.data-subject-requests",
title="Dashboard data-subject requests",
summary=(
"Export or delete personal Dashboard layouts without changing "
"the data displayed by their widgets."
),
body=(
"Dashboard contributes layouts only when the request contains one "
"exact, corroborated account identifier in the active tenant. A "
"layout or View reference can narrow that result. The access package "
"contains layout version, revision, View, widget identities, sizes, "
"and positions. It deliberately omits arbitrary widget configuration "
"values and never follows a widget into its owning module. Erasure "
"deletes the selected account-owned layout after verifying its owner "
"and revision; a repeated execution is unchanged. The operation does "
"not delete widgets, reports, files, tasks, or any other domain data."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "auditor"),
related_modules=("core", "views"),
order=19,
metadata={
"kind": "reference",
"help_contexts": [
"dashboard.page",
"dashboard.action.reset",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_layout": (
"Returns minimized layout structure, never widget-domain data."
),
"delete_layout": (
"Irreversibly removes the selected personal layout only."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen zum Dashboard",
"summary": (
"Persönliche Dashboard-Layouts ausgeben oder löschen, ohne die von Widgets dargestellten Daten zu verändern."
),
"body": (
"Dashboard trägt Layouts nur bei, wenn die Anfrage im aktiven Mandanten genau eine bestätigte "
"Kontokennung enthält. Ein Layout- oder View-Verweis kann das Ergebnis einschränken. Das Auskunftspaket "
"enthält Layoutversion, Revision, View, Widgetkennungen, Größen und Positionen. Beliebige Werte der "
"Widgetkonfiguration werden bewusst ausgelassen; einem Widget wird nie in sein Eigentümermodul gefolgt. "
"Die Löschung entfernt das ausgewählte kontoeigene Layout nach Prüfung von Eigentümer und Revision; eine "
"Wiederholung bleibt unverändert. Widgets, Berichte, Dateien, Tasks und andere Fachdaten werden nicht gelöscht."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"export_layout": "Gibt eine minimierte Layoutstruktur zurück, niemals Daten der Widget-Fachdomäne.",
"delete_layout": "Entfernt ausschließlich das ausgewählte persönliche Layout unwiderruflich.",
}
}
},
),
DocumentationTopic(
id="dashboard.configurable-home",
title="Configurable user dashboard",
@@ -138,7 +284,9 @@ manifest = ModuleManifest(
audience=("user", "tenant_admin", "operator"),
related_modules=("core", "ops"),
order=20,
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
metadata={
"kind": "workflow",
"help_contexts": [
"dashboard.page",
"dashboard.summary",
@@ -147,6 +295,48 @@ manifest = ModuleManifest(
"dashboard.state.browser-fallback",
"dashboard.state.view-specific",
],
"prerequisites": [
"Your tenant has enabled the Dashboard module.",
"Your role grants the dashboard read permission.",
],
"steps": [
"Open Dashboard from the main navigation.",
"Add, configure, resize, or remove widgets for the active View.",
"Save the layout after reviewing the unsaved-change indicator.",
],
"outcome": "The personal layout is available for the active tenant, account, and View.",
"verification": "Reload the Dashboard and confirm that the saved widget arrangement returns.",
},
translations={
"de": {
"title": "Konfigurierbares Benutzer-Dashboard",
"summary": (
"Das Dashboard-Modul führt die konfigurierbare Startoberfläche; Fachmodule stellen Widgets über die enge Fähigkeit dashboard.widgets bereit."
),
"body": (
"Core stellt nur dann eine minimale Ersatzstartseite bereit, wenn das Dashboard-Modul fehlt. Dashboard-Widgets "
"müssen über Core-Verträge beigetragen werden und dürfen Komponenten anderer Module nicht direkt importieren. "
"Persönliche Layouts werden je Mandant, Konto und aktivem View gespeichert. Die Anzahl aktiver Oberflächenmodule "
"umfasst nur mandantenaktivierte Module, deren WebUI in der aktuellen Browsersitzung geladen wurde. Sie unterscheidet "
"sich bewusst vom Paketkatalog der Administration, der auch entdeckte reine Backend- und Headless-Manifeste enthält."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"prerequisites": [
"Ihr Mandant hat das Dashboard-Modul aktiviert.",
"Ihre Rolle gewährt die Dashboard-Leseberechtigung.",
],
"steps": [
"Öffnen Sie das Dashboard über die Hauptnavigation.",
"Fügen Sie Widgets für den aktiven View hinzu, konfigurieren oder skalieren Sie sie oder entfernen Sie Widgets.",
"Speichern Sie das Layout, nachdem Sie die Anzeige ungespeicherter Änderungen geprüft haben.",
],
"outcome": "Das persönliche Layout ist für den aktiven Mandanten, das Konto und den View verfügbar.",
"verification": "Laden Sie das Dashboard neu und prüfen Sie, ob die gespeicherte Widget-Anordnung wiederhergestellt wird.",
}
},
),
DocumentationTopic(
@@ -156,7 +346,10 @@ manifest = ModuleManifest(
body=(
"A Dashboard layout belongs to the active tenant, account, and focused View. "
"Configuring changes only a local draft until Save layout is selected; Cancel or "
"Discard restores the last saved arrangement. Widget removal removes only the placement, "
"Discard restores the last saved arrangement. The page action bar always reports whether "
"the draft is saved, unsaved, or currently saving; Save and Cancel remain visible in stable "
"positions and are disabled with an explanation when no draft change exists. Leaving while "
"dirty invokes the shared save-or-discard guard. Widget removal removes only the placement, "
"not the module data represented by the widget. Reset restores the defaults announced by "
"currently active modules and remains reversible until save. A widget is offered only when "
"its module, focused View surface, and permission contract are available. Widgets never grant "
@@ -170,6 +363,7 @@ manifest = ModuleManifest(
related_modules=("core", "views", "access", "ops"),
order=21,
metadata={
"kind": "reference",
"help_contexts": [
"dashboard.action.configure",
"dashboard.action.save",
@@ -185,6 +379,39 @@ manifest = ModuleManifest(
"configure_widget": "Changes presentation and query preferences for one widget placement.",
},
},
translations={
"de": {
"title": "Dashboard-Layouts und Widgetfolgen",
"summary": (
"Platzierung, Größe, Konfiguration, Verfügbarkeit und Rückfallverhalten von Widgets je Benutzer, Mandant und View."
),
"body": (
"Ein Dashboard-Layout gehört zum aktiven Mandanten, Konto und fokussierten View. Konfigurationen ändern "
"zunächst nur einen lokalen Entwurf; erst Layout speichern übernimmt sie. Abbrechen oder Verwerfen stellt "
"die zuletzt gespeicherte Anordnung wieder her. Die Seitenaktionsleiste zeigt stets, ob der Entwurf gespeichert, "
"ungespeichert oder in Speicherung ist; Speichern und Abbrechen bleiben an stabilen Positionen und sind mit "
"Erklärung deaktiviert, wenn keine Änderung vorliegt. Beim Verlassen eines geänderten Entwurfs greift die gemeinsame "
"Speichern-oder-Verwerfen-Sicherung. Das Entfernen eines Widgets entfernt nur seine Platzierung, nicht die dargestellten "
"Moduldaten. Zurücksetzen übernimmt die von aktuell aktiven Modulen angekündigten Standardwerte und bleibt bis zum "
"Speichern umkehrbar. Ein Widget wird nur angeboten, wenn Modul, fokussierte View-Oberfläche und Berechtigungsvertrag "
"verfügbar sind. Widgets gewähren keinen Zugriff; Anbieter müssen jede Datenanfrage autorisieren. Ist das Serverlayout "
"nicht erreichbar, kann ein Browser- oder Modulstandard angezeigt werden, bleibt aber bis zum Speichern als Rückfall "
"gekennzeichnet. Gleichzeitige Speicherungen verwenden die Layoutrevision und weisen veraltete Änderungen zurück, "
"statt sie zu überschreiben."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"save_layout": "Speichert das vollständige Layout im Revisionskontext von aktivem Mandant, Konto und View.",
"reset_layout": "Ersetzt den Entwurf durch aktuelle Modulstandardwerte und bleibt bis zum Speichern umkehrbar.",
"remove_widget": "Entfernt nur die Platzierung aus dem Entwurf; Anbieterdaten bleiben unverändert.",
"configure_widget": "Ändert Darstellungs- und Abfrageeinstellungen einer Widgetplatzierung.",
}
}
},
),
),
architecture=declared_module_architecture(
@@ -0,0 +1,6 @@
"""Dashboard permission scope constants."""
READ_SCOPE = "dashboard:dashboard:read"
__all__ = ["READ_SCOPE"]
+13 -1
View File
@@ -4,9 +4,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_dashboard.backend.db.models import DashboardLayout
from govoplan_dashboard.backend.permissions import READ_SCOPE
from govoplan_dashboard.backend.schemas import (
DashboardLayoutResponse,
DashboardLayoutUpdateRequest,
@@ -24,6 +25,14 @@ from govoplan_dashboard.backend.service import (
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
def _require_read(principal: ApiPrincipal) -> None:
if not has_scope(principal, READ_SCOPE):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required scope: {READ_SCOPE}",
)
def _response(
layout: DashboardLayout | None,
*,
@@ -51,6 +60,7 @@ def api_get_dashboard_layout(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DashboardLayoutResponse:
_require_read(principal)
return _response(
get_dashboard_layout(
session,
@@ -69,6 +79,7 @@ def api_save_dashboard_layout(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DashboardLayoutResponse:
_require_read(principal)
try:
layout = save_dashboard_layout(
session,
@@ -111,6 +122,7 @@ def api_delete_dashboard_layout(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> None:
_require_read(principal)
delete_dashboard_layout(
session,
tenant_id=principal.tenant_id,
+28 -1
View File
@@ -12,6 +12,7 @@ from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.session import get_session
from govoplan_dashboard.backend.db.models import DashboardLayout
from govoplan_dashboard.backend.manifest import READ_SCOPE
from govoplan_dashboard.backend.router import router
@@ -19,13 +20,14 @@ def principal(
*,
tenant_id: str = "tenant-1",
account_id: str = "account-1",
scopes: frozenset[str] = frozenset({READ_SCOPE}),
) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=account_id,
membership_id=f"membership:{tenant_id}:{account_id}",
tenant_id=tenant_id,
scopes=frozenset(),
scopes=scopes,
group_ids=frozenset(),
),
account=object(),
@@ -67,6 +69,31 @@ class DashboardLayoutApiTests(unittest.TestCase):
self.client.close()
self.engine.dispose()
def test_layout_endpoints_require_dashboard_read_permission(self) -> None:
self.active_principal = principal(scopes=frozenset())
for method in ("get", "put", "delete"):
response = getattr(self.client, method)(
"/api/v1/dashboard/layout",
**(
{
"json": {
"expected_revision": 0,
"layout_version": 1,
"placements": [],
"known_widget_ids": [],
}
}
if method == "put"
else {}
),
)
self.assertEqual(403, response.status_code)
self.assertEqual(
f"Missing required scope: {READ_SCOPE}",
response.json()["detail"],
)
def test_layouts_are_isolated_by_account_and_view(self) -> None:
initial = self.client.get("/api/v1/dashboard/layout")
self.assertEqual(200, initial.status_code)
+356
View File
@@ -0,0 +1,356 @@
from __future__ import annotations
import json
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_dashboard.backend.db.models import DashboardLayout
from govoplan_dashboard.backend.dsar_provider import (
DASHBOARD_DSAR_CAPABILITY,
DashboardDsarProvider,
)
from govoplan_dashboard.backend.manifest import manifest
class _Registry:
def __init__(
self,
provider: DashboardDsarProvider,
*,
active: bool = True,
) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (DASHBOARD_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "dashboard"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("dashboard",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "dashboard"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != DASHBOARD_DSAR_CAPABILITY:
raise KeyError(name)
class DashboardDsarProviderTests(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 = DashboardDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
def layout(
layout_id: str,
*,
tenant_id: str = "tenant-1",
account_id: str = "account-1",
view_id: str | None = None,
) -> DashboardLayout:
return DashboardLayout(
id=layout_id,
tenant_id=tenant_id,
account_id=account_id,
context_key=f"view:{view_id}" if view_id else "full",
view_id=view_id,
layout_version=1,
revision=3,
placements=[
{
"instance_id": f"instance-{layout_id}",
"widget_id": "reporting.metric",
"size": "medium",
"column_start": 2,
"configuration": {
"subjectFilter": f"private-{layout_id}-do-not-export"
},
}
],
known_widget_ids=["reporting.metric"],
)
self.session.add_all(
(
layout("layout-full"),
layout("layout-view", view_id="view-1"),
layout("layout-other-account", account_id="account-other"),
layout("layout-other-tenant", tenant_id="tenant-2"),
)
)
def test_search_is_tenant_and_account_scoped_and_minimized(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-1"),
)
self.assertEqual(
["layout-full", "layout-view"],
[record.resource_id for record in records],
)
exported = json.dumps([record.to_dict() for record in records])
self.assertNotIn("private-layout-full-do-not-export", exported)
self.assertNotIn("private-layout-view-do-not-export", exported)
self.assertNotIn("layout-other-account", exported)
self.assertNotIn("layout-other-tenant", exported)
self.assertIn("reporting.metric", exported)
def test_references_narrow_and_conflicts_fail_closed(self) -> None:
by_layout = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"dashboard.layout": "layout-view"},
),
)
by_view = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"dashboard.view": "view-1"},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"dashboard.account": "account-other"},
),
)
without_account = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"dashboard.layout": "layout-view"}
),
)
self.assertEqual(["layout-view"], [item.resource_id for item in by_layout])
self.assertEqual(["layout-view"], [item.resource_id for item in by_view])
self.assertEqual((), conflict)
self.assertEqual((), without_account)
def test_erasure_is_owner_scoped_revision_safe_and_idempotent(self) -> None:
subject = DsarSubjectRef(
account_id="account-1",
external_references={"dashboard.layout": "layout-view"},
)
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,
)
self.assertEqual(1, len(actions))
self.assertEqual("delete", actions[0].kind)
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
second = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
self.assertEqual("executed", first[0].status)
self.assertEqual("unchanged", second[0].status)
self.assertIsNone(self.session.get(DashboardLayout, "layout-view"))
self.assertIsNotNone(self.session.get(DashboardLayout, "layout-full"))
self.assertIsNotNone(
self.session.get(DashboardLayout, "layout-other-account")
)
self.assertIsNotNone(
self.session.get(DashboardLayout, "layout-other-tenant")
)
def test_changed_or_foreign_resources_are_blocked(self) -> None:
subject = DsarSubjectRef(account_id="account-1")
record = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"dashboard.layout": "layout-full"},
),
)[0]
action = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(record,),
)[0]
self.session.get(DashboardLayout, "layout-full").revision += 1
changed = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(action,),
request_id="dsar-1",
)
self.assertEqual("blocked", changed[0].status)
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="views",
module_id="views",
resource_type="dashboard_layout",
resource_id="layout-full",
category="preference",
title="Foreign layout",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="views:delete:dashboard_layout:layout-full",
provider_id="views",
module_id="views",
kind="delete",
resource_type="dashboard_layout",
resource_id="layout-full",
title="Delete layout",
rationale="Foreign action",
executable=True,
),
),
request_id="dsar-1",
)
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-DASHBOARD-1",
request_kind="access",
subject=DsarSubjectRef(account_id="account-1"),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=1,
)
self.assertEqual(
[DASHBOARD_DSAR_CAPABILITY], row.coverage["provider_capabilities"]
)
self.assertEqual(2, row.search_result["record_count"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-DASHBOARD-2",
request_kind="access",
subject=DsarSubjectRef(account_id="account-1"),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual([], inactive.coverage["provider_capabilities"])
self.assertEqual(
[DASHBOARD_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
def test_manifest_registers_and_documents_the_capability(self) -> None:
self.assertIn(DASHBOARD_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(DASHBOARD_DSAR_CAPABILITY, manifest.capability_documentation)
self.assertIn(
DASHBOARD_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "dashboard.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
+42 -1
View File
@@ -3,13 +3,32 @@ from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_dashboard.backend.manifest import get_manifest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
from govoplan_dashboard.backend.manifest import READ_SCOPE, get_manifest
REPO_ROOT = Path(__file__).resolve().parents[1]
class DashboardInterfaceDocumentationContractTests(unittest.TestCase):
def test_dashboard_surface_requires_explicit_read_permission(self) -> None:
manifest = get_manifest()
self.assertEqual({READ_SCOPE}, {item.scope for item in manifest.permissions})
self.assertIn(
READ_SCOPE,
next(
item.permissions
for item in manifest.role_templates
if item.slug == "dashboard_user"
),
)
self.assertEqual((READ_SCOPE,), manifest.nav_items[0].required_all)
self.assertEqual((READ_SCOPE,), manifest.frontend.routes[0].required_all) # type: ignore[union-attr]
self.assertEqual((READ_SCOPE,), manifest.frontend.nav_items[0].required_all) # type: ignore[union-attr]
def test_surface_hierarchy_remains_declared(self) -> None:
frontend = get_manifest().frontend
self.assertIsNotNone(frontend)
@@ -35,10 +54,32 @@ class DashboardInterfaceDocumentationContractTests(unittest.TestCase):
reference = topics["dashboard.reference.layout-and-widgets"]
self.assertIn("dashboard.state.browser-fallback", home.metadata["help_contexts"])
self.assertEqual("workflow", home.metadata["kind"])
self.assertEqual((READ_SCOPE,), home.conditions[0].required_scopes)
self.assertIn("dashboard.field.widget-size", reference.metadata["help_contexts"])
self.assertIn("save_layout", reference.metadata["consequence_classes"])
self.assertIn("remove_widget", reference.metadata["consequence_classes"])
def test_german_reference_documentation_is_complete(self) -> None:
topics = get_manifest().documentation
self.assertEqual(3, 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))
reference = next(
topic
for topic in topics
if topic.id == "dashboard.reference.layout-and-widgets"
)
self.assertEqual("reference", reference.metadata["kind"])
def test_webui_guards_layout_and_nested_widget_drafts(self) -> None:
page = (REPO_ROOT / "webui/src/features/dashboard/DashboardPage.tsx").read_text(
encoding="utf-8"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/dashboard-webui",
"version": "0.1.16",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -17,7 +17,7 @@
"test:dashboard-layout": "node --experimental-strip-types tests/dashboard-layout.test.ts"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.16",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+34 -58
View File
@@ -1,3 +1,4 @@
import { MetricGrid } from "@govoplan/core-webui";
import {
useEffect,
useMemo,
@@ -12,12 +13,11 @@ import {
} from "lucide-react";
import {
Button,
DismissibleAlert,
DocumentationHelpLink,
LoadingFrame,
MetricCard,
PageScrollViewport,
PageTitle,
PageActionBar,
PageLayout,
dashboardWidgetsForModules,
hasAnyScope,
hasScope,
@@ -443,24 +443,32 @@ export default function DashboardPage({
}
return (
<PageScrollViewport className="dashboard-page">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle>
<p>Personal workspace assembled from installed module widgets.</p>
</div>
<div className="button-row compact-actions">
<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />
{!configuring && (
<>
<Button
onClick={() => setRefreshKey((value) => value + 1)}
disabled={loading}
disabledReason={loading ? DASHBOARD_I18N.loading : undefined}
>
<RefreshCw size={16} /> Refresh
</Button>
<PageLayout
archetype={configuring ? "editor" : "overview"}
className="dashboard-page"
title="i18n:govoplan-dashboard.dashboard.3f8b4df2"
description="Personal workspace assembled from installed module widgets."
error={error}
success={error ? "" : notice}
actions={configuring ? (
<PageActionBar
variant="editor"
state={saving ? "saving" : dirty ? "dirty" : "clean"}
helpAction={<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />}
discardAction={{ label: <><X size={16} /> Cancel</>, onClick: discardChanges }}
saveAction={{ label: <><Save size={16} /> Save layout</>, onClick: () => void persistLayout() }}
/>
) : (
<PageActionBar
variant="overview"
refreshable
reloadAction={{
onReload: () => setRefreshKey((value) => value + 1),
loading,
disabledReason: loading ? DASHBOARD_I18N.loading : undefined
}}
helpAction={<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />}
primaryActions={(
<Button
onClick={beginConfiguration}
disabled={loading}
@@ -468,42 +476,12 @@ export default function DashboardPage({
>
<SlidersHorizontal size={16} /> Configure
</Button>
</>
)}
{configuring && (
<>
<Button
onClick={discardChanges}
disabled={saving}
disabledReason={saving ? DASHBOARD_I18N.saving : undefined}
/>
)}
>
<X size={16} /> Cancel
</Button>
<Button
variant="primary"
onClick={() => void persistLayout()}
disabled={saving || !dirty}
disabledReason={saving ? DASHBOARD_I18N.saving : !dirty ? DASHBOARD_I18N.noChanges : undefined}
>
<Save size={16} /> {saving ? "Saving..." : "Save layout"}
</Button>
</>
)}
</div>
</div>
{error && (
<DismissibleAlert tone="danger" resetKey={error} floating>
{error}
</DismissibleAlert>
)}
{notice && !error && (
<DismissibleAlert tone="success" resetKey={notice} floating>
{notice}
</DismissibleAlert>
)}
<div className="metric-grid dashboard-summary-grid">
<MetricGrid className="dashboard-summary-metrics">
<MetricCard
label="Active interface modules"
value={modules.length}
@@ -528,7 +506,7 @@ export default function DashboardPage({
tone="neutral"
detail={layoutSourceLabel(layoutSource)}
/>
</div>
</MetricGrid>
<LoadingFrame loading={loading} label="Loading Dashboard layout">
{configuring ? (
@@ -597,8 +575,6 @@ export default function DashboardPage({
/>
)}
</LoadingFrame>
</div>
<WidgetConfigurationDialog
open={Boolean(editingPlacement && editingWidget)}
widget={editingWidget}
@@ -609,7 +585,7 @@ export default function DashboardPage({
setEditingInstanceId(null);
}}
/>
</PageScrollViewport>
</PageLayout>
);
}
@@ -1,3 +1,4 @@
import { DescriptionList } from "@govoplan/core-webui";
import {
StatusBadge,
type DashboardWidgetConfiguration,
@@ -26,7 +27,7 @@ export default function InstalledModulesWidget({
return (
<>
<dl className="detail-list dashboard-compact-list">
<DescriptionList variant="inline" termWidth="compact">
{visibleModules.map((module) =>
<div key={module.id}>
<dt><StatusBadge status="success" label={module.id} /></dt>
@@ -36,7 +37,7 @@ export default function InstalledModulesWidget({
</dd>
</div>
)}
</dl>
</DescriptionList>
{remaining > 0 && <p className="muted dashboard-widget-overflow">+{remaining} more active interface modules</p>}
</>);
}
+3 -1
View File
@@ -5,6 +5,7 @@ import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/dashboard.css";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const dashboardRead = ["dashboard:dashboard:read"];
const dashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
@@ -117,10 +118,11 @@ export const dashboardModule: PlatformWebModule = {
order: 10
}
],
navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", order: 10 }],
navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", anyOf: dashboardRead, order: 10 }],
routes: [
{
path: "/dashboard",
anyOf: dashboardRead,
order: 10,
surfaceId: "dashboard.page",
render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth })
+3 -16
View File
@@ -2,7 +2,7 @@
margin-top: 18px;
}
.dashboard-summary-grid .metric-detail {
.dashboard-summary-metrics .metric-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -47,15 +47,6 @@
grid-column-end: span 4;
}
.dashboard-compact-list.detail-list div {
grid-template-columns: minmax(92px, auto) minmax(0, 1fr);
}
.dashboard-widget-metrics.metric-grid {
grid-template-columns: repeat(3, minmax(120px, 1fr));
margin-bottom: 0;
}
.dashboard-config-workspace {
display: flex;
min-width: 0;
@@ -106,7 +97,7 @@
margin: 0;
padding: 4px 8px;
border: var(--border-line);
border-radius: 5px;
border-radius: var(--radius-xs);
background: var(--surface);
}
@@ -317,7 +308,7 @@
margin: 12px 0 0;
}
@media (max-width: 1120px) {
@media (max-width: 1100px) {
.dashboard-widget-grid {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
@@ -338,10 +329,6 @@
@media (max-width: 900px) {
.dashboard-widget-grid,
.dashboard-widget-metrics.metric-grid {
grid-template-columns: 1fr;
}
.dashboard-widget-library {
grid-template-columns: minmax(0, 1fr) 36px;
}