12 Commits
Author SHA1 Message Date
zemion 89755e1924 fix(packaging): expose immutable WebUI Git package for v0.1.21
Module Package Release / publish-packages (push) Successful in 12s
2026-09-08 02:06:11 +02:00
zemion f2e42cecfe Release govoplan-reporting v0.1.21: preserve calculated measure bindings 2026-09-08 01:32:52 +02:00
zemion d7e7890291 fix(webui): bind report publication to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:44 +02:00
zemion f85e0fe9ad docs(reporting): add complete German workflow guidance
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 20:56:43 +02:00
zemion eb6742a393 feat(reporting): add governed DSAR coverage 2026-08-21 03:11:22 +02:00
zemion e932a3d0f7 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion 0a606184b1 feat: align reporting with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion 63ee29e222 Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion e13d95c843 Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion 79fa45d14a Adopt shared WebUI layout primitives 2026-08-18 10:42:53 +02:00
zemion c2c400a43a Verify exact-run CSV publication 2026-08-06 12:42:20 +02:00
zemion f930a6cff0 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:52 +02:00
18 changed files with 2145 additions and 184 deletions
+35
View File
@@ -37,6 +37,23 @@ The canonical global route is `/reports`. `/reporting` remains a
Reporting-owned compatibility route for saved links. Domain modules may keep
their own operational report routes, but do not register `/reports`.
## Data-subject requests
Reporting publishes `privacy.dsar.reporting` for private saved views,
short-lived drill contexts, subject access grants, minimized staff
attribution, and explicitly identified retained executions, exports, and
publications. DSAR output never copies report rows, parameters, filters,
delivery targets, source payloads, diagnostics, provenance bodies, or hashes.
The source module remains responsible for locating and correcting subject
facts; arbitrary aggregate report output is not searched as if Reporting were
the authoritative owner.
Private views and drill contexts can be removed, grants revoked, and exact
retained result or publication detail minimized idempotently. Shared views,
definitions, schedules, quality/import evidence, and institutional attribution
require authorized review or retention. Source facts must be corrected before
rerunning or republishing a report.
See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary
decision. The behavior-level comparison with the supplied SuperX module set is
recorded in
@@ -50,3 +67,21 @@ are covered by [docs/USER_GUIDE.md](docs/USER_GUIDE.md) and
The Reporting route, workspace, state, consequence, and accessibility mapping
is recorded in
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
## Git-source WebUI package
The repository root exposes `@govoplan/reporting-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/reporting-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.
+4 -1
View File
@@ -31,7 +31,10 @@ does not publish a result.
A report configured against an exact published Dataflow run reads that run's
immutable Datasource materialization. It does not rerun the flow with current
inputs. The evidence identifies the Dataflow run and materialization, and access
to both is checked again when the report runs.
to both is checked again when the report runs. CSV export therefore provides an
Excel-readable publication of the exact authorized Dataflow result, with
spreadsheet formula markers escaped and the run lineage retained on the
Reporting execution.
The **Effective access** explanation states when dimensions, measures, source
rows, or actions were removed by Policy. A result with no hidden elements says
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/reporting-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/reporting.css": "./webui/src/styles/reporting.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"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-reporting"
version = "0.1.17"
version = "0.1.21"
description = "GovOPlaN governed reporting and semantic BI module."
readme = "README.md"
requires-python = ">=3.12"
license = { text = "AGPL-3.0-or-later" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.17",
"govoplan-access>=0.1.17",
"govoplan-core>=0.1.45",
"govoplan-access>=0.1.18",
]
[tool.setuptools.packages.find]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN Reporting module."""
__version__ = "0.1.17"
__version__ = "0.1.21"
@@ -0,0 +1,951 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_reporting.backend.db.models import (
ReportingDefinitionGrant,
ReportingDefinitionIdentity,
ReportingDefinitionRevision,
ReportingDrillContext,
ReportingExecution,
ReportingImportAssessment,
ReportingProviderExecution,
ReportingProviderExport,
ReportingPublication,
ReportingQualityResult,
ReportingSavedView,
ReportingSchedule,
)
REPORTING_DSAR_CAPABILITY = dsar_capability_name("reporting")
_MAX_RECORDS = 5_000
_CONFLICT = object()
_DIRECT_ALIASES = {
"definition_id": ("reporting.definition",),
"revision_id": (
"reporting.definition_revision",
"reporting.revision",
),
"execution_id": ("reporting.execution",),
"provider_execution_id": ("reporting.provider_execution",),
"provider_export_id": ("reporting.provider_export",),
"grant_id": ("reporting.definition_grant", "reporting.grant"),
"saved_view_id": ("reporting.saved_view",),
"schedule_id": ("reporting.schedule",),
"publication_id": ("reporting.publication",),
"drill_context_id": ("reporting.drill_context",),
"quality_result_id": ("reporting.quality_result",),
"import_assessment_id": ("reporting.import_assessment",),
}
_RESOURCE_MODELS = {
"reporting_definition": ReportingDefinitionIdentity,
"reporting_definition_revision": ReportingDefinitionRevision,
"reporting_execution": ReportingExecution,
"reporting_provider_execution": ReportingProviderExecution,
"reporting_provider_export": ReportingProviderExport,
"reporting_definition_grant": ReportingDefinitionGrant,
"reporting_saved_view": ReportingSavedView,
"reporting_schedule": ReportingSchedule,
"reporting_publication": ReportingPublication,
"reporting_drill_context": ReportingDrillContext,
"reporting_quality_result": ReportingQualityResult,
"reporting_import_assessment": ReportingImportAssessment,
}
_EXECUTABLE_KINDS = {
"reporting_execution": "anonymize",
"reporting_provider_execution": "anonymize",
"reporting_provider_export": "anonymize",
"reporting_publication": "anonymize",
"reporting_definition_grant": "revoke",
"reporting_saved_view": "delete",
"reporting_drill_context": "delete",
}
@dataclass(frozen=True, slots=True)
class _Selectors:
account_id: str | None
identity_id: str | None
membership_id: str | None
direct: dict[str, str]
@property
def actor_ids(self) -> tuple[str, ...]:
return tuple(
value
for value in (self.account_id, self.identity_id, self.membership_id)
if value
)
@dataclass(frozen=True, slots=True)
class _Match:
resource_type: str
row: Any
category: str
class ReportingDsarProvider:
provider_id = "reporting"
module_id = "reporting"
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.actor_ids or selectors.direct):
return ()
direct = _direct_matches(db, tenant_id=tenant_id, selectors=selectors)
if direct is None:
return ()
if direct:
if selectors.actor_ids and not all(
_correlates(
db,
tenant_id=tenant_id,
match=match,
actor_ids=selectors.actor_ids,
)
for match in direct
):
return ()
matches = direct
else:
matches = _canonical_matches(
db,
tenant_id=tenant_id,
actor_ids=selectors.actor_ids,
)
records: list[DsarRecordRef] = []
seen: set[tuple[str, str]] = set()
for match in matches:
key = (match.resource_type, str(match.row.id))
if key in seen:
continue
if len(records) >= _MAX_RECORDS:
raise ValueError(
"Reporting DSAR result limit exceeded; narrow the selectors."
)
seen.add(key)
records.append(_record(match))
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _selectors(subject) is None:
raise ValueError("Reporting DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
kind = _planned_kind(record)
executable = kind in {"delete", "anonymize", "revoke"}
actions.append(
DsarErasureActionRef(
action_id=(
f"reporting:{kind}:{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind=kind,
resource_type=record.resource_type,
resource_id=record.resource_id,
title=(
f"Minimize {record.title}"
if kind == "anonymize"
else f"{kind.replace('_', ' ').title()} {record.title}"
),
rationale=_rationale(record, kind=kind),
executable=executable,
irreversible=kind in {"delete", "anonymize"},
metadata={"record_category": record.category},
)
)
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 = _selectors(subject)
if selectors is None:
raise ValueError("Reporting DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if not action.executable:
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Review institutional reporting evidence, shared "
"configuration, retention, and third-party impact."
),
evidence={"request_id": request_id},
)
)
continue
model = _RESOURCE_MODELS[action.resource_type]
row = (
db.query(model)
.filter(model.tenant_id == tenant_id, model.id == action.resource_id)
.with_for_update()
.one_or_none()
)
if row is None:
status = "unchanged"
summary = "Reporting row was already absent or minimized."
else:
match = _Match(action.resource_type, row, "execution")
if not (
_directly_targets(selectors, match)
or _correlates(
db,
tenant_id=tenant_id,
match=match,
actor_ids=selectors.actor_ids,
)
):
raise ValueError(
"Reporting DSAR action is not corroborated by the subject."
)
status, summary = _execute_action(
db,
row=row,
resource_type=action.resource_type,
kind=action.kind,
)
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status=status,
summary=summary,
evidence={"request_id": request_id},
)
)
return tuple(results)
def _direct_matches(
session: Session,
*,
tenant_id: str,
selectors: _Selectors,
) -> list[_Match] | None:
matches: list[_Match] = []
for selector, value in selectors.direct.items():
current: list[_Match]
if selector == "definition_id":
identities = _rows(
session,
ReportingDefinitionIdentity,
tenant_id=tenant_id,
field="definition_id",
value=value,
)
revisions = _rows(
session,
ReportingDefinitionRevision,
tenant_id=tenant_id,
field="definition_id",
value=value,
)
current = [
*(
_Match("reporting_definition", row, "reporting_configuration")
for row in identities
),
*(
_Match(
"reporting_definition_revision",
row,
"reporting_configuration",
)
for row in revisions
),
]
else:
model, field, resource_type = {
"revision_id": (
ReportingDefinitionRevision,
"id",
"reporting_definition_revision",
),
"execution_id": (
ReportingExecution,
"execution_id",
"reporting_execution",
),
"provider_execution_id": (
ReportingProviderExecution,
"execution_id",
"reporting_provider_execution",
),
"provider_export_id": (
ReportingProviderExport,
"export_id",
"reporting_provider_export",
),
"grant_id": (
ReportingDefinitionGrant,
"id",
"reporting_definition_grant",
),
"saved_view_id": (
ReportingSavedView,
"view_id",
"reporting_saved_view",
),
"schedule_id": (
ReportingSchedule,
"schedule_id",
"reporting_schedule",
),
"publication_id": (
ReportingPublication,
"publication_id",
"reporting_publication",
),
"drill_context_id": (
ReportingDrillContext,
"drill_context_id",
"reporting_drill_context",
),
"quality_result_id": (
ReportingQualityResult,
"result_id",
"reporting_quality_result",
),
"import_assessment_id": (
ReportingImportAssessment,
"assessment_id",
"reporting_import_assessment",
),
}[selector]
current = [
_Match(resource_type, row, _direct_category(resource_type, row))
for row in _rows(
session,
model,
tenant_id=tenant_id,
field=field,
value=value,
)
]
if not current:
return None
matches.extend(current)
if len(matches) > _MAX_RECORDS:
raise ValueError(
"Reporting DSAR result limit exceeded; narrow the selectors."
)
return matches
def _canonical_matches(
session: Session,
*,
tenant_id: str,
actor_ids: tuple[str, ...],
) -> list[_Match]:
if not actor_ids:
return []
specs = (
(
ReportingDefinitionIdentity,
"created_by",
"reporting_definition",
"reporting_operator_attribution",
),
(
ReportingDefinitionRevision,
"changed_by",
"reporting_definition_revision",
"reporting_operator_attribution",
),
(
ReportingExecution,
"actor_id",
"reporting_execution",
"reporting_operator_attribution",
),
(
ReportingProviderExecution,
"actor_id",
"reporting_provider_execution",
"reporting_operator_attribution",
),
(
ReportingProviderExport,
"actor_id",
"reporting_provider_export",
"reporting_operator_attribution",
),
(
ReportingSavedView,
"owner_id",
"reporting_saved_view",
"subject_owned_reporting_view",
),
(
ReportingSchedule,
"created_by",
"reporting_schedule",
"reporting_operator_attribution",
),
(
ReportingDrillContext,
"actor_id",
"reporting_drill_context",
"subject_owned_drill_context",
),
(
ReportingQualityResult,
"actor_id",
"reporting_quality_result",
"reporting_operator_attribution",
),
(
ReportingImportAssessment,
"assessed_by",
"reporting_import_assessment",
"reporting_operator_attribution",
),
(
ReportingDefinitionGrant,
"subject_id",
"reporting_definition_grant",
"subject_access_grant",
),
)
matches: list[_Match] = []
for model, field, resource_type, category in specs:
rows = (
session.query(model)
.filter(
model.tenant_id == tenant_id,
getattr(model, field).in_(actor_ids),
)
.order_by(model.id)
.limit(_MAX_RECORDS + 1)
.all()
)
matches.extend(
_Match(
resource_type,
row,
_direct_category(resource_type, row)
if resource_type == "reporting_saved_view"
else category,
)
for row in rows
)
if len(matches) > _MAX_RECORDS:
raise ValueError(
"Reporting DSAR result limit exceeded; narrow the selectors."
)
return matches
def _rows(
session: Session,
model: Any,
*,
tenant_id: str,
field: str,
value: str,
) -> list[Any]:
return (
session.query(model)
.filter(model.tenant_id == tenant_id, getattr(model, field) == value)
.order_by(model.id)
.limit(_MAX_RECORDS + 1)
.all()
)
def _correlates(
session: Session,
*,
tenant_id: str,
match: _Match,
actor_ids: tuple[str, ...],
) -> bool:
if not actor_ids:
return False
row = match.row
field = {
"reporting_definition": "created_by",
"reporting_definition_revision": "changed_by",
"reporting_execution": "actor_id",
"reporting_provider_execution": "actor_id",
"reporting_provider_export": "actor_id",
"reporting_definition_grant": "subject_id",
"reporting_saved_view": "owner_id",
"reporting_schedule": "created_by",
"reporting_drill_context": "actor_id",
"reporting_quality_result": "actor_id",
"reporting_import_assessment": "assessed_by",
}.get(match.resource_type)
if field and str(getattr(row, field, "") or "") in actor_ids:
return True
if match.resource_type == "reporting_definition_revision":
identity = session.get(ReportingDefinitionIdentity, row.identity_id)
return bool(
identity
and identity.tenant_id == tenant_id
and identity.created_by in actor_ids
)
if match.resource_type == "reporting_provider_export":
execution = session.get(
ReportingProviderExecution,
row.provider_execution_id,
)
return bool(
execution
and execution.tenant_id == tenant_id
and execution.actor_id in actor_ids
)
if match.resource_type == "reporting_publication":
execution = (
session.query(ReportingExecution)
.filter(
ReportingExecution.tenant_id == tenant_id,
ReportingExecution.execution_id == row.execution_id,
)
.one_or_none()
)
return bool(execution and execution.actor_id in actor_ids)
return False
def _directly_targets(selectors: _Selectors, match: _Match) -> bool:
row = match.row
selector, field = {
"reporting_definition": ("definition_id", "definition_id"),
"reporting_definition_revision": ("revision_id", "id"),
"reporting_execution": ("execution_id", "execution_id"),
"reporting_provider_execution": (
"provider_execution_id",
"execution_id",
),
"reporting_provider_export": ("provider_export_id", "export_id"),
"reporting_definition_grant": ("grant_id", "id"),
"reporting_saved_view": ("saved_view_id", "view_id"),
"reporting_schedule": ("schedule_id", "schedule_id"),
"reporting_publication": ("publication_id", "publication_id"),
"reporting_drill_context": ("drill_context_id", "drill_context_id"),
"reporting_quality_result": ("quality_result_id", "result_id"),
"reporting_import_assessment": (
"import_assessment_id",
"assessment_id",
),
}[match.resource_type]
value = getattr(row, field)
if selectors.direct.get(selector) == str(value):
return True
return (
match.resource_type == "reporting_definition_revision"
and selectors.direct.get("definition_id") == row.definition_id
)
def _direct_category(resource_type: str, row: Any) -> str:
if resource_type == "reporting_saved_view":
return "shared_reporting_view" if row.shared else "subject_owned_reporting_view"
return {
"reporting_execution": "derived_report_result",
"reporting_provider_execution": "derived_provider_report_result",
"reporting_provider_export": "derived_provider_report_export",
"reporting_publication": "derived_report_publication",
"reporting_definition_grant": "subject_access_grant",
"reporting_drill_context": "subject_owned_drill_context",
}.get(resource_type, "reporting_configuration")
def _record(match: _Match) -> DsarRecordRef:
row = match.row
data = _record_data(match.resource_type, row)
immutable = match.category == "reporting_operator_attribution"
return DsarRecordRef(
provider_id="reporting",
module_id="reporting",
resource_type=match.resource_type,
resource_id=str(row.id),
category=match.category,
title=_title(match.resource_type),
data={key: value for key, value in data.items() if value is not None},
observed_at=_observed_at(row),
immutable_evidence=immutable,
retention_reason=(
"Institutional reporting activity remains attributable for "
"governance and audit review."
if immutable
else None
),
source_path="/reports",
)
def _record_data(resource_type: str, row: Any) -> dict[str, object]:
if resource_type == "reporting_definition":
return {
"definition_kind": row.definition_kind,
"definition_id": row.definition_id,
"definition_key": row.definition_key,
"created_at": _iso(row.created_at),
}
if resource_type == "reporting_definition_revision":
return {
"definition_kind": row.definition_kind,
"definition_id": row.definition_id,
"revision": row.revision,
"status": row.status,
"visibility": row.visibility,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
}
if resource_type == "reporting_execution":
return {
"execution_id": row.execution_id,
"report_id": row.report_id,
"report_revision": row.report_revision,
"semantic_model_id": row.semantic_model_id,
"semantic_model_revision": row.semantic_model_revision,
"dataset_id": row.dataset_id,
"dataset_revision": row.dataset_revision,
"status": row.status,
"total_rows": row.total_rows,
"truncated": row.truncated,
"has_retained_result": bool(row.result_rows),
"started_at": _iso(row.started_at),
"finished_at": _iso(row.finished_at),
}
if resource_type == "reporting_provider_execution":
return {
"execution_id": row.execution_id,
"provider_id": row.provider_id,
"report_id": row.report_id,
"report_revision": row.report_revision,
"contract_version": row.contract_version,
"privacy_transforms": list(row.privacy_transforms or []),
"retention_class": row.retention_class,
"retention_days": row.retention_days,
"expires_at": _iso(row.expires_at),
"retention_redacted_at": _iso(row.retention_redacted_at),
"has_retained_result": bool(row.result_payload),
"generated_at": _iso(row.generated_at),
}
if resource_type == "reporting_provider_export":
return {
"export_id": row.export_id,
"execution_id": row.execution_id,
"format": row.format,
"exported_at": _iso(row.exported_at),
}
if resource_type == "reporting_definition_grant":
return {
"definition_kind": row.definition_kind,
"definition_id": row.definition_id,
"subject_kind": row.subject_kind,
"permissions": list(row.permissions or []),
"active": row.active,
"source_revision": row.source_revision,
}
if resource_type == "reporting_saved_view":
return {
"view_id": row.view_id,
"report_id": row.report_id,
"report_revision": row.report_revision,
"owner_kind": row.owner_kind,
"revision": row.revision,
"shared": row.shared,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
}
if resource_type == "reporting_schedule":
return {
"schedule_id": row.schedule_id,
"report_id": row.report_id,
"report_revision": row.report_revision,
"revision": row.revision,
"trigger_kind": row.trigger_kind,
"enabled": row.enabled,
"next_run_at": _iso(row.next_run_at),
"last_run_at": _iso(row.last_run_at),
}
if resource_type == "reporting_publication":
return {
"publication_id": row.publication_id,
"execution_id": row.execution_id,
"target_capability": row.target_capability,
"format": row.format,
"status": row.status,
"completed_at": _iso(row.completed_at),
"created_at": _iso(row.created_at),
}
if resource_type == "reporting_drill_context":
return {
"drill_context_id": row.drill_context_id,
"execution_id": row.execution_id,
"expires_at": _iso(row.expires_at),
"last_accessed_at": _iso(row.last_accessed_at),
}
if resource_type == "reporting_quality_result":
return {
"result_id": row.result_id,
"quality_plan_id": row.quality_plan_id,
"quality_plan_revision": row.quality_plan_revision,
"dataset_id": row.dataset_id,
"dataset_revision": row.dataset_revision,
"status": row.status,
"evaluated_at": _iso(row.evaluated_at),
}
return {
"assessment_id": row.assessment_id,
"source_system": row.source_system,
"status": row.status,
"created_at": _iso(row.created_at),
}
def _planned_kind(record: DsarRecordRef) -> str:
if record.category == "reporting_operator_attribution":
return "retain"
if record.category in {"reporting_configuration", "shared_reporting_view"}:
return "manual_review"
return _EXECUTABLE_KINDS.get(record.resource_type, "manual_review")
def _rationale(record: DsarRecordRef, *, kind: str) -> str:
if kind == "delete":
return "Remove subject-owned, non-authoritative Reporting workspace state."
if kind == "anonymize":
return (
"Clear retained result or delivery detail while preserving hashes and "
"minimal institutional execution evidence."
)
if kind == "revoke":
return "Disable the subject-specific Reporting access relationship."
if kind == "retain":
return record.retention_reason or "Retain institutional attribution evidence."
return (
"An authorized Reporting owner must review shared configuration, "
"dependencies, legal retention, and third-party impact."
)
def _execute_action(
session: Session,
*,
row: Any,
resource_type: str,
kind: str,
) -> tuple[str, str]:
expected = _EXECUTABLE_KINDS.get(resource_type)
if expected != kind:
raise ValueError("Reporting DSAR executable action is not supported.")
if resource_type == "reporting_saved_view":
if row.shared:
raise ValueError("Shared Reporting views require manual review.")
session.delete(row)
session.flush()
return "executed", "Subject-owned Reporting view removed."
if resource_type == "reporting_drill_context":
session.delete(row)
session.flush()
return "executed", "Ephemeral Reporting drill context removed."
if resource_type == "reporting_definition_grant":
if not row.active:
return "unchanged", "Reporting access grant was already inactive."
row.active = False
session.flush()
return "executed", "Subject-specific Reporting access grant revoked."
if resource_type == "reporting_execution":
fields = {
"parameters": {},
"query": {},
"source_fingerprints": [],
"result_rows": [],
"diagnostics": [],
"provenance": {},
}
changed = _replace_fields(row, fields)
elif resource_type == "reporting_provider_execution":
fields = {
"purpose": "Redacted by data-subject request.",
"audience_scope": {},
"parameters": {},
"result_payload": {},
"source_revisions": [],
"effective_scope": {},
"provenance": {},
"governance_provenance": {},
}
changed = _replace_fields(row, fields)
if row.retention_redacted_at is None:
row.retention_redacted_at = datetime.now(timezone.utc)
changed = True
elif resource_type == "reporting_provider_export":
changed = _replace_fields(
row,
{
"purpose": "Redacted by data-subject request.",
"audience_scope": {},
},
)
else:
changed = _replace_fields(
row,
{"target_ref": None, "evidence": {}, "error": None},
)
if changed:
session.flush()
return "executed", "Retained Reporting detail minimized; hashes remain."
return "unchanged", "Retained Reporting detail was already minimized."
def _replace_fields(row: Any, values: dict[str, object]) -> bool:
changed = False
for field, value in values.items():
if getattr(row, field) != value:
setattr(row, field, value)
changed = True
return changed
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
references = subject.external_references
account_id = _coalesce(
subject.account_id,
references.get("reporting.account"),
references.get("access.account"),
)
identity_id = _coalesce(
subject.identity_id,
references.get("reporting.identity"),
references.get("identity.id"),
)
membership_id = _coalesce(
subject.membership_id,
references.get("reporting.membership"),
references.get("tenancy.membership"),
)
direct: dict[str, str] = {}
for selector, aliases in _DIRECT_ALIASES.items():
value = _coalesce(*(references.get(alias) for alias in aliases))
if value is _CONFLICT:
return None
if value:
direct[selector] = str(value)
if _CONFLICT in {account_id, identity_id, membership_id}:
return None
return _Selectors(
account_id=_optional(account_id),
identity_id=_optional(identity_id),
membership_id=_optional(membership_id),
direct=direct,
)
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(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _title(resource_type: str) -> str:
return resource_type.removeprefix("reporting_").replace("_", " ").title()
def _observed_at(row: Any) -> datetime | None:
for field in (
"generated_at",
"exported_at",
"evaluated_at",
"recorded_at",
"started_at",
"completed_at",
"updated_at",
"created_at",
):
value = getattr(row, field, None)
if isinstance(value, datetime):
return _aware(value)
return 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("Reporting DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "reporting" or record.module_id != "reporting":
raise ValueError("Reporting DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_MODELS or not record.resource_id:
raise ValueError("Reporting DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "reporting" or action.module_id != "reporting":
raise ValueError("Reporting DSAR cannot execute a foreign provider action.")
if action.resource_type not in _RESOURCE_MODELS or not action.action_id.startswith(
"reporting:"
):
raise ValueError("Reporting DSAR action identity is invalid.")
__all__ = ["REPORTING_DSAR_CAPABILITY", "ReportingDsarProvider"]
+217 -2
View File
@@ -15,6 +15,7 @@ from govoplan_core.core.module_guards import (
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
@@ -26,6 +27,7 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import (
@@ -50,6 +52,10 @@ from govoplan_reporting.backend.contracts import (
CAPABILITY_REPORTING_SCHEDULER,
)
from govoplan_reporting.backend.db import models as reporting_models
from govoplan_reporting.backend.dsar_provider import (
REPORTING_DSAR_CAPABILITY,
ReportingDsarProvider,
)
from govoplan_reporting.backend.definitions import (
ADMIN_SCOPE,
READ_SCOPE,
@@ -77,7 +83,7 @@ from govoplan_reporting.backend.search_source import create_reporting_search_sou
MODULE_ID = "reporting"
MODULE_NAME = "Reporting"
MODULE_VERSION = "0.1.17"
MODULE_VERSION = "0.1.21"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -198,6 +204,11 @@ def _retention(context: ModuleContext):
return ReportingRetentionService()
def _dsar_provider(context: ModuleContext) -> ReportingDsarProvider:
del context
return ReportingDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
definitions = (
session.query(reporting_models.ReportingDefinitionRevision)
@@ -307,6 +318,21 @@ manifest = ModuleManifest(
surface_id="reporting.navigation",
),
),
product_areas=(
ProductAreaContribution(
id="data-assurance",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.data_assurance",
icon="database-zap",
description="i18n:govoplan-core.product_area.data_assurance_description",
surface_ids=(
"reporting.navigation",
"reporting.workspace",
"reporting.compatibility",
),
order=60,
),
),
view_surfaces=(
ViewSurface(
id="reporting.parameters",
@@ -345,6 +371,7 @@ manifest = ModuleManifest(
name=CAPABILITY_REPORTING_PUBLICATION_MAIL, version="1.0.0"
),
ModuleInterfaceProvider(name=CAPABILITY_REPORTING_RETENTION, version="1.0.0"),
ModuleInterfaceProvider(name=REPORTING_DSAR_CAPABILITY, version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
@@ -380,6 +407,7 @@ manifest = ModuleManifest(
CAPABILITY_REPORTING_PUBLICATION_FILES: _files_publication,
CAPABILITY_REPORTING_PUBLICATION_MAIL: _mail_publication,
CAPABILITY_REPORTING_RETENTION: _retention,
REPORTING_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
CAPABILITY_REPORTING_REGISTRY: CapabilityDocumentation(
@@ -419,6 +447,13 @@ manifest = ModuleManifest(
documentation_types=("admin",),
audience=("privacy_officer", "operator", "system_admin"),
),
REPORTING_DSAR_CAPABILITY: CapabilityDocumentation(
label="Reporting data-subject request provider",
summary="Finds subject-owned Reporting state and minimizes derived report copies.",
contract_version="0.1.0",
documentation_types=("admin", "user"),
audience=("privacy_officer", "operator", "user"),
),
},
search_sources=(
SearchSourceProviderRegistration(
@@ -476,6 +511,87 @@ manifest = ModuleManifest(
),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="reporting.data-subject-requests",
title="Reporting data-subject requests",
summary="Review personal workspace state and derived report copies without confusing them with source-owned facts.",
body=(
"Reporting matches exact tenant-scoped artifact references and account, identity, or membership ownership and attribution. Access output is deliberately minimized: report rows, parameters, filters, delivery targets, source payloads, diagnostics, provenance bodies, and hashes are not copied into the DSAR result. Source modules remain responsible for finding and correcting subject facts; Reporting cannot safely infer a person by scanning arbitrary aggregate output. "
"Private saved views and short-lived drill contexts can be deleted, subject grants can be revoked, and explicitly identified retained execution or publication detail can be minimized idempotently while hashes remain. Shared views, definitions, schedules, quality/import evidence, and staff attribution require authorized review or retention. Correct the source before rerunning a report or republishing an output."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "datasources", "dataflow", "policy"),
metadata={
"kind": "reference",
"help_contexts": [
"reporting.data-subject-requests",
"reporting.workspace",
],
"consequence_classes": {
"export_minimized_attribution": (
"Returns ownership and lifecycle context without report rows, parameters, or payloads."
),
"retain_governed_evidence": (
"Shared definitions, schedules, quality evidence, and required staff attribution remain subject to authorized review and retention."
),
"correct_authoritative_source": (
"Source facts must be corrected in their owner module before reports are rerun or republished."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen im Reporting",
"summary": (
"Persönliche Arbeitsbereichsdaten und abgeleitete Berichtskopien prüfen, "
"ohne sie mit Fakten aus führenden Quellsystemen zu verwechseln."
),
"body": (
"Reporting gleicht innerhalb des exakten Mandanten nur ausdrückliche "
"Artefaktverweise sowie die Zuordnung oder Urheberschaft von Konten, "
"Identitäten und Mitgliedschaften ab. Die Auskunft ist bewusst minimiert: "
"Berichtszeilen, Parameter, Filter, Zustellziele, Quellinhalte, Diagnosen, "
"Provenienzinhalte und Prüfsummen werden nicht in das Ergebnis kopiert. "
"Die Quellmodule bleiben dafür verantwortlich, personenbezogene Fakten zu "
"finden und zu berichtigen; Reporting darf Personen nicht durch das Durchsuchen "
"beliebiger Aggregatergebnisse ableiten. Private gespeicherte Ansichten und "
"kurzlebige Drilldown-Kontexte können gelöscht, personenbezogene Freigaben "
"entzogen und ausdrücklich bestimmte aufbewahrte Ausführungs- oder "
"Veröffentlichungsdetails idempotent minimiert werden, während Prüfsummen "
"erhalten bleiben. Gemeinsame Ansichten, Definitionen, Zeitpläne, Qualitäts- "
"und Importnachweise sowie dienstliche Zuschreibungen erfordern eine befugte "
"Prüfung oder Aufbewahrung. Die Quelle ist zu berichtigen, bevor ein Bericht "
"erneut ausgeführt oder veröffentlicht wird."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"export_minimized_attribution": (
"Gibt Zuordnungs- und Lebenszykluskontext ohne Berichtszeilen, Parameter oder Inhalte zurück."
),
"retain_governed_evidence": (
"Gemeinsame Definitionen, Zeitpläne, Qualitätsnachweise und erforderliche dienstliche Zuschreibungen unterliegen weiterhin befugter Prüfung und Aufbewahrung."
),
"correct_authoritative_source": (
"Quellfakten müssen im führenden Modul berichtigt werden, bevor Berichte erneut ausgeführt oder veröffentlicht werden."
),
}
}
},
links=(
DocumentationLink(
label="Reporting governance and retention",
href="govoplan-reporting/README.md",
kind="repository",
),
),
order=9,
),
DocumentationTopic(
id="reporting.governed-bi",
title="Governed reporting and semantic BI",
@@ -487,12 +603,111 @@ manifest = ModuleManifest(
"aggregations, typed expressions, filters, pivots, saved views, chart models, "
"schedules, exports, and publication providers replace unchecked SQL in the "
"presentation layer. PostgreSQL executes bounded semantic plans when available. "
"Calculated measure keys may contain the documented dots and hyphens, including in nested references; generated bind names remain internal and values remain parameters, not SQL fragments. "
"Signed drill contexts reauthorize contributor rows, and Files/Mail publication "
"adapters retain idempotent evidence. A dataset may pin one successful published Dataflow run, which is read from its exact Datasource materialization after both source boundaries reauthorize the current principal. Dataflow and module read models remain source owners."
"adapters retain idempotent evidence. A dataset may pin one successful published Dataflow run, which is read from its exact Datasource materialization after both source boundaries reauthorize the current principal. Dataflow and module read models remain source owners. "
"The contributor drill-down action stays in a shared action column at the right edge of horizontally scrolled results; opening it still reauthorizes every contributor."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
related_modules=("datasources", "dataflow", "policy", "files", "mail"),
metadata={
"kind": "workflow",
"help_contexts": [
"reporting.workspace",
"reporting.definitions",
"reporting.executions",
"reporting.publications",
],
"purpose": (
"Create and run a reproducible report over an authorized, revision-pinned dataset."
),
"prerequisites": [
"The actor can read Reporting and has the additional scope required for each offered action.",
"The selected dataset and every contributing row remain authorized by their owner providers.",
],
"steps": [
"Select or create a dataset definition and pin an immutable source revision.",
"Define safe dimensions, measures, filters, pivots, and a report revision.",
"Run the pinned revision and review quality, policy, provenance, and diagnostics evidence.",
"Inspect authorized rows or use a signed drill context that reauthorizes every contributor.",
"Export, schedule, or publish only when the corresponding action and target are authorized.",
],
"limitations": [
"Reporting never replaces source-module authorization or authoritative source correction.",
"XLSX and PDF output require an installed renderer provider; native browser export is CSV or JSON.",
],
"operational_consequences": {
"run": "Creates immutable execution, provenance, quality, diagnostic, and output-hash evidence.",
"publish": "Creates idempotent target-delivery evidence and may cause an external effect.",
"schedule": "Allows future executions under the then-current authorization and policy state.",
},
"verification": [
"The execution names the pinned definition and source fingerprints.",
"Quality and policy results are visible before publication evidence is accepted.",
"Drilldown and publication access are reauthorized for the current principal.",
],
},
translations={
"de": {
"title": "Gesteuertes Reporting und semantische BI",
"summary": (
"Reproduzierbare Berichte auf anbietergeführten Datensätzen erstellen, "
"ohne Modul- oder Zeilenberechtigungen zu umgehen."
),
"body": (
"Reporting fixiert Revisionen von Datensätzen, semantischen Modellen und "
"Berichten. Ausführungen bewahren Definitionsprüfsummen, Quellfingerabdrücke, "
"Richtlinienherkunft, Qualitätsnachweise, berechtigte Ergebniszeilen, Diagnosen "
"und Ausgabeprüfsummen. Sichere Dimensionen, Aggregationen, typisierte Ausdrücke, "
"Filter, Pivotierungen, gespeicherte Ansichten, Diagrammmodelle, Zeitpläne, "
"Exporte und Veröffentlichungsanbieter ersetzen ungeprüftes SQL in der "
"Darstellungsschicht. PostgreSQL führt begrenzte semantische Pläne aus, sofern "
"verfügbar. Kennungen berechneter Kennzahlen dürfen auch in verschachtelten Verweisen die vorgesehenen Punkte und Bindestriche enthalten; "
"erzeugte Bindungsnamen bleiben intern, und Werte bleiben Parameter statt SQL-Fragmente. Signierte Drilldown-Kontexte autorisieren beitragende Zeilen erneut; "
"Adapter für Dateien und Mail bewahren idempotente Nachweise. Ein Datensatz kann "
"genau eine erfolgreiche veröffentlichte Dataflow-Ausführung fixieren, die nach "
"erneuter Autorisierung beider Quellgrenzen aus ihrer exakten Datasource-"
"Materialisierung gelesen wird. Dataflow und die Lesemodelle der Module bleiben "
"führende Quellen. Die Aktion zum Aufschlüsseln beitragender Zeilen bleibt in einer gemeinsamen Aktionsspalte am rechten Rand horizontal gescrollter Ergebnisse; beim Öffnen wird jeder Beitrag erneut autorisiert."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"purpose": (
"Einen reproduzierbaren Bericht über einen berechtigten, revisionsgenau fixierten Datensatz erstellen und ausführen."
),
"prerequisites": [
"Die handelnde Person darf Reporting lesen und besitzt für jede angebotene Aktion die zusätzlich erforderliche Berechtigung.",
"Der ausgewählte Datensatz und jede beitragende Zeile bleiben durch ihre führenden Anbieter autorisiert.",
],
"steps": [
"Eine Datensatzdefinition auswählen oder erstellen und eine unveränderliche Quellrevision fixieren.",
"Sichere Dimensionen, Kennzahlen, Filter, Pivotierungen und eine Berichtsrevision definieren.",
"Die fixierte Revision ausführen und Qualitäts-, Richtlinien-, Provenienz- und Diagnosenachweise prüfen.",
"Berechtigte Zeilen prüfen oder einen signierten Drilldown-Kontext verwenden, der jeden Beitrag erneut autorisiert.",
"Nur mit der jeweiligen Aktions- und Zielberechtigung exportieren, planen oder veröffentlichen.",
],
"limitations": [
"Reporting ersetzt weder die Autorisierung der Quellmodule noch die Berichtigung in der führenden Quelle.",
"XLSX- und PDF-Ausgaben erfordern einen installierten Renderer-Anbieter; der native Browserexport unterstützt CSV und JSON.",
],
"operational_consequences": {
"run": "Erzeugt unveränderliche Nachweise zu Ausführung, Provenienz, Qualität, Diagnosen und Ausgabeprüfsumme.",
"publish": "Erzeugt idempotente Nachweise zur Zielzustellung und kann eine externe Wirkung auslösen.",
"schedule": "Erlaubt künftige Ausführungen unter dem dann gültigen Berechtigungs- und Richtlinienstand.",
},
"verification": [
"Die Ausführung nennt die fixierte Definition und die Quellfingerabdrücke.",
"Qualitäts- und Richtlinienergebnisse sind sichtbar, bevor ein Veröffentlichungsnachweis akzeptiert wird.",
"Drilldown- und Veröffentlichungszugriffe werden für die aktuelle Person erneut autorisiert.",
],
}
},
links=(
DocumentationLink(
label="Reporting module boundary",
@@ -344,7 +344,9 @@ def _calculated_sql(
return _calculated_sql(
target.expression,
parameters,
prefix + "_" + reference,
# The expression position already makes this prefix unique. Model
# keys may contain dots or hyphens, which are not SQL bind names.
prefix + "_ref",
measures=measures,
stack=(*stack, reference),
)
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
user_workflow_scope_condition_issues,
)
from govoplan_reporting.backend.manifest import manifest
class ReportingDocumentationTests(unittest.TestCase):
def test_public_topics_have_complete_german_reference_content(self) -> None:
self.assertEqual(2, len(manifest.documentation))
for topic in manifest.documentation:
translation = topic.translations.get("de", {})
self.assertTrue(
all(translation.get(key) for key in ("title", "summary", "body"))
)
self.assertEqual((), documentation_structured_translation_issues(topic))
def test_documentation_has_scope_conditioned_workflow_and_reference(self) -> None:
kinds = {topic.metadata.get("kind") for topic in manifest.documentation}
self.assertIn("workflow", kinds)
self.assertIn("reference", kinds)
for topic in manifest.documentation:
self.assertEqual((), user_workflow_scope_condition_issues(topic))
if __name__ == "__main__":
unittest.main()
+644
View File
@@ -0,0 +1,644 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime, timedelta
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_reporting.backend.db.models import (
ReportingDefinitionGrant,
ReportingDefinitionIdentity,
ReportingDefinitionRevision,
ReportingDrillContext,
ReportingExecution,
ReportingImportAssessment,
ReportingProviderExecution,
ReportingProviderExport,
ReportingPublication,
ReportingQualityResult,
ReportingSavedView,
ReportingSchedule,
)
from govoplan_reporting.backend.dsar_provider import (
REPORTING_DSAR_CAPABILITY,
ReportingDsarProvider,
)
from govoplan_reporting.backend.manifest import manifest
NOW = datetime(2026, 8, 21, 18, 0, tzinfo=UTC)
SECRET = "personal-report-detail-do-not-export"
class _Registry:
def __init__(self, provider: ReportingDsarProvider, *, active: bool = True) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (REPORTING_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "reporting"
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": ("reporting",) 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": "reporting"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != REPORTING_DSAR_CAPABILITY:
raise KeyError(name)
class ReportingDsarProviderTests(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 = ReportingDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
identity = ReportingDefinitionIdentity(
id="definition-row-1",
tenant_id="tenant-1",
definition_kind="report",
definition_id="report-1",
definition_key="resident-permits",
created_by="account-1",
)
self.session.add(identity)
self.session.flush()
self.session.add(
ReportingDefinitionRevision(
id="revision-row-1",
tenant_id="tenant-1",
identity_id=identity.id,
definition_kind="report",
definition_id="report-1",
definition_key="resident-permits",
revision=1,
name=SECRET,
description=SECRET,
status="active",
visibility="restricted",
content_hash="a" * 64,
change_reason=SECRET,
idempotency_key=SECRET,
request_sha256="b" * 64,
event_id="event-1",
recorded_at=NOW,
payload={"secret": SECRET},
changed_by="account-1",
)
)
self.session.add_all(
(
self._execution(
row_id="execution-row-1",
tenant_id="tenant-1",
execution_id="execution-1",
actor_id="account-1",
),
self._execution(
row_id="execution-row-2",
tenant_id="tenant-2",
execution_id="execution-1",
actor_id="account-1",
),
)
)
provider_execution = ReportingProviderExecution(
id="provider-row-1",
tenant_id="tenant-1",
execution_id="provider-execution-1",
provider_id="cases.reports",
report_id="resident-permits",
report_revision="1",
contract_version="1.0.0",
idempotency_key=SECRET,
request_sha256="c" * 64,
purpose=SECRET,
audience_scope={"secret": SECRET},
parameters={"secret": SECRET},
result_schema=[{"secret": SECRET}],
result_payload={"secret": SECRET},
source_revisions=[{"secret": SECRET}],
effective_scope={"secret": SECRET},
privacy_transforms=["small_cell_suppression"],
provenance={"secret": SECRET},
governance_provenance={"secret": SECRET},
retention_class="short",
retention_days=30,
expires_at=NOW + timedelta(days=30),
output_hash="d" * 64,
generated_at=NOW,
actor_id="account-1",
)
self.session.add(provider_execution)
self.session.flush()
self.session.add_all(
(
ReportingProviderExport(
id="export-row-1",
tenant_id="tenant-1",
export_id="export-1",
provider_execution_id=provider_execution.id,
execution_id=provider_execution.execution_id,
format="json",
purpose=SECRET,
audience_scope={"secret": SECRET},
output_hash="e" * 64,
exported_at=NOW,
actor_id="account-1",
),
ReportingDefinitionGrant(
id="grant-row-1",
tenant_id="tenant-1",
definition_kind="report",
definition_id="report-1",
subject_kind="account",
subject_id="account-1",
permissions=["view"],
active=True,
source_revision=1,
),
ReportingSavedView(
id="view-row-1",
tenant_id="tenant-1",
view_id="view-1",
report_id="report-1",
report_revision=1,
owner_kind="account",
owner_id="account-1",
name=SECRET,
state={"secret": SECRET},
shared=False,
access={"secret": SECRET},
),
ReportingSavedView(
id="view-row-2",
tenant_id="tenant-1",
view_id="view-shared",
report_id="report-1",
report_revision=1,
owner_kind="account",
owner_id="account-1",
name=SECRET,
state={"secret": SECRET},
shared=True,
access={"secret": SECRET},
),
ReportingSchedule(
id="schedule-row-1",
tenant_id="tenant-1",
schedule_id="schedule-1",
report_id="report-1",
report_revision=1,
name=SECRET,
trigger_kind="interval",
trigger_config={"secret": SECRET},
parameters={"secret": SECRET},
query={"secret": SECRET},
publication_target={"secret": SECRET},
enabled=True,
next_run_at=NOW + timedelta(days=1),
created_by="account-1",
),
ReportingPublication(
id="publication-row-1",
tenant_id="tenant-1",
publication_id="publication-1",
execution_id="execution-1",
target_capability="files.artifact_store",
target_ref=SECRET,
format="json",
status="succeeded",
idempotency_key=SECRET,
evidence={"secret": SECRET},
error=SECRET,
completed_at=NOW,
),
ReportingDrillContext(
id="drill-row-1",
tenant_id="tenant-1",
drill_context_id="drill-1",
execution_id="execution-1",
token_sha256="f" * 64,
context_sha256="0" * 64,
actor_id="account-1",
dimension_path=[{"secret": SECRET}],
source_fingerprints=[{"secret": SECRET}],
policy_provenance={"secret": SECRET},
expires_at=NOW + timedelta(minutes=10),
),
ReportingQualityResult(
id="quality-row-1",
tenant_id="tenant-1",
result_id="quality-1",
quality_plan_id="quality-plan-1",
quality_plan_revision=1,
dataset_id="dataset-1",
dataset_revision=1,
status="passed",
output_hash="1" * 64,
assertions=[{"secret": SECRET}],
source_fingerprints=[{"secret": SECRET}],
evaluated_at=NOW,
actor_id="account-1",
),
ReportingImportAssessment(
id="assessment-row-1",
tenant_id="tenant-1",
assessment_id="assessment-1",
source_system="legacy-bi",
source_id=SECRET,
source_fingerprint="2" * 64,
mapping_report={"secret": SECRET},
status="blocked",
accepted_approximations=[SECRET],
assessed_by="account-1",
),
)
)
@staticmethod
def _execution(
*,
row_id: str,
tenant_id: str,
execution_id: str,
actor_id: str,
) -> ReportingExecution:
return ReportingExecution(
id=row_id,
tenant_id=tenant_id,
execution_id=execution_id,
report_id="report-1",
report_revision=1,
semantic_model_id="semantic-1",
semantic_model_revision=1,
dataset_id="dataset-1",
dataset_revision=1,
status="succeeded",
idempotency_key=SECRET,
request_sha256="3" * 64,
parameters={"secret": SECRET},
query={"secret": SECRET},
source_fingerprints=[{"secret": SECRET}],
definition_hashes={"secret": SECRET},
output_hash="4" * 64,
executor_version="reporting-v1",
result_schema=[{"secret": SECRET}],
result_rows=[{"secret": SECRET}],
total_rows=1,
truncated=False,
diagnostics=[{"secret": SECRET}],
provenance={"secret": SECRET},
started_at=NOW,
finished_at=NOW,
actor_id=actor_id,
)
def test_canonical_selector_returns_minimized_owned_and_attribution_rows(
self,
) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-1"),
)
self.assertEqual(12, len(records))
self.assertIn(
"subject_owned_reporting_view",
{record.category for record in records},
)
self.assertIn(
"reporting_operator_attribution",
{record.category for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertNotIn(SECRET, exported)
self.assertNotIn("account-1", exported)
self.assertNotIn("execution-row-2", exported)
def test_direct_references_are_exact_tenant_scoped_and_corroborated(self) -> None:
direct = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"reporting.execution": "execution-1"},
),
)
mismatch = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-2",
external_references={"reporting.execution": "execution-1"},
),
)
wrong_tenant = self.provider.search_subject(
self.session,
tenant_id="tenant-2",
subject=DsarSubjectRef(
external_references={"reporting.saved_view": "view-1"}
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={
"reporting.definition_revision": "revision-row-1",
"reporting.revision": "different-revision",
}
),
)
self.assertEqual(["execution-row-1"], [item.resource_id for item in direct])
self.assertEqual("derived_report_result", direct[0].category)
self.assertEqual((), mismatch)
self.assertEqual((), wrong_tenant)
self.assertEqual((), conflict)
def test_every_exact_artifact_reference_is_supported(self) -> None:
references = {
"reporting.definition": ("report-1", 2),
"reporting.definition_revision": ("revision-row-1", 1),
"reporting.provider_execution": ("provider-execution-1", 1),
"reporting.provider_export": ("export-1", 1),
"reporting.definition_grant": ("grant-row-1", 1),
"reporting.saved_view": ("view-1", 1),
"reporting.schedule": ("schedule-1", 1),
"reporting.publication": ("publication-1", 1),
"reporting.drill_context": ("drill-1", 1),
"reporting.quality_result": ("quality-1", 1),
"reporting.import_assessment": ("assessment-1", 1),
}
for key, (value, expected) in references.items():
with self.subTest(key=key):
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(external_references={key: value}),
)
self.assertEqual(expected, len(records))
def test_planning_and_execution_preserve_governed_boundaries(self) -> None:
subject = DsarSubjectRef(account_id="account-1")
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_resource = {action.resource_id: action for action in actions}
self.assertEqual("delete", by_resource["view-row-1"].kind)
self.assertEqual("manual_review", by_resource["view-row-2"].kind)
self.assertEqual("delete", by_resource["drill-row-1"].kind)
self.assertEqual("revoke", by_resource["grant-row-1"].kind)
self.assertEqual("retain", by_resource["execution-row-1"].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-retry",
)
self.assertIn("executed", {result.status for result in first})
self.assertIn("blocked", {result.status for result in first})
self.assertIn("unchanged", {result.status for result in second})
self.assertIsNone(self.session.get(ReportingSavedView, "view-row-1"))
self.assertIsNone(self.session.get(ReportingDrillContext, "drill-row-1"))
self.assertFalse(
self.session.get(ReportingDefinitionGrant, "grant-row-1").active
)
self.assertEqual(
[{"secret": SECRET}],
self.session.get(ReportingExecution, "execution-row-1").result_rows,
)
def test_exact_derived_detail_is_minimized_idempotently(self) -> None:
subject = DsarSubjectRef(
external_references={
"reporting.execution": "execution-1",
"reporting.provider_execution": "provider-execution-1",
"reporting.provider_export": "export-1",
"reporting.publication": "publication-1",
}
)
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({"anonymize"}, {action.kind for action in actions})
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-2",
)
second = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-2-retry",
)
self.assertTrue(all(result.status == "executed" for result in first))
self.assertTrue(all(result.status == "unchanged" for result in second))
execution = self.session.get(ReportingExecution, "execution-row-1")
provider_execution = self.session.get(
ReportingProviderExecution,
"provider-row-1",
)
publication = self.session.get(
ReportingPublication,
"publication-row-1",
)
provider_export = self.session.get(ReportingProviderExport, "export-row-1")
self.assertEqual([], execution.result_rows)
self.assertEqual({}, execution.parameters)
self.assertEqual({}, provider_execution.result_payload)
self.assertIsNotNone(provider_execution.retention_redacted_at)
self.assertEqual({}, provider_export.audience_scope)
self.assertEqual(
"Redacted by data-subject request.",
provider_export.purpose,
)
self.assertIsNone(publication.target_ref)
self.assertEqual({}, publication.evidence)
self.assertIsNone(publication.error)
def test_foreign_records_and_actions_are_rejected(self) -> None:
subject = DsarSubjectRef(account_id="account-1")
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="cases",
module_id="cases",
resource_type="case",
resource_id="case-1",
category="case",
title="Case",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="cases:delete:case:case-1",
provider_id="cases",
module_id="cases",
kind="delete",
resource_type="case",
resource_id="case-1",
title="Delete case",
rationale="Foreign",
executable=True,
),
),
request_id="dsar-3",
)
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-REPORTING-1",
request_kind="access_and_erasure",
subject=DsarSubjectRef(account_id="account-1"),
purpose="Respond to a verified request.",
legal_basis="Article 15 and 17 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(
[REPORTING_DSAR_CAPABILITY],
row.coverage["provider_capabilities"],
)
self.assertEqual(12, row.search_result["record_count"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-REPORTING-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(
[REPORTING_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertEqual(0, inactive.search_result["record_count"])
def test_manifest_registers_and_documents_capability(self) -> None:
self.assertIn(REPORTING_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(REPORTING_DSAR_CAPABILITY, manifest.capability_documentation)
self.assertIn(
REPORTING_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "reporting.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -14,7 +14,7 @@ class ReportingManifestTests(unittest.TestCase):
self.assertEqual("@govoplan/reporting-webui", manifest.frontend.package_name)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertEqual(7, len(manifest.provides_interfaces))
self.assertEqual(8, len(manifest.provides_interfaces))
self.assertEqual(1, len(manifest.search_sources))
self.assertIn("dataflow", manifest.optional_dependencies)
self.assertIn("policy", manifest.optional_dependencies)
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import unittest
from sqlalchemy import text
from sqlalchemy.dialects import postgresql
from govoplan_reporting.backend.postgres_planner import compile_postgres_query
from govoplan_reporting.backend.schemas import (
DatasetDefinition,
ReportQuery,
SemanticModelDefinition,
)
class PostgresBindNameTests(unittest.TestCase):
def test_allowed_measure_key_punctuation_never_becomes_bind_parameter_syntax(
self,
) -> None:
dataset = DatasetDefinition(
source_kind="static",
source_ref="fixture",
static_rows=[{"value": 10}],
purpose="Bound parameter fixture",
)
semantic = SemanticModelDefinition.model_validate(
{
"dataset_id": "fixture",
"dataset_revision": 1,
"measures": [
{
"key": "base",
"label": "Base",
"aggregation": "sum",
"field": "value",
},
{
"key": "extra-cost",
"label": "Extra",
"aggregation": "calculated",
"expression": {
"op": "add",
"args": [
{"op": "measure", "ref": "base"},
{"op": "literal", "value": 5},
],
},
},
{
"key": "tax.factor",
"label": "Tax",
"aggregation": "calculated",
"expression": {
"op": "multiply",
"args": [
{"op": "measure", "ref": "base"},
{"op": "literal", "value": 9},
],
},
},
{
"key": "grand-total",
"label": "Total",
"aggregation": "calculated",
"expression": {
"op": "add",
"args": [
{"op": "measure", "ref": "extra-cost"},
{"op": "measure", "ref": "tax.factor"},
],
},
},
],
}
)
plan = compile_postgres_query(
dataset,
semantic,
ReportQuery(measures=["extra-cost", "tax.factor", "grand-total"]),
)
binds = text(plan.sql).compile(dialect=postgresql.dialect()).params
self.assertEqual(
set(plan.parameters) | {"rows_json", "result_limit", "result_offset"},
set(binds),
)
self.assertTrue(
all("-" not in name and "." not in name for name in plan.parameters)
)
self.assertIn('AS "extra-cost"', plan.sql)
self.assertIn('AS "tax.factor"', plan.sql)
self.assertEqual(2, list(plan.parameters.values()).count(5))
self.assertEqual(2, list(plan.parameters.values()).count(9))
if __name__ == "__main__":
unittest.main()
+11 -2
View File
@@ -384,7 +384,7 @@ class ReportingServiceTests(unittest.TestCase):
)
self.assertTrue(raised.exception.execution_id)
def test_dataflow_dataset_can_pin_an_exact_published_run(self) -> None:
def test_dataflow_run_can_be_exported_as_formula_safe_csv(self) -> None:
payload = dataset_payload()
rows = list(payload.pop("static_rows"))
payload.update(
@@ -410,7 +410,7 @@ class ReportingServiceTests(unittest.TestCase):
report_id="report-1",
report_revision=1,
parameters={},
query=None,
query=ReportQuery(mode="detail", dimensions=["note"]),
idempotency_key="published-dataflow-run",
)
@@ -421,6 +421,15 @@ class ReportingServiceTests(unittest.TestCase):
provider.last_request.run_ref,
)
self.assertTrue(result["provenance"]["source"]["immutable_run"])
content, content_type, filename = export_execution(
self.session,
self.principal,
execution_id=str(result["execution_id"]),
format="csv",
)
self.assertEqual("text/csv; charset=utf-8", content_type)
self.assertTrue(filename.endswith(".csv"))
self.assertIn("'=cmd", content.decode("utf-8-sig"))
def test_restricted_access_and_service_scope_guards(self) -> None:
self._create_report_graph(
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/reporting-webui",
"version": "0.1.17",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -17,7 +17,7 @@
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.17",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2
View File
@@ -10,6 +10,8 @@ assert.ok(page.includes("PageScrollViewport"), "Reporting owns bounded catalogue
assert.ok(page.includes("DataGrid"), "Tabular report results use the shared grid");
assert.ok(page.includes("<Dialog"), "Save and schedule operations use shared dialogs");
assert.ok(page.includes("createDrillContext"), "Aggregate detail uses an actor-bound drill context");
assert.match(page, /id: "drill",\s*header: "Detail",\s*columnType: "actions",\s*sticky: "end"/, "Result drill-down controls use the shared pinned action-column contract");
assert.match(page, /<TableActionGroup actions=\{\[\{\s*id: "drill"/, "Drill-down renders the shared measurable action surface");
assert.ok(page.includes("AccessExplanation"), "Policy-hidden fields, rows, and actions are explained");
assert.ok(page.includes("PublishDialog"), "Publication targets use the shared dialog surface");
assert.ok(provider.includes("disabledReason={runDisabledReason}"), "Governed report blockers remain keyboard-explainable");
@@ -1,12 +1,15 @@
import { DescriptionList } from "@govoplan/core-webui";
import { Download, FileJson, Play, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
import { ContentGrid,
Button,
Card,
DismissibleAlert,
IconButton,
MetricCard,
StatePanel,
StatusBadge,
WorkspaceActionBar,
hasScope,
type ApiSettings,
type AuthInfo
@@ -174,20 +177,25 @@ export function ProviderReportWorkspace({ settings, auth, report }: {
</div>
{execution ?
<>
<div className="reporting-output-toolbar">
<span>Generated {formatDateTime(execution.generated_at)}</span>
{report.export_formats.includes("csv") &&
<WorkspaceActionBar
scope="detail-pane"
variant="detail"
className="reporting-output-toolbar"
contextActions={<span>Generated {formatDateTime(execution.generated_at)}</span>}
primaryActions={<>
{report.export_formats.includes("csv") &&
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void download("csv")} />
}
{report.export_formats.includes("json") &&
}
{report.export_formats.includes("json") &&
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void download("json")} />
}
</div>
}
</>}
/>
<div className="reporting-provider-output">
<ProviderResult execution={execution} />
</div>
</> :
<div className="reporting-empty">Select the parameters and run this governed report.</div>
<StatePanel size="fill" description="Select the parameters and run this governed report." />
}
</>
);
@@ -227,33 +235,33 @@ function ProviderResult({ execution }: { execution: ProviderReportExecution }) {
return (
<Card title={group} key={group}>
{metrics.length > 0 &&
<div className="dashboard-grid reporting-provider-metrics">
<ContentGrid columns={2} collapseAt="workspace" className="reporting-provider-metrics">
{metrics.map((field) =>
<MetricCard key={field.path} label={field.label} value={displayValue(pathValue(execution.result, field.path))} />
)}
</div>
</ContentGrid>
}
{details.length > 0 &&
<dl className="detail-list">
<DescriptionList variant="inline">
{details.map((field) =>
<div key={field.path}>
<dt>{field.label}</dt>
<dd>{displayValue(pathValue(execution.result, field.path), field)}</dd>
</div>
)}
</dl>
</DescriptionList>
}
</Card>
);
})}
<Card title="Provenance">
<dl className="detail-list">
<DescriptionList variant="inline">
<div><dt>Purpose</dt><dd>{execution.purpose}</dd></div>
<div><dt>Output hash</dt><dd title={execution.output_hash}>{shortHash(execution.output_hash)}</dd></div>
<div><dt>Source revisions</dt><dd>{execution.source_revisions.length}</dd></div>
<div><dt>Privacy transforms</dt><dd>{execution.privacy_transforms.join(", ")}</dd></div>
<div><dt>Expires</dt><dd>{execution.expires_at ? formatDateTime(execution.expires_at) : "Policy managed"}</dd></div>
</dl>
</DescriptionList>
</Card>
</>
);
+67 -56
View File
@@ -7,7 +7,6 @@ import {
FolderOutput,
History,
Play,
RefreshCw,
Save,
Search,
SlidersHorizontal,
@@ -19,19 +18,27 @@ import {
useState,
type FormEvent
} from "react";
import {
import { FormGrid,
Button,
DataGrid,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FilterBar,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatePanel,
SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatusBadge,
TableActionGroup,
ToggleSwitch,
hasScope,
WorkspaceActionBar,
WorkspaceFrame,
type DataGridColumn,
type PlatformRouteContext
} from "@govoplan/core-webui";
@@ -249,9 +256,15 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
return (
<main className="reporting-page">
<div className="reporting-shell">
<div className="reporting-toolbar">
<form className="reporting-search" onSubmit={submitSearch}>
<WorkspaceFrame className="reporting-shell" label="Reporting workspace" interfaceId="reporting.workspace" helpContextId="reporting.page.workspace" helpModuleId="reporting">
<WorkspaceActionBar
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void reload(), loading, label: "Reload reports" }}
className="reporting-toolbar"
contextActions={<>
<FilterBar as="form" surface="control" wrap="never" width="compact" className="reporting-search" onSubmit={submitSearch}>
<Search size={17} aria-hidden="true" />
<input
value={search}
@@ -259,19 +272,14 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
aria-label="Search reports"
placeholder="Search reports"
/>
</form>
<span className="reporting-count">{reports.length + providerReports.length} reports</span>
<DocumentationHelpLink
</FilterBar>
<span className="reporting-count">{reports.length + providerReports.length} reports</span>
</>}
helpAction={<DocumentationHelpLink
reference={{ topicId: "reporting.governed-bi", documentationType: "user" }}
label="Open reporting documentation"
/>
<IconButton
label="Reload reports"
icon={<RefreshCw size={17} />}
variant="ghost"
onClick={() => void reload()}
/>
</div>
/>}
/>
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
@@ -280,38 +288,32 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
<div className="reporting-workspace">
<PageScrollViewport className="reporting-catalogue">
{loading && <LoadingIndicator label="Loading reports" />}
{!loading && reports.length + providerReports.length === 0 && <div className="reporting-empty">No active reports are available.</div>}
<div className="reporting-report-list" role="list">
{providerReports.length > 0 && <div className="reporting-list-heading">Module reports</div>}
{!loading && reports.length + providerReports.length === 0 && <StatePanel size="compact" description="No active reports are available." />}
<SelectionList variant="navigation" label="Reports">
{providerReports.length > 0 && <div className="reporting-list-heading" role="presentation">Module reports</div>}
{providerReports.map((item) => {
const key = `${item.provider_id}:${item.report_id}`;
return (
<button
type="button"
role="listitem"
<SelectionListItem
key={key}
className={`reporting-report-row${selectedProviderKey === key ? " is-selected" : ""}`}
selected={selectedProviderKey === key}
onClick={() => { setSelectedProviderKey(key); setSelectedId(""); }}>
<BarChart3 size={17} aria-hidden="true" />
<span><strong>{item.title}</strong><small>{item.provider_id} · {item.revision}</small></span>
<SelectionListItemContent leading={<BarChart3 size={17} />} title={item.title} description={`${item.provider_id} · ${item.revision}`} />
<StatusBadge status={item.available ? "active" : "locked"} label={item.available ? "Available" : "Restricted"} />
</button>
</SelectionListItem>
);
})}
{reports.length > 0 && <div className="reporting-list-heading">Semantic reports</div>}
{reports.length > 0 && <div className="reporting-list-heading" role="presentation">Semantic reports</div>}
{reports.map((item) =>
<button
type="button"
role="listitem"
<SelectionListItem
key={item.definition_id}
className={`reporting-report-row${selectedId === item.definition_id ? " is-selected" : ""}`}
selected={selectedId === item.definition_id}
onClick={() => { setSelectedId(item.definition_id); setSelectedProviderKey(""); }}>
<BarChart3 size={17} aria-hidden="true" />
<span><strong>{item.name}</strong><small>{item.definition_key} · r{item.revision}</small></span>
<SelectionListItemContent leading={<BarChart3 size={17} />} title={item.name} description={`${item.definition_key} · r${item.revision}`} />
<StatusBadge status="active" label="Active" />
</button>
</SelectionListItem>
)}
</div>
</SelectionList>
</PageScrollViewport>
<section className="reporting-result-region">
{selectedProvider ?
@@ -345,8 +347,11 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
onQueryChange={setQuery}
onParametersChange={setParameters}
/>
<div className="reporting-output-toolbar">
<SegmentedControl
<WorkspaceActionBar
scope="detail-pane"
variant="detail"
className="reporting-output-toolbar"
contextActions={<SegmentedControl
ariaLabel="Report output"
value={outputMode}
onChange={setOutputMode}
@@ -354,25 +359,25 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
{ id: "visual", label: <><BarChart3 size={15} /> Visual</> },
{ id: "table", label: <><Table2 size={15} /> Table</> }
]}
/>
{execution &&
/>}
primaryActions={execution ?
<>
<span>{execution.total_rows} rows{execution.truncated ? " (truncated)" : ""}</span>
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "csv").catch((reason) => setError(message(reason)))} />
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "json").catch((reason) => setError(message(reason)))} />
{canPublish &&
<IconButton label="Publish report" icon={<FolderOutput size={17} />} variant="ghost" onClick={() => setPublishDialogOpen(true)} />
<IconButton label="Publish report" helpContextId="reporting.publications" helpModuleId="reporting" icon={<FolderOutput size={17} />} variant="ghost" onClick={() => setPublishDialogOpen(true)} />
}
</>
}
</div>
: undefined}
/>
<div className="reporting-output">
{!execution && <div className="reporting-empty">Run the report or select a previous execution.</div>}
{!execution && <StatePanel size="fill" description="Run the report or select a previous execution." />}
{execution && outputMode === "visual" && <ReportVisual execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
{execution && outputMode === "table" && <ReportTable execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
</div>
</> :
<div className="reporting-empty">Select a report.</div>
<StatePanel size="fill" title="Reports" description="Select a report." />
}
</section>
<PageScrollViewport className="reporting-inspector">
@@ -398,7 +403,7 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
/>}
</PageScrollViewport>
</div>
</div>
</WorkspaceFrame>
<SaveViewDialog
open={saveDialogOpen}
onClose={() => setSaveDialogOpen(false)}
@@ -539,7 +544,7 @@ function ReportTable({ execution, onDrill }: { execution: ReportExecution; onDri
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [execution.execution_id]);
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => {
const result = execution.schema.map((field) => ({
const result: DataGridColumn<Record<string, unknown>>[] = execution.schema.map((field) => ({
id: field.name,
header: humanize(field.name),
width: "1fr",
@@ -555,16 +560,20 @@ function ReportTable({ execution, onDrill }: { execution: ReportExecution; onDri
result.push({
id: "drill",
header: "Detail",
columnType: "actions",
sticky: "end",
resizable: false,
align: "right",
width: 74,
minWidth: 74,
maxWidth: 74,
render: (row) => (
<IconButton
label="Show authorized contributing rows"
icon={<ChevronRight size={16} />}
variant="ghost"
onClick={() => onDrill(row)}
/>
<TableActionGroup actions={[{
id: "drill",
label: "Show authorized contributing rows",
icon: <ChevronRight size={16} aria-hidden="true" />,
onClick: () => onDrill(row)
}]} />
)
});
}
@@ -736,6 +745,8 @@ function PublishDialog({ open, targets, onClose, onPublish }: {
<Button onClick={onClose}>Cancel</Button>
<Button
variant="primary"
helpContextId="reporting.publications"
helpModuleId="reporting"
disabled={!valid || saving}
disabledReason={!target?.available ? target?.reason ?? "The selected target is unavailable." : undefined}
onClick={() => {
@@ -757,7 +768,7 @@ function PublishDialog({ open, targets, onClose, onPublish }: {
</Button>
</>}>
{dialogError && <DismissibleAlert tone="danger" resetKey={dialogError}>{dialogError}</DismissibleAlert>}
<div className="reporting-dialog-grid">
<FormGrid columns={2} gap="small" collapseAt="narrow">
<label className="reporting-dialog-field">
<span>Target</span>
<select value={targetCapability} onChange={(event) => {
@@ -784,7 +795,7 @@ function PublishDialog({ open, targets, onClose, onPublish }: {
<label className="reporting-dialog-field"><span>Sender address</span><input type="email" value={fromAddress} onChange={(event) => setFromAddress(event.target.value)} /></label>
<label className="reporting-dialog-field reporting-dialog-span"><span>Subject</span><input value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Generated from report name" /></label>
</>}
</div>
</FormGrid>
{target?.reason && <DismissibleAlert tone="warning" dismissible={false}>{target.reason}</DismissibleAlert>}
</Dialog>
);
@@ -936,10 +947,10 @@ function ScheduleDialog({ open, onClose, onSave }: { open: boolean; onClose: ()
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={!name.trim() || saving} onClick={() => { setSaving(true); void onSave(name.trim(), Number(interval)).finally(() => setSaving(false)); }}>Schedule</Button>
</>}>
<div className="reporting-dialog-grid">
<FormGrid columns={2} gap="small" collapseAt="narrow">
<label className="reporting-dialog-field"><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
<label className="reporting-dialog-field"><span>Interval</span><select value={interval} onChange={(event) => setIntervalValue(event.target.value)}><option value="3600">Hourly</option><option value="86400">Daily</option><option value="604800">Weekly</option><option value="2592000">Every 30 days</option></select></label>
</div>
</FormGrid>
</Dialog>
);
}
+21 -100
View File
@@ -1,19 +1,10 @@
.reporting-page,
.reporting-shell {
.reporting-page {
height: 100%;
min-height: 0;
overflow: hidden;
}
.reporting-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.reporting-toolbar,
.reporting-result-header,
.reporting-output-toolbar {
.reporting-result-header {
display: flex;
align-items: center;
gap: 10px;
@@ -21,21 +12,8 @@
background: var(--surface-raised);
}
.reporting-toolbar {
min-height: 56px;
padding: 9px 14px;
}
.reporting-search {
display: flex;
align-items: center;
gap: 8px;
width: min(440px, 46vw);
}
.reporting-search input {
min-width: 140px;
flex: 1;
flex: 1 1 440px;
}
.reporting-count {
@@ -66,11 +44,10 @@
border-left: 1px solid var(--border);
}
.reporting-report-list,
.reporting-inspector-content section {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius-compact);
background: var(--surface-raised);
}
@@ -84,7 +61,6 @@
text-transform: uppercase;
}
.reporting-report-row,
.reporting-inspector-content section > button {
display: grid;
align-items: center;
@@ -97,45 +73,15 @@
cursor: pointer;
}
.reporting-report-row {
grid-template-columns: 22px minmax(0, 1fr) auto;
gap: 8px;
min-height: 58px;
padding: 8px 10px;
}
.reporting-report-row:last-child,
.reporting-inspector-content section > button:last-child {
border-bottom: 0;
}
.reporting-report-row:hover,
.reporting-report-row:focus-visible,
.reporting-report-row.is-selected,
.reporting-inspector-content section > button:hover,
.reporting-inspector-content section > button.is-selected {
background: var(--hover-bg);
}
.reporting-report-row.is-selected {
box-shadow: inset 3px 0 0 var(--accent);
}
.reporting-report-row > span:nth-child(2) {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.reporting-report-row strong,
.reporting-report-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reporting-report-row small,
.reporting-inspector-content small,
.reporting-inspector-content p {
color: var(--text-soft);
@@ -211,7 +157,7 @@
margin: 0;
padding: 4px 8px 7px;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius-compact);
}
.reporting-query-controls legend,
@@ -251,11 +197,6 @@
min-width: min(320px, 35vw);
}
.reporting-output-toolbar {
min-height: 48px;
padding: 6px 12px;
}
.reporting-output-toolbar > span {
margin-left: auto;
color: var(--text-soft);
@@ -288,12 +229,6 @@
min-width: 100%;
}
.reporting-empty {
padding: 38px 12px;
color: var(--text-soft);
text-align: center;
}
.reporting-inspector-content {
display: flex;
flex-direction: column;
@@ -402,7 +337,7 @@
display: block;
width: 72%;
margin: 0 auto;
border-radius: 3px 3px 0 0;
border-radius: var(--radius-tight) var(--radius-tight) 0 0;
background: var(--accent);
}
@@ -411,7 +346,7 @@
height: 280px;
overflow: visible;
border: 1px solid var(--border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--surface-raised);
}
@@ -447,7 +382,7 @@
gap: 6px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius-compact);
background: var(--surface-raised);
}
@@ -469,7 +404,7 @@
width: min(100%, 280px);
aspect-ratio: 1;
margin: 0 auto;
border-radius: 50%;
border-radius: var(--radius-round);
box-shadow: inset 0 0 0 1px var(--border);
}
@@ -495,17 +430,17 @@
.reporting-swatch {
width: 10px;
height: 10px;
border-radius: 2px;
background: #2f7d6e;
border-radius: var(--radius-hairline);
background: var(--data-series-1);
}
.reporting-swatch-1 { background: #3366a8; }
.reporting-swatch-2 { background: #c28b2c; }
.reporting-swatch-3 { background: #9a4f71; }
.reporting-swatch-4 { background: #5f7f3a; }
.reporting-swatch-5 { background: #b85c3b; }
.reporting-swatch-6 { background: #586176; }
.reporting-swatch-7 { background: #2e8b9a; }
.reporting-swatch-1 { background: var(--data-series-2); }
.reporting-swatch-2 { background: var(--data-series-3); }
.reporting-swatch-3 { background: var(--data-series-4); }
.reporting-swatch-4 { background: var(--data-series-5); }
.reporting-swatch-5 { background: var(--data-series-6); }
.reporting-swatch-6 { background: var(--data-series-7); }
.reporting-swatch-7 { background: var(--data-series-8); }
.reporting-bar-row {
display: grid;
@@ -519,7 +454,7 @@
height: 22px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--surface-subtle, var(--surface));
}
@@ -600,7 +535,7 @@
.reporting-drill-path span {
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--surface-subtle, var(--surface));
font-size: 0.76rem;
}
@@ -611,12 +546,6 @@
overflow: auto;
}
.reporting-dialog-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 1100px) {
.reporting-workspace {
grid-template-columns: minmax(220px, 28%) minmax(0, 1fr);
@@ -638,16 +567,8 @@
border-bottom: 1px solid var(--border);
}
.reporting-result-header,
.reporting-toolbar {
.reporting-result-header {
flex-wrap: wrap;
}
.reporting-search {
width: 100%;
}
.reporting-dialog-grid {
grid-template-columns: 1fr;
}
}