3 Commits
Author SHA1 Message Date
zemion 8253bd0dd6 refactor(reporting): reuse exact principal projection contract
Module Package Release / publish-packages (push) Successful in 15s
Release v0.1.22. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:40 +02:00
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
12 changed files with 178 additions and 34 deletions
+18
View File
@@ -67,3 +67,21 @@ are covered by [docs/USER_GUIDE.md](docs/USER_GUIDE.md) and
The Reporting route, workspace, state, consequence, and accessibility mapping The Reporting route, workspace, state, consequence, and accessibility mapping
is recorded in is recorded in
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md). [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.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/reporting-webui",
"version": "0.1.22",
"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"
]
}
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-reporting" name = "govoplan-reporting"
version = "0.1.20" version = "0.1.22"
description = "GovOPlaN governed reporting and semantic BI module." description = "GovOPlaN governed reporting and semantic BI module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { text = "AGPL-3.0-or-later" } license = { text = "AGPL-3.0-or-later" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.37", "govoplan-core>=0.1.46",
"govoplan-access>=0.1.18", "govoplan-access>=0.1.18",
] ]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN Reporting module.""" """GovOPlaN Reporting module."""
__version__ = "0.1.20" __version__ = "0.1.22"
+1 -16
View File
@@ -10,6 +10,7 @@ import uuid
from sqlalchemy import and_, exists, func, or_ from sqlalchemy import and_, exists, func, or_
from sqlalchemy.orm import Query, Session from sqlalchemy.orm import Query, Session
from govoplan_core.core.principal_helpers import principal_actor_ids as _actor_ids
from govoplan_core.core.events import ( from govoplan_core.core.events import (
EventActorRef, EventActorRef,
EventObjectRef, EventObjectRef,
@@ -884,22 +885,6 @@ def _actor(principal: object) -> str | None:
return None return None
def _actor_ids(principal: object) -> tuple[str, ...]:
user = getattr(principal, "user", None)
return tuple(
dict.fromkeys(
str(value)
for value in (
getattr(principal, "account_id", None),
getattr(principal, "identity_id", None),
getattr(principal, "membership_id", None),
getattr(user, "id", None),
)
if str(value or "").strip()
)
)
def _principal_tenant(principal: object) -> str: def _principal_tenant(principal: object) -> str:
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id: if not tenant_id:
+6 -3
View File
@@ -83,7 +83,7 @@ from govoplan_reporting.backend.search_source import create_reporting_search_sou
MODULE_ID = "reporting" MODULE_ID = "reporting"
MODULE_NAME = "Reporting" MODULE_NAME = "Reporting"
MODULE_VERSION = "0.1.20" MODULE_VERSION = "0.1.22"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -603,8 +603,10 @@ manifest = ModuleManifest(
"aggregations, typed expressions, filters, pivots, saved views, chart models, " "aggregations, typed expressions, filters, pivots, saved views, chart models, "
"schedules, exports, and publication providers replace unchecked SQL in the " "schedules, exports, and publication providers replace unchecked SQL in the "
"presentation layer. PostgreSQL executes bounded semantic plans when available. " "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 " "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", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -663,12 +665,13 @@ manifest = ModuleManifest(
"Filter, Pivotierungen, gespeicherte Ansichten, Diagrammmodelle, Zeitpläne, " "Filter, Pivotierungen, gespeicherte Ansichten, Diagrammmodelle, Zeitpläne, "
"Exporte und Veröffentlichungsanbieter ersetzen ungeprüftes SQL in der " "Exporte und Veröffentlichungsanbieter ersetzen ungeprüftes SQL in der "
"Darstellungsschicht. PostgreSQL führt begrenzte semantische Pläne aus, sofern " "Darstellungsschicht. PostgreSQL führt begrenzte semantische Pläne aus, sofern "
"verfügbar. Signierte Drilldown-Kontexte autorisieren beitragende Zeilen erneut; " "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 " "Adapter für Dateien und Mail bewahren idempotente Nachweise. Ein Datensatz kann "
"genau eine erfolgreiche veröffentlichte Dataflow-Ausführung fixieren, die nach " "genau eine erfolgreiche veröffentlichte Dataflow-Ausführung fixieren, die nach "
"erneuter Autorisierung beider Quellgrenzen aus ihrer exakten Datasource-" "erneuter Autorisierung beider Quellgrenzen aus ihrer exakten Datasource-"
"Materialisierung gelesen wird. Dataflow und die Lesemodelle der Module bleiben " "Materialisierung gelesen wird. Dataflow und die Lesemodelle der Module bleiben "
"führende Quellen." "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."
), ),
} }
}, },
@@ -344,7 +344,9 @@ def _calculated_sql(
return _calculated_sql( return _calculated_sql(
target.expression, target.expression,
parameters, 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, measures=measures,
stack=(*stack, reference), stack=(*stack, reference),
) )
+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()
+1 -1
View File
@@ -92,7 +92,7 @@ class _Provider:
del session del session
return ReportProviderResult( return ReportProviderResult(
report_id=request.report_id, report_id=request.report_id,
generated_at=datetime(2026, 8, 2, 10, 0, tzinfo=UTC), generated_at=datetime.now(UTC),
payload={"metric": 12}, payload={"metric": 12},
source_revisions=( source_revisions=(
{ {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/reporting-webui", "name": "@govoplan/reporting-webui",
"version": "0.1.20", "version": "0.1.22",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -17,7 +17,7 @@
"test:interface-pattern": "node scripts/test-interface-pattern.mjs" "test:interface-pattern": "node scripts/test-interface-pattern.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+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("DataGrid"), "Tabular report results use the shared grid");
assert.ok(page.includes("<Dialog"), "Save and schedule operations use shared dialogs"); 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.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("AccessExplanation"), "Policy-hidden fields, rows, and actions are explained");
assert.ok(page.includes("PublishDialog"), "Publication targets use the shared dialog surface"); assert.ok(page.includes("PublishDialog"), "Publication targets use the shared dialog surface");
assert.ok(provider.includes("disabledReason={runDisabledReason}"), "Governed report blockers remain keyboard-explainable"); assert.ok(provider.includes("disabledReason={runDisabledReason}"), "Governed report blockers remain keyboard-explainable");
+12 -7
View File
@@ -34,6 +34,7 @@ import { FormGrid,
SelectionListItem, SelectionListItem,
SelectionListItemContent, SelectionListItemContent,
StatusBadge, StatusBadge,
TableActionGroup,
ToggleSwitch, ToggleSwitch,
hasScope, hasScope,
WorkspaceActionBar, WorkspaceActionBar,
@@ -543,7 +544,7 @@ function ReportTable({ execution, onDrill }: { execution: ReportExecution; onDri
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
useEffect(() => setPage(0), [execution.execution_id]); useEffect(() => setPage(0), [execution.execution_id]);
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => { 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, id: field.name,
header: humanize(field.name), header: humanize(field.name),
width: "1fr", width: "1fr",
@@ -559,16 +560,20 @@ function ReportTable({ execution, onDrill }: { execution: ReportExecution; onDri
result.push({ result.push({
id: "drill", id: "drill",
header: "Detail", header: "Detail",
columnType: "actions",
sticky: "end",
resizable: false,
align: "right",
width: 74, width: 74,
minWidth: 74, minWidth: 74,
maxWidth: 74, maxWidth: 74,
render: (row) => ( render: (row) => (
<IconButton <TableActionGroup actions={[{
label="Show authorized contributing rows" id: "drill",
icon={<ChevronRight size={16} />} label: "Show authorized contributing rows",
variant="ghost" icon: <ChevronRight size={16} aria-hidden="true" />,
onClick={() => onDrill(row)} onClick: () => onDrill(row)
/> }]} />
) )
}); });
} }