15 Commits
Author SHA1 Message Date
zemion f930a6cff0 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:52 +02:00
zemion 323963c49d Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:13 +02:00
zemion a6bde385ca Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:31 +02:00
zemion 101eafbcf6 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 12s
2026-08-04 15:10:27 +02:00
zemion fc8cf4b914 Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion 4c192c1a2f Harden module package publication 2026-08-04 14:02:41 +02:00
zemion 4e0238f701 Pin reporting datasets to published Dataflow runs 2026-08-04 12:48:37 +02:00
zemion aebe94ecc2 Add protected package release workflow 2026-08-04 04:14:07 +02:00
zemion 72e86fa87d Correct Access package version floor 2026-08-04 04:13:50 +02:00
zemion 8337aec19a Complete governed reporting execution and publication 2026-08-04 02:23:24 +02:00
zemion a81151391b Document reporting interface patterns 2026-08-04 01:50:14 +02:00
zemion 0abcc2455e feat: add governed cross-module reporting 2026-08-02 05:29:38 +02:00
zemion eba35edd3c feat: implement governed reporting vertical 2026-08-01 17:48:39 +02:00
zemion 720959536b docs(reporting): assess SuperX capability parity 2026-07-30 18:19:22 +02:00
zemion 858f9831ad Release v0.1.8 2026-07-11 16:49:04 +02:00
51 changed files with 14976 additions and 22 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+44 -4
View File
@@ -1,12 +1,52 @@
# GovOPlaN Reporting # GovOPlaN Reporting
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`govoplan-reporting` owns report definitions, BI-style views, dashboards, `govoplan-reporting` owns report definitions, BI-style views, dashboards,
scheduled outputs, and report publication/export behavior. It is intentionally scheduled outputs, and report publication/export behavior. It is intentionally
separate from reusable template rendering and from generic dataflow pipelines. separate from reusable template rendering and from generic dataflow pipelines.
This repository is currently a tag-only scaffold. It should gain package The module now provides an executable governed semantic-reporting vertical:
metadata and module manifests only after the first backend or WebUI slice is
designed. - immutable, optimistic-concurrency guarded dataset, semantic-model, report,
and quality-plan revisions;
- exact source and definition pins, tenant isolation, normalized access
grants, row-policy handoff, freshness checks, and reconstructable run
provenance;
- safe dimensions, hierarchies, measures, typed calculations, filters,
detail/summary/pivot queries, parameterized PostgreSQL semantic plans, and
accessible chart models without executing arbitrary report SQL;
- quality gates, saved views, interval/scheduled runs, CSV/JSON export,
provider-neutral publication targets, and import activation assessments;
- a versioned, provider-neutral cross-module report contract with source-owned
authorization, declared result schemas, privacy transforms, effective scope,
source revisions, purpose, retention, export history, and audit provenance;
- a full-height Reporting workspace for running, inspecting, saving,
scheduling, visualizing, drilling into reauthorized contributors, publishing
through Files/Mail, and exporting authorized reports;
- a configurable Dashboard widget and explicit policy explanations for hidden
fields, rows, and actions.
Reporting consumes Dataflow outputs or provider-owned read models. It does not
read another module's ORM tables or take ownership of ingestion and
transformation.
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`.
See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary
decision. decision. The behavior-level comparison with the supplied SuperX module set is
recorded in
[docs/SUPERX_CAPABILITY_ASSESSMENT.md](docs/SUPERX_CAPABILITY_ASSESSMENT.md).
Operational and recovery behavior is documented in
[docs/OPERATIONS.md](docs/OPERATIONS.md), while user and administrator tasks
are covered by [docs/USER_GUIDE.md](docs/USER_GUIDE.md) and
[docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
The Reporting route, workspace, state, consequence, and accessibility mapping
is recorded in
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
+106
View File
@@ -0,0 +1,106 @@
# Reporting Administration Guide
## Definition graph
Reporting definitions form an exact graph:
```text
Dataset revision -> Semantic-model revision -> Report revision
-> Quality-plan revision
```
Create parents before children. An active child may reference only an active,
existing parent revision. Editing creates a new immutable revision and
requires the currently observed revision number. Existing runs continue to
reference the historical revisions they used.
Each definition also records system, tenant, group, or user governance scope,
whether it is inherited, and whether lower scopes may run, reuse, or automate
it. A child may tighten but never broaden any effective ancestor limit. System
definitions require system governance permission; tenant definitions are bound
to the active tenant; group and user definitions require the matching subject
unless a Reporting administrator performs the operation. Policy is consulted
for view, edit, run, reuse, and automation decisions.
Datasets may bind a static fixture, a pinned Dataflow output, or a capability
published by a source-owning module. Do not expose another module's ORM or an
unbounded SQL connection as a report source. Configure an explicit schema,
freshness policy, source fingerprint expectations, purpose, privacy,
retention, and a row-policy provider where source access alone is not enough.
For a Dataflow source, `source_run_ref` optionally pins one successful run that
published an immutable Datasource materialization. Reporting passes this pin
through rather than re-executing the pipeline. The current principal must still
be authorized for the Dataflow definition and the exact Datasource output.
On PostgreSQL installations, Reporting compiles bounded semantic filters,
grouping, measures, calculated measures, ordering, offsets, and limits into a
parameterized PostgreSQL plan over the already authorized provider rows. Field
paths and values are bound parameters and result limits remain mandatory. Pivot
plans retain the safe provider-neutral engine fallback. SQLite development and
other database engines use the same typed semantics through the bounded runtime
engine.
## Access and publication
Tenant-visible definitions are readable by principals with Reporting read
permission. Restricted reports use normalized account, identity, group, role,
function, assignment, organization-unit, or service-account grants. The
creator and Reporting administrators retain management access.
Scheduled output publication requires an installed capability implementing
the Reporting publication-target contract. The target receives one immutable
execution payload and an idempotency key. It must return bounded evidence and
must not expose credentials in that evidence.
Reporting ships two optional adapters. `reporting.publication.files` calls
`files.artifact_store` and stores an idempotent managed artifact with execution,
revision, output-hash, and file-version evidence. `reporting.publication.mail`
calls `mail.notificationDelivery` and submits an idempotent report notice to the
Mail outbox. The latter does not bypass Mail profile, credential, or transport
policy. Adapter availability is evaluated at runtime, so Reporting remains
usable with neither Files nor Mail installed.
Drill contexts expire after 20 minutes, are bound to the creating account, store
only token and context hashes, and must match the original execution output and
source fingerprints. Resolution re-runs definition and row-level authorization.
Treat a fingerprint mismatch as a required report rerun, not as a recoverable
client warning.
## Cross-module provider governance
Source modules register `reporting.report_provider.<provider-id>` capabilities;
do not grant Reporting direct table access to those modules. Review every
descriptor's result schema, required privacy transforms, retention class,
export formats, and re-identification risk before enabling it in production.
Provider authorization remains mandatory even when the Reporting role allows
the user to run reports.
When Policy is enabled, configure `reporting_governance_policy` in system or
tenant settings. Tenant settings may only tighten the system result. Supported
fields are:
```json
{
"reporting_governance_policy": {
"allow_exports": true,
"allowed_export_formats": ["json"],
"allow_high_reidentification_risk": false,
"max_retention_days": 30,
"required_privacy_transforms": ["small_cell_suppression"]
}
}
```
Malformed explicit policy fails closed. The ordinary privacy-retention setting
`stored_report_detail_retention_days` is an additional ceiling. A tenant cannot
re-enable an export format, high-risk report, or longer retention period that
the system policy denied.
## Import assessments
Import assessment accepts declarative metadata only. Native datasets,
dimensions, hierarchies, measures, parameters, tables, pivots, charts,
quality assertions, and saved views map exactly. Provider-specific formatting,
dialect functions, and dashboard layouts require explicit approximation
acceptance. Raw SQL, procedures, scripts, implicit authorization, unchecked
functions, and unknown features block activation.
+29
View File
@@ -0,0 +1,29 @@
# Reporting Interface Pattern Migration
Reporting is a list-detail analytical workspace. It owns report semantics,
policy-aware result shaping, drill-through presentation, rendering, and
publication. Dataflow owns generic validation, typed expressions, schema
propagation, SQL compilation, and execution; Datasources and provider modules
own source access.
| Surface | Task and archetype | Consequence and state contract |
| --- | --- | --- |
| `/reports` | Report catalogue and list-detail workspace | The catalogue keeps selected report context while parameters, results, history, and provenance change. `/reporting` is a compatibility route only. |
| Semantic report controls | Parameterized analytical query | Dimensions, measures, parameters, and pivot shape become a validated Dataflow-backed plan. Reporting does not execute unchecked presentation SQL. |
| Provider report controls | Governed cross-module report | Purpose, audience, permission, availability, privacy transforms, source revisions, and retention remain visible. A blocked Run action stays present with a keyboard-focusable reason. |
| Results, history, and export | Monitoring/reporting evidence | Loading, empty, failed, truncated, successful, historical, and provider-partial states remain distinguishable. Tables are the accessible fallback for visual results. |
| Save and schedule dialogs | Create/edit and asynchronous setup | Core dialogs retain focus and stable actions. Scheduling records a durable report definition/revision and does not imply immediate publication. |
Run, schedule, export, and publication are consequential actions. Backend
permissions remain authoritative; the WebUI mirrors them and explains disabled
actions without exposing protected rows. Optional Dataflow, Policy, Files,
Mail, Templates, Search, and Notifications integrations are capability-based.
The full-height three-region workspace collapses at narrow widths while keeping
the catalogue, result, and inspector in task order.
Verification:
- `npm run test:interface-pattern`
- Reporting service, provider-report, migration, manifest, and module-permutation tests
- the Core TypeScript graph, structural localization audit, theme check, module
permutations, and full-product bundle budget
+53
View File
@@ -0,0 +1,53 @@
# Reporting Operations, Security, and Recovery
## Runtime behavior
Reporting API nodes are stateless. Definitions, executions, schedules,
publication evidence, and import/quality evidence live in the shared platform
database. Dataset bytes remain with their source owner; large durable output
files belong in a publication provider backed by shared object storage.
Run and schedule idempotency keys make retries replay-safe. Schedule workers
claim bounded batches, retain success or failure state, and advance interval
time from the claimed occurrence. Providers must use the same tenant and
principal context and must enforce their source-side authorization.
Provider-report executions and exports are database evidence. Each execution
stores the exact provider/report revision, request hash, source revisions,
effective scope, applied privacy transforms, governance decision provenance,
generation actor/time, retention expiry, and output hash. Export rows retain
their own purpose, audience, actor/time, format, and hash. Expired provider
results are rejected by retrieval and export paths; run the platform privacy
retention process on schedule to remove or minimize expired stored detail in
accordance with the active Policy.
## Security controls
- The semantic engine never evaluates Python, browser code, raw SQL, stored
procedures, or imported runtime scripts.
- Definition and execution APIs require explicit module scopes and a
tenant-bound principal.
- Restricted access is normalized into grants and rechecked before definitions
or execution history are returned.
- Row-policy providers may only reduce a dataset; returning more rows than the
source read fails closed.
- Freshness, schema, fingerprints, quality gates, definition hashes, source
provenance, actor, and output hash are retained with every run.
- CSV formula injection is neutralized before direct download.
- Provider exceptions are isolated in catalogue diagnostics; an unavailable
optional provider cannot take down the global report catalogue.
- High re-identification-risk provider reports fail closed unless an installed
Policy capability explicitly permits them.
## Backup and restore
Back up the shared database and every object-storage publication target as one
recovery set. Before destructive module retirement, take and verify a database
snapshot; retirement removes definitions, result evidence, schedules, and
publication records. Restore the database first, then target object storage,
then reconcile provider publications by their idempotency/evidence references.
After restore, verify migrations, definition parent pins, schedule enablement,
execution counts and hashes, and publication target health before dispatching
due schedules. Do not claim successful recovery until an operator has run and
recorded the deployment-specific drill.
+115 -18
View File
@@ -15,6 +15,9 @@ Reporting owns:
- curated query/view inputs from modules, connectors, datasources, or - curated query/view inputs from modules, connectors, datasources, or
configuration packages configuration packages
- report execution history, generated output evidence, and scheduled runs - report execution history, generated output evidence, and scheduled runs
- institutional question, goal/obligation, owner, calculation-version,
freshness/quality, and decision/action-consumption references for each
material report or indicator
- report permissions and sharing rules - report permissions and sharing rules
- export targets such as file, DMS, mail, API, RSS/Atom publication, and - export targets such as file, DMS, mail, API, RSS/Atom publication, and
downstream connector handoff downstream connector handoff
@@ -28,13 +31,27 @@ Initial source categories:
- curated SQL/query views exposed through a controlled capability - curated SQL/query views exposed through a controlled capability
- connector-staged datasets and external references - connector-staged datasets and external references
- generated file/csv/xlsx inputs from files/connectors - generated file/csv/xlsx inputs from files/connectors
- future `govoplan-datasources` catalog entries, if that module is created - governed `govoplan-datasources` catalogue entries and immutable
- future `govoplan-dataflow` outputs, if that module is created materializations
- versioned `govoplan-dataflow` outputs and run lineage
Reporting should not reach into module ORM internals directly. Module-owned Reporting should not reach into module ORM internals directly. Module-owned
data must be exposed through capabilities, DTOs, events, read models, or data must be exposed through capabilities, DTOs, events, read models, or
controlled query views. controlled query views.
Every reproducible report definition should retain or resolve:
- the institutional question, obligation, goal, or service measure it serves;
- owner and responsible organization/function;
- datasource/materialization and Dataflow definition/run revisions;
- source freshness, quality state, known limitations, and calculation version;
- purpose, visibility, privacy, retention, and publication decisions;
- reports, decisions, controls, projects, or actions that consumed the result.
Reporting owns execution and presentation of the measure. Mandates, Services,
Projects, Risk Compliance, Decisions, and other domains retain ownership of
the referenced institutional concepts.
## Output Targets ## Output Targets
- dashboard/widget view - dashboard/widget view
@@ -53,24 +70,104 @@ Reporting does not own:
- reusable template rendering; that belongs to `govoplan-templates` - reusable template rendering; that belongs to `govoplan-templates`
- DMS lifecycle, collaborative editing, legal hold, or records management - DMS lifecycle, collaborative editing, legal hold, or records management
- raw file/blob storage and provider connectors - raw file/blob storage and provider connectors
- general-purpose ingestion/transformation pipeline ownership unless a future - ingestion, governed source identity, staging, or transformation pipeline
`govoplan-dataflow` module is justified ownership; those belong to Connectors, Datasources, and Dataflow
- cross-module search indexing; that belongs to `govoplan-search` - cross-module search indexing; that belongs to `govoplan-search`
## Candidate Capabilities ## Capability Contracts
- `reporting.catalog` - `reporting.registry` owns immutable definition registration and lookup.
- `reporting.run` - `reporting.runner` executes exact report graphs and returns governed result
- `reporting.schedules` evidence.
- `reporting.exports` - `reporting.scheduler` claims due schedules and preserves run/publication
- `reporting.readModels` outcomes.
- `reporting.dashboardContributions` - `reporting.chart_renderer` renders provider-neutral visual models with an
accessible table fallback.
- `reporting.publication.files` adapts immutable results to Core's
`files.artifact_store` boundary without importing Files internals.
- `reporting.publication.mail` adapts report notices to Core's
`mail.notificationDelivery` boundary without importing Mail internals.
- `reporting.read_model:*` capabilities can expose bounded source-owned rows.
- `reporting.publication_target:*` capabilities can accept immutable result
payloads without Reporting importing the target module.
## First Implementation Slice ### Cross-module report providers
1. Define manifest metadata, permissions, and capability names. Source modules contribute aggregate or otherwise minimized reports through
2. Add report definition and parameter DTOs. `reporting.report_provider.<provider-id>`. Core owns contract version `1.0` and
3. Add one read-only report source contract over module-provided summary DTOs. the provider-neutral DTOs. A provider must declare stable report and revision
4. Add export target DTOs without implementing every target. IDs, typed parameters, the complete result schema, privacy transforms,
5. Add tests that report consumers/producers use capability lookup rather than retention class, export formats, and re-identification risk. It must enforce
direct imports. source access before listing options or producing a result and must return
source revisions, effective tenant scope, applied transforms, generation time,
and bounded provenance.
Reporting discovers these capabilities by prefix. It validates descriptors,
rejects undeclared output fields, requires every mandatory transform, records
purpose and effective audience, and persists immutable execution and export
evidence. The optional `policy.reporting_governance` capability can tighten
retention, export formats, required transforms, and high-risk handling. If
Policy is absent, Reporting applies its restrictive built-in baseline and
denies high-risk provider reports.
This direction is intentionally one-way: source modules import only Core's
contract and remain usable when Reporting is absent. Reporting never imports
the source module or reads its ORM tables.
## Implemented Vertical
The first complete vertical persists datasets, semantic models, reports,
quality plans, grants, executions, saved views, schedules, publications,
quality results, and import assessments. Each definition update creates an
immutable revision and requires optimistic concurrency. Active child
definitions require the exact pinned parent revision to be active.
Execution resolves the report, semantic model, and dataset graph before
reading data. Static fixtures, Dataflow output, and provider-owned read models
share one bounded read contract. Source fingerprints, freshness, schema,
row-policy provenance, blocking quality plans, definition hashes, executor
version, output hash, diagnostics, and authorized rows are retained with the
execution. Failed runs also retain evidence.
The query layer deliberately implements a typed expression and semantic
query language rather than `eval`, arbitrary SQL, stored procedures, or
runtime scripts. PostgreSQL installations receive parameterized semantic plans
for filters, grouping, measures, calculated aggregates, sorting, and bounds;
other engines and pivots use the equivalent bounded runtime evaluator. It
supports detail, grouped summary, pivot, dimensions,
hierarchies, common aggregates, calculated measures, filters, sorting,
pagination, totals, and a provider-neutral visualization model. A saved chart
that is incompatible with an ad-hoc query degrades to its mandatory table
fallback instead of failing a valid report run.
Aggregate drill-through uses an expiring actor-bound context hash. Resolution
rechecks all definition and row-policy decisions, verifies the source
fingerprints against the original execution, preserves the complete dimension
path, and returns only authorized contributors.
Direct export supports UTF-8 CSV and JSON. CSV cells that spreadsheet software
could interpret as formulas are escaped. Additional formats and delivery
destinations use an optional publication capability and preserve idempotent
evidence. Import assessments classify exact, approximated, and unsupported
semantics and block activation until every approximation is accepted and no
unsupported executable behavior remains.
The WebUI uses the platform module loader and common controls. It exposes a
report catalogue, parameter and semantic-query controls, result visualization
and table views, accessible bar/column/line/area/pie/donut/metric charts,
drill-through, access explanations, history/provenance, saved views, schedule
management, Files/Mail publication management, downloads, and a Dashboard
widget contribution.
The global `/reports` route is owned only by Reporting. `/reporting` is a
documented compatibility path. Campaign's module-local aggregate view remains
at `/campaigns/reports`; when both modules are enabled, the same safe aggregate
projection is also contributed to the global catalogue through the provider
contract.
## Remaining Product Depth
The architecture boundary and first operational vertical are implemented.
Further work is additive product depth: packaged domain report catalogues,
XLSX/PDF formatting through optional renderer providers, selector-backed Mail
profile configuration, external publication connectors, and target-environment
evidence for a maturity claim above `vertical_slice`.
+150
View File
@@ -0,0 +1,150 @@
# SuperX Capability Assessment
## Scope
The supplied SuperX directory contains 22 PostgreSQL module archives:
`bau`, `cob`, `costage`, `erfolg`, `etl`, `fin`, `gang`, `gxstage`, `ivs`,
`kenn`, `kern`, `lm`, `man`, `msg`, `qa`, `rpta`, `sos`, `sva`, `sxc`, `viz`,
`xcube`, and `zul`.
This is a behavior and architecture assessment. It uses archive metadata and
representative definitions to identify product capabilities; no SuperX source
code or visual assets are copied.
## Functional Model
The supplied modules collectively provide five relevant layers:
1. **ETL orchestration:** parameterized jobs, ordered steps, dependencies,
load/select/native actions, continuation policy, and execution metadata.
2. **Domain data marts:** higher-education, finance, personnel, buildings,
applications, courses, success, and benchmark datasets with curated
reports.
3. **Semantic BI model:** cubes, dimensions, measures, hierarchies, totals,
labels, virtual calculated columns, and row/column arrangements.
4. **Presentation:** parameter masks, tables, pivots, charts, saved
visualizations, dashboard tabs, and formatted report products.
5. **Quality and operations:** assertions, comparison profiles, grouped
controls, execution logs, projects/issues, installation, and updates.
## GovOPlaN Coverage
| Capability | GovOPlaN state | Primary owner |
| --- | --- | --- |
| Governed source identity and frozen snapshots | Strong foundation | Datasources |
| External acquisition and credentials | Strong foundation | Connectors |
| Graph and SQL transformations | Strong foundation | Dataflow |
| Calculations, joins, ranking, aggregates | Available | Dataflow |
| Quality rules and keyed reconciliation | Available foundation | Dataflow |
| Durable scheduled/event/user runs | Available foundation | Dataflow/Core |
| Resumable approval and correction steps | Available foundation | Workflow |
| Scope inheritance and provenance | Available foundation | Policy/Core |
| Configurable dashboard widgets | Available foundation | Dashboard |
| Reusable parameterized subflows | Available foundation | Dataflow |
| BI dimensions, measures, and hierarchies | Available | Reporting |
| Pivot analytical view | Available; drill-through is product depth | Reporting |
| Parameterized report masks and saved views | Available | Reporting |
| Chart renderer/provider contract | Available with tabular fallback | Reporting |
| Scheduled report publication/export | Available through provider contract | Reporting |
| Report-specific access enforcement | Available; richer explanations are product depth | Reporting/Policy |
| Data-quality test plans and evidence | Available foundation | Dataflow/Reporting |
| Packaged domain report catalogues | Missing | Reporting plus domain modules |
GovOPlaN already has most platform primitives below the BI layer. The largest
gap is not another execution engine; it is a governed Reporting semantic layer
that turns curated Dataflow outputs and module read models into dimensions,
measures, parameterized views, pivots, charts, and publishable reports.
## Recommended Architecture
Reporting should consume only capability-published datasets or module read
models. It must not query another module's ORM tables directly.
The minimum durable contracts are:
- **Analytical dataset:** stable reference, schema, freshness, provenance,
scope, row-level policy hook, and supported query operations.
- **Semantic model:** dimensions, levels/hierarchies, measures, calculations,
labels, formats, totals, and default filters.
- **Report definition:** semantic-model revision, parameters, layout,
visualization, sort/filter state, and access policy.
- **Execution:** immutable definition revision, bound parameters, source
fingerprints, effective authorization, result summary, and diagnostics.
- **Publication:** target capability, format profile, retention, schedule,
delivery evidence, and idempotency key.
- **Contribution:** modules may announce report sources, semantic fragments,
templates, and dashboard widgets without Reporting importing them.
PostgreSQL should remain the first execution target. Optional analytical or
search engines can be adapters later; report definitions must not depend on
one engine's private syntax.
## Import Boundary
A future SuperX importer may read declarative metadata for sources,
parameters, dimensions, measures, hierarchies, layouts, and report catalogues.
It must produce native GovOPlaN definitions, never retain executable SQL or
stored procedures as an unchecked runtime escape hatch.
Every import result needs a machine-readable mapping report:
- source object and stable source identifier
- generated GovOPlaN object and revision
- exact, approximated, omitted, and unsupported semantics
- required manual bindings or Policy decisions
- source dialect/provider assumptions
- warnings for custom functions, procedural code, implicit authorization, and
presentation behavior that has no native equivalent
Unsupported semantics must block activation unless an administrator explicitly
accepts a documented approximation. Imported definitions remain ordinary
versioned GovOPlaN definitions after migration; the runtime must not depend on
the original SuperX installation.
## Delivery State
### Phase 1: Semantic Core - complete
- scaffold the Reporting module and manifest
- define analytical dataset, dimension, hierarchy, measure, parameter, and
report-definition schemas
- consume one Dataflow-published dataset through a capability
- enforce scope, Policy provenance, and source freshness
### Phase 2: Interactive BI - vertical complete
- parameter panel and saved views
- table, pivot, drill-down, totals, calculated measures, and export of the
currently authorized result
- stable URL/reference contract for report state
- dashboard contribution for saved reports
### Phase 3: Visualization And Publication - vertical complete
- chart renderer provider contract with an initial bounded chart catalogue
- scheduled runs and publication through Files, Mail, or another declared
target capability
- accessible tabular fallback and report-specific access explanations
### Phase 4: Quality And Domain Packs - foundation complete
- reusable report assertions and dataset comparison plans
- execution evidence and trend views
- versioned domain report packages owned by the relevant domain modules
- import tooling for metadata where licensing and semantic mapping permit it
Packaged domain catalogues and an importer that translates actual source
metadata remain product depth. The native contracts, quality evidence, and
blocking import assessment required to implement them are present.
## Product Boundary
Dataflow remains responsible for producing governed tabular datasets.
Reporting owns analytical semantics and presentation. Dashboard arranges
widgets but does not become the report engine. Workflow coordinates human
handoffs but does not own report execution. Templates format documents but do
not own report data or query semantics.
The assessment and its implementation split are tracked by
[`govoplan-reporting#3`](https://git.add-ideas.de/GovOPlaN/govoplan-reporting/issues/3).
+75
View File
@@ -0,0 +1,75 @@
# Reporting User Guide
## Open and run a report
Open **Reporting** at `/reports` and choose a report from the left catalogue.
The catalogue separates source-module reports from Reporting semantic reports.
The header identifies the immutable report revision that will be used. Select a
summary, detail, or pivot shape, choose dimensions and measures, enter any
report parameters, and select **Run**.
Only rows authorized for the current tenant and principal reach the query
engine. A successful result shows its authorized row count, visualization,
and table. When a saved chart does not match an ad-hoc query, Reporting shows
the accessible table fallback instead of changing or rejecting the query.
Summary and pivot rows expose a detail action. Selecting it creates a short-lived,
account-bound drill context, rechecks the report, semantic model, dataset, row
policy, and source fingerprint, and then displays only the authorized contributing
rows. The path above the table records every aggregate dimension used for the
drill. If the source changed, run the report again rather than treating stale
aggregate and detail states as equivalent.
## Inspect evidence
The right panel lists previous runs and definition/source pins. Select an
earlier run to inspect its retained result. Content and output hashes identify
the exact definition and output. Warnings explain freshness, inferred schema,
or provider diagnostics. A failed quality gate records a failed execution and
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.
The **Effective access** explanation states when dimensions, measures, source
rows, or actions were removed by Policy. A result with no hidden elements says
so explicitly; catalogue visibility never grants access to protected detail.
## Save and export
Use **Save current view** to keep the current query under your account. Saved
views do not alter the report definition. CSV and JSON downloads contain only
the authorized result of the selected execution. CSV values that begin with a
spreadsheet formula marker are escaped.
Users with scheduling permission can create an hourly, daily, weekly, or
30-day interval from the current revision, parameters, and query. Scheduled
runs continue to use those exact pins until the schedule is edited.
The Schedules panel can pause or resume each schedule with optimistic revision
checking. Users with publication permission can publish a successful execution
to Files or Mail. Files stores CSV, JSON, or accessible HTML through managed
artifact storage. Mail submits a bounded report notice through its durable
outbox and requires a usable profile, sender, and recipient. Unavailable targets
remain explained but cannot be selected as a valid destination. Publication
history records the target, result, time, output hash, and provider evidence.
When Dashboard is enabled, the **Reports** widget lists active reports without
copying result data into Dashboard. Its item limit is configurable per widget.
## Run a module report
Module reports retain their source module's access rules. Select the source
object and any optional parameters, enter the concrete purpose for the run,
and verify the effective tenant audience before selecting **Run**. Reporting
will refuse a result that does not carry the declared privacy transforms,
source revision, and tenant scope.
The result groups declared metrics and details without exposing undeclared
provider data. The Governance panel explains the risk class, permitted export
formats, retention ceiling, contract version, and required privacy transforms.
An export records a separate purpose, actor, audience, time, format, and output
hash. A missing or restricted module report is not replaced with a less
protected client-side query.
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-reporting"
version = "0.1.18"
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.18",
"govoplan-access>=0.1.18",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_reporting = ["py.typed"]
[project.entry-points."govoplan.modules"]
reporting = "govoplan_reporting.backend.manifest:get_manifest"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Reporting module."""
__version__ = "0.1.18"
@@ -0,0 +1 @@
"""Reporting backend package."""
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from govoplan_core.auth import has_scope
from govoplan_core.core.modules import AccessDecision
class ReportingScopeAclProvider:
def __init__(self, resource_type: str) -> None:
self.resource_type = resource_type
def can_read(self, principal: object, resource_id: str) -> bool:
del resource_id
return has_scope(principal, "reporting:definition:read")
def can_write(self, principal: object, resource_id: str) -> bool:
del resource_id
return has_scope(principal, "reporting:definition:write")
def explain(self, principal: object, resource_id: str) -> AccessDecision:
del resource_id
allowed = self.can_read(principal, "")
return AccessDecision(
allowed=allowed,
reason=None if allowed else "Missing scope: reporting:definition:read",
requirements=("reporting:definition:read",),
)
__all__ = ["ReportingScopeAclProvider"]
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_REPORTING_REGISTRY = "reporting.registry"
CAPABILITY_REPORTING_RUNNER = "reporting.runner"
CAPABILITY_REPORTING_SCHEDULER = "reporting.scheduler"
CAPABILITY_REPORTING_CHART_RENDERER = "reporting.chart_renderer"
CAPABILITY_REPORTING_PUBLICATION_FILES = "reporting.publication.files"
CAPABILITY_REPORTING_PUBLICATION_MAIL = "reporting.publication.mail"
@dataclass(frozen=True, slots=True)
class ReportingDatasetReadRequest:
source_ref: str
source_revision: int | None
parameters: Mapping[str, object] = field(default_factory=dict)
row_limit: int = 2_000
expected_definition_hash: str | None = None
expected_source_fingerprints: tuple[Mapping[str, object], ...] = ()
@dataclass(frozen=True, slots=True)
class ReportingDatasetReadResult:
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
output_hash: str
executor_version: str
definition_hash: str | None = None
source_fingerprints: tuple[Mapping[str, object], ...] = ()
diagnostics: tuple[Mapping[str, object], ...] = ()
generated_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class ReportingReadModelProvider(Protocol):
def read_dataset(
self,
session: object,
principal: object,
*,
request: ReportingDatasetReadRequest,
) -> ReportingDatasetReadResult: ...
@dataclass(frozen=True, slots=True)
class ReportingRowPolicyRequest:
dataset_id: str
dataset_revision: int
policy_ref: str
rows: tuple[Mapping[str, object], ...]
@dataclass(frozen=True, slots=True)
class ReportingRowPolicyResult:
rows: tuple[Mapping[str, object], ...]
decision_ref: str
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class ReportingRowPolicyProvider(Protocol):
def authorize_rows(
self,
session: object,
principal: object,
*,
request: ReportingRowPolicyRequest,
) -> ReportingRowPolicyResult: ...
@dataclass(frozen=True, slots=True)
class ReportingPublicationPayload:
publication_id: str
execution_id: str
tenant_id: str
report_id: str
report_revision: int
format: str
target_ref: str | None
rows: tuple[Mapping[str, object], ...]
schema: tuple[Mapping[str, object], ...]
output_hash: str
options: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class ReportingPublicationTarget(Protocol):
def publish_report(
self,
session: object,
principal: object,
*,
payload: ReportingPublicationPayload,
) -> Mapping[str, object]: ...
@runtime_checkable
class ReportingChartRenderer(Protocol):
def render(
self, *, visualization: object, result: object
) -> Mapping[str, object]: ...
def capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_REPORTING_CHART_RENDERER",
"CAPABILITY_REPORTING_PUBLICATION_FILES",
"CAPABILITY_REPORTING_PUBLICATION_MAIL",
"CAPABILITY_REPORTING_REGISTRY",
"CAPABILITY_REPORTING_RUNNER",
"CAPABILITY_REPORTING_SCHEDULER",
"ReportingChartRenderer",
"ReportingDatasetReadRequest",
"ReportingDatasetReadResult",
"ReportingPublicationPayload",
"ReportingPublicationTarget",
"ReportingReadModelProvider",
"ReportingRowPolicyProvider",
"ReportingRowPolicyRequest",
"ReportingRowPolicyResult",
"capability",
]
@@ -0,0 +1,27 @@
"""Reporting database models."""
from govoplan_reporting.backend.db.models import (
ReportingDefinitionGrant,
ReportingDefinitionIdentity,
ReportingDefinitionRevision,
ReportingDrillContext,
ReportingExecution,
ReportingImportAssessment,
ReportingPublication,
ReportingQualityResult,
ReportingSavedView,
ReportingSchedule,
)
__all__ = [
"ReportingDefinitionGrant",
"ReportingDefinitionIdentity",
"ReportingDefinitionRevision",
"ReportingDrillContext",
"ReportingExecution",
"ReportingImportAssessment",
"ReportingPublication",
"ReportingQualityResult",
"ReportingSavedView",
"ReportingSchedule",
]
+607
View File
@@ -0,0 +1,607 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class ReportingDefinitionIdentity(Base, TimestampMixin):
__tablename__ = "reporting_definition_identities"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"definition_kind",
"definition_id",
name="uq_reporting_definition_identity",
),
UniqueConstraint(
"tenant_id",
"definition_kind",
"definition_key",
name="uq_reporting_definition_key",
),
Index(
"ix_reporting_definition_catalog",
"tenant_id",
"definition_kind",
"definition_key",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
definition_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
definition_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
class ReportingDefinitionRevision(Base, TimestampMixin):
__tablename__ = "reporting_definition_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"definition_kind",
"definition_id",
"revision",
name="uq_reporting_definition_revision",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_reporting_definition_idempotency",
),
UniqueConstraint(
"tenant_id",
"event_id",
name="uq_reporting_definition_event",
),
Index(
"ix_reporting_definition_current",
"tenant_id",
"definition_kind",
"definition_id",
"superseded_at",
),
Index(
"ix_reporting_definition_parent",
"tenant_id",
"parent_kind",
"parent_id",
"parent_revision",
),
Index(
"ix_reporting_definition_list",
"tenant_id",
"definition_kind",
"status",
"recorded_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
identity_id: Mapped[str] = mapped_column(
ForeignKey("reporting_definition_identities.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
definition_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
definition_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("reporting_definition_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
parent_kind: Mapped[str | None] = mapped_column(
String(40), nullable=True, index=True
)
parent_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
parent_revision: Mapped[int | None] = mapped_column(Integer, nullable=True)
name: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
visibility: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
change_reason: Mapped[str] = mapped_column(String(1000), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
changed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
class ReportingExecution(Base, TimestampMixin):
__tablename__ = "reporting_executions"
__table_args__ = (
UniqueConstraint("tenant_id", "execution_id", name="uq_reporting_execution"),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_reporting_execution_idempotency",
),
Index(
"ix_reporting_execution_history",
"tenant_id",
"report_id",
"started_at",
),
Index(
"ix_reporting_execution_status",
"tenant_id",
"status",
"started_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
execution_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
report_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
report_revision: Mapped[int] = mapped_column(Integer, nullable=False)
semantic_model_id: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
semantic_model_revision: Mapped[int] = mapped_column(Integer, nullable=False)
dataset_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
dataset_revision: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
parameters: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
query: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
source_fingerprints: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
definition_hashes: Mapped[dict[str, str]] = mapped_column(
JSON, default=dict, nullable=False
)
output_hash: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True
)
executor_version: Mapped[str | None] = mapped_column(String(255), nullable=True)
result_schema: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
result_rows: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
total_rows: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
diagnostics: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class ReportingProviderExecution(Base, TimestampMixin):
__tablename__ = "reporting_provider_executions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"execution_id",
name="uq_reporting_provider_execution",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_reporting_provider_execution_idempotency",
),
Index(
"ix_reporting_provider_execution_history",
"tenant_id",
"provider_id",
"report_id",
"generated_at",
),
Index(
"ix_reporting_provider_execution_retention",
"tenant_id",
"expires_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
execution_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
provider_id: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
report_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
report_revision: Mapped[str] = mapped_column(String(255), nullable=False)
contract_version: Mapped[str] = mapped_column(String(30), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
purpose: Mapped[str] = mapped_column(Text, nullable=False)
audience_scope: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
parameters: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
result_schema: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
result_payload: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
source_revisions: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
effective_scope: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
privacy_transforms: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
governance_provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
retention_class: Mapped[str] = mapped_column(String(120), nullable=False)
retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
retention_redacted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
output_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
generated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class ReportingProviderExport(Base, TimestampMixin):
__tablename__ = "reporting_provider_exports"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"export_id",
name="uq_reporting_provider_export",
),
Index(
"ix_reporting_provider_export_history",
"tenant_id",
"provider_execution_id",
"exported_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
export_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
provider_execution_id: Mapped[str] = mapped_column(
ForeignKey(
"reporting_provider_executions.id",
ondelete="RESTRICT",
),
nullable=False,
index=True,
)
execution_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
format: Mapped[str] = mapped_column(String(30), nullable=False)
purpose: Mapped[str] = mapped_column(Text, nullable=False)
audience_scope: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
output_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
exported_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class ReportingDefinitionGrant(Base, TimestampMixin):
__tablename__ = "reporting_definition_grants"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"definition_kind",
"definition_id",
"subject_kind",
"subject_id",
name="uq_reporting_definition_grant",
),
Index(
"ix_reporting_definition_grant_subject",
"tenant_id",
"subject_kind",
"subject_id",
"active",
),
Index(
"ix_reporting_definition_grant_object",
"tenant_id",
"definition_kind",
"definition_id",
"active",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
definition_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
subject_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False, index=True
)
source_revision: Mapped[int] = mapped_column(Integer, nullable=False)
class ReportingSavedView(Base, TimestampMixin):
__tablename__ = "reporting_saved_views"
__table_args__ = (
UniqueConstraint("tenant_id", "view_id", name="uq_reporting_saved_view"),
Index(
"ix_reporting_saved_view_catalog",
"tenant_id",
"report_id",
"owner_id",
"shared",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
view_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
report_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
report_revision: Mapped[int] = mapped_column(Integer, nullable=False)
owner_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
owner_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(500), nullable=False)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
state: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
shared: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False, index=True
)
access: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class ReportingSchedule(Base, TimestampMixin):
__tablename__ = "reporting_schedules"
__table_args__ = (
UniqueConstraint("tenant_id", "schedule_id", name="uq_reporting_schedule"),
Index(
"ix_reporting_schedule_due",
"enabled",
"next_run_at",
"tenant_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
schedule_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
report_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
report_revision: Mapped[int] = mapped_column(Integer, nullable=False)
name: Mapped[str] = mapped_column(String(500), nullable=False)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
trigger_kind: Mapped[str] = mapped_column(String(30), nullable=False)
trigger_config: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
parameters: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
query: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
publication_target: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
enabled: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False, index=True
)
next_run_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
last_run_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_execution_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
class ReportingPublication(Base, TimestampMixin):
__tablename__ = "reporting_publications"
__table_args__ = (
UniqueConstraint(
"tenant_id", "publication_id", name="uq_reporting_publication"
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_reporting_publication_idempotency",
),
Index(
"ix_reporting_publication_history",
"tenant_id",
"execution_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
publication_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
execution_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
target_capability: Mapped[str] = mapped_column(String(255), nullable=False)
target_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
format: Mapped[str] = mapped_column(String(30), nullable=False)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class ReportingDrillContext(Base, TimestampMixin):
__tablename__ = "reporting_drill_contexts"
__table_args__ = (
UniqueConstraint(
"tenant_id", "drill_context_id", name="uq_reporting_drill_context"
),
Index(
"ix_reporting_drill_context_expiry",
"tenant_id",
"expires_at",
),
Index(
"ix_reporting_drill_context_execution",
"tenant_id",
"execution_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
drill_context_id: Mapped[str] = mapped_column(
String(36), nullable=False, index=True
)
execution_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
token_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
context_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
dimension_path: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
source_fingerprints: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
policy_provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
last_accessed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class ReportingQualityResult(Base, TimestampMixin):
__tablename__ = "reporting_quality_results"
__table_args__ = (
UniqueConstraint("tenant_id", "result_id", name="uq_reporting_quality_result"),
Index(
"ix_reporting_quality_history",
"tenant_id",
"quality_plan_id",
"evaluated_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
result_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
quality_plan_id: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
quality_plan_revision: Mapped[int] = mapped_column(Integer, nullable=False)
dataset_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
dataset_revision: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
output_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
assertions: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
source_fingerprints: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
evaluated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
class ReportingImportAssessment(Base, TimestampMixin):
__tablename__ = "reporting_import_assessments"
__table_args__ = (
UniqueConstraint(
"tenant_id", "assessment_id", name="uq_reporting_import_assessment"
),
Index(
"ix_reporting_import_history",
"tenant_id",
"source_system",
"created_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
assessment_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
source_system: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
source_id: Mapped[str] = mapped_column(String(500), nullable=False)
source_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
mapping_report: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
accepted_approximations: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
assessed_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
__all__ = [
"ReportingDefinitionGrant",
"ReportingDefinitionIdentity",
"ReportingDefinitionRevision",
"ReportingDrillContext",
"ReportingExecution",
"ReportingImportAssessment",
"ReportingPublication",
"ReportingProviderExecution",
"ReportingProviderExport",
"ReportingQualityResult",
"ReportingSavedView",
"ReportingSchedule",
]
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from govoplan_reporting.backend.db.models import ReportingDefinitionRevision
@dataclass(frozen=True, slots=True)
class ReportingDefinitionRecord:
tenant_id: str
definition_kind: str
definition_id: str
definition_key: str
revision: int
name: str
description: str | None
status: str
visibility: str
content_hash: str
recorded_at: datetime
change_reason: str
payload: dict[str, Any]
parent_kind: str | None = None
parent_id: str | None = None
parent_revision: int | None = None
def to_dict(self) -> dict[str, Any]:
return {
"tenant_id": self.tenant_id,
"definition_kind": self.definition_kind,
"definition_id": self.definition_id,
"definition_key": self.definition_key,
"revision": self.revision,
"name": self.name,
"description": self.description,
"status": self.status,
"visibility": self.visibility,
"content_hash": self.content_hash,
"parent_kind": self.parent_kind,
"parent_id": self.parent_id,
"parent_revision": self.parent_revision,
"recorded_at": _datetime_text(self.recorded_at),
"change_reason": self.change_reason,
"payload": dict(self.payload),
}
def definition_from_row(
row: ReportingDefinitionRevision,
) -> ReportingDefinitionRecord:
return ReportingDefinitionRecord(
tenant_id=row.tenant_id,
definition_kind=row.definition_kind,
definition_id=row.definition_id,
definition_key=row.definition_key,
revision=row.revision,
name=row.name,
description=row.description,
status=row.status,
visibility=row.visibility,
content_hash=row.content_hash,
parent_kind=row.parent_kind,
parent_id=row.parent_id,
parent_revision=row.parent_revision,
recorded_at=row.recorded_at,
change_reason=row.change_reason,
payload=dict(row.payload or {}),
)
def _datetime_text(value: datetime) -> str:
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.isoformat()
__all__ = ["ReportingDefinitionRecord", "definition_from_row"]
+388
View File
@@ -0,0 +1,388 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime, timedelta
import hashlib
import hmac
import json
import secrets
from typing import Any
import uuid
from sqlalchemy.orm import Session
from govoplan_core.security.time import utc_now
from govoplan_reporting.backend.db.models import (
ReportingDrillContext,
ReportingExecution,
)
from govoplan_reporting.backend.definitions import get_definition
from govoplan_reporting.backend.execution import (
ReportingExecutionError,
_apply_row_policy,
_read_dataset,
_validate_schema,
get_execution,
)
from govoplan_reporting.backend.postgres_planner import execute_postgres_query
from govoplan_reporting.backend.query_engine import execute_semantic_query
from govoplan_reporting.backend.schemas import (
DatasetDefinition,
FilterClause,
ReportDefinition,
ReportQuery,
SemanticModelDefinition,
)
DRILL_CONTEXT_TTL = timedelta(minutes=20)
class ReportingDrillError(ValueError):
pass
def create_drill_context(
session: Session,
principal: object,
*,
registry: object | None,
execution_id: str,
aggregate_row: Mapping[str, object],
limit: int,
) -> dict[str, object]:
execution_payload = get_execution(
session,
principal,
execution_id=execution_id,
registry=registry,
)
if execution_payload is None or execution_payload.get("status") != "succeeded":
raise LookupError("Successful Reporting execution not found.")
row = _execution(session, _tenant(principal), execution_id)
normalized_aggregate = _json_value(dict(aggregate_row))
if normalized_aggregate not in [
_json_value(dict(item)) for item in row.result_rows or []
]:
raise ReportingDrillError(
"The selected aggregate row does not belong to this execution."
)
semantic_record = get_definition(
session,
principal,
definition_kind="semantic_model",
definition_id=row.semantic_model_id,
revision=row.semantic_model_revision,
)
if semantic_record is None:
raise PermissionError("The report semantic model is no longer accessible.")
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
query = ReportQuery.model_validate(row.query or {})
dimension_keys = _drill_dimensions(query, semantic)
dimension_map = {item.key: item for item in semantic.dimensions}
path = [
{
"dimension": key,
"label": dimension_map[key].label,
"value": normalized_aggregate.get(key),
}
for key in dimension_keys
if key in normalized_aggregate
]
if not path:
raise ReportingDrillError(
"This aggregate has no dimension path to drill through."
)
bounded_limit = max(1, min(int(limit), 500))
actor_id = _actor(principal)
if not actor_id:
raise ReportingDrillError("Drill-through requires an accountable actor.")
drill_context_id = str(uuid.uuid4())
secret = secrets.token_urlsafe(32)
token = f"{drill_context_id}.{secret}"
context = {
"execution_id": execution_id,
"output_hash": row.output_hash,
"actor_id": actor_id,
"dimension_path": path,
"source_fingerprints": list(row.source_fingerprints or []),
"limit": bounded_limit,
}
item = ReportingDrillContext(
tenant_id=row.tenant_id,
drill_context_id=drill_context_id,
execution_id=execution_id,
token_sha256=_sha256(token),
context_sha256=_sha256(context),
actor_id=actor_id,
dimension_path=path,
source_fingerprints=list(row.source_fingerprints or []),
policy_provenance=dict(
execution_payload.get("delivery_authorization") or {}
),
expires_at=utc_now() + DRILL_CONTEXT_TTL,
)
item.policy_provenance["limit"] = bounded_limit
session.add(item)
session.flush()
return {
"token": token,
"drill_context_id": drill_context_id,
"execution_id": execution_id,
"dimension_path": path,
"expires_at": _datetime_text(item.expires_at),
}
def resolve_drill_context(
session: Session,
principal: object,
*,
registry: object | None,
token: str,
) -> dict[str, object]:
context_id, separator, _secret = token.partition(".")
if not separator or not context_id:
raise ReportingDrillError("The drill-through context token is invalid.")
item = (
session.query(ReportingDrillContext)
.filter(
ReportingDrillContext.tenant_id == _tenant(principal),
ReportingDrillContext.drill_context_id == context_id,
)
.one_or_none()
)
if item is None or not hmac.compare_digest(item.token_sha256, _sha256(token)):
raise LookupError("Reporting drill-through context not found.")
if item.actor_id != _actor(principal):
raise PermissionError(
"This drill-through context belongs to another account."
)
if _aware(item.expires_at) <= utc_now():
raise ReportingDrillError("The drill-through context has expired.")
row = _execution(session, item.tenant_id, item.execution_id)
expected_context = {
"execution_id": row.execution_id,
"output_hash": row.output_hash,
"actor_id": item.actor_id,
"dimension_path": list(item.dimension_path or []),
"source_fingerprints": list(item.source_fingerprints or []),
"limit": int((item.policy_provenance or {}).get("limit", 200)),
}
if not hmac.compare_digest(item.context_sha256, _sha256(expected_context)):
raise ReportingDrillError(
"The persisted drill-through context failed its integrity check."
)
execution_payload = get_execution(
session,
principal,
execution_id=row.execution_id,
registry=registry,
)
if execution_payload is None:
raise LookupError("Reporting execution not found.")
report_record = get_definition(
session,
principal,
definition_kind="report",
definition_id=row.report_id,
revision=row.report_revision,
)
semantic_record = get_definition(
session,
principal,
definition_kind="semantic_model",
definition_id=row.semantic_model_id,
revision=row.semantic_model_revision,
)
dataset_record = get_definition(
session,
principal,
definition_kind="dataset",
definition_id=row.dataset_id,
revision=row.dataset_revision,
)
if report_record is None or semantic_record is None or dataset_record is None:
raise PermissionError(
"The report source graph is no longer accessible for drill-through."
)
report = ReportDefinition.model_validate(report_record.payload)
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
dataset = DatasetDefinition.model_validate(dataset_record.payload)
source = _read_dataset(
session,
principal,
registry=registry,
dataset=dataset,
parameters=dict(row.parameters or {}),
)
if not _fingerprints_equal(
item.source_fingerprints or [], source.source_fingerprints
):
raise ReportingExecutionError(
"The source fingerprint changed after the aggregate execution; run the report again before drilling through."
)
normalized_rows = tuple(_json_value(dict(source_row)) for source_row in source.rows)
_validate_schema(dataset, normalized_rows)
authorized_rows, row_policy = _apply_row_policy(
session,
principal,
registry=registry,
dataset_id=dataset_record.definition_id,
dataset_revision=dataset_record.revision,
dataset=dataset,
rows=normalized_rows,
)
original = ReportQuery.model_validate(row.query or {})
hidden_dimensions = _strings(report.access_policy.get("hidden_dimensions"))
visible_dimensions = [
dimension.key
for dimension in semantic.dimensions
if dimension.key not in hidden_dimensions
]
filters = list(original.filters)
filters.extend(
FilterClause(
dimension=str(path_item["dimension"]),
operator="eq",
value=path_item.get("value"),
)
for path_item in item.dimension_path or []
)
detail_query = ReportQuery(
mode="detail",
dimensions=visible_dimensions,
filters=filters,
limit=int((item.policy_provenance or {}).get("limit", 200)),
)
result = execute_postgres_query(
session,
rows=authorized_rows,
dataset=dataset,
semantic_model=semantic,
query=detail_query,
) or execute_semantic_query(authorized_rows, semantic, detail_query)
item.last_accessed_at = utc_now()
item.policy_provenance = {
**dict(item.policy_provenance or {}),
"resolved_row_policy": dict(row_policy),
"delivery_authorization": dict(
execution_payload.get("delivery_authorization") or {}
),
}
session.flush()
return {
"drill_context_id": item.drill_context_id,
"execution_id": item.execution_id,
"dimension_path": list(item.dimension_path or []),
"rows": list(result.rows),
"schema": list(result.schema),
"total_rows": result.total_rows,
"truncated": result.truncated or source.truncated,
"source_fingerprints": list(source.source_fingerprints),
"policy_provenance": dict(item.policy_provenance or {}),
"expires_at": _datetime_text(item.expires_at),
}
def _drill_dimensions(
query: ReportQuery,
semantic: SemanticModelDefinition,
) -> tuple[str, ...]:
if query.mode == "pivot" and query.pivot is not None:
return tuple(dict.fromkeys((*query.pivot.rows, *query.pivot.columns)))
return tuple(query.dimensions or semantic.default_dimensions)
def _execution(
session: Session,
tenant_id: str,
execution_id: str,
) -> ReportingExecution:
row = (
session.query(ReportingExecution)
.filter(
ReportingExecution.tenant_id == tenant_id,
ReportingExecution.execution_id == execution_id,
)
.one_or_none()
)
if row is None:
raise LookupError("Reporting execution not found.")
return row
def _fingerprints_equal(
expected: Sequence[Mapping[str, object]],
actual: Sequence[Mapping[str, object]],
) -> bool:
normalize = lambda values: sorted( # noqa: E731 - compact canonicalizer
json.dumps(
_json_value(dict(item)),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)
for item in values
)
return normalize(expected) == normalize(actual)
def _tenant(principal: object) -> str:
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id:
raise ReportingDrillError("Drill-through requires a tenant-bound principal.")
return tenant_id
def _actor(principal: object) -> str | None:
for value in (
getattr(principal, "account_id", None),
getattr(principal, "identity_id", None),
getattr(principal, "membership_id", None),
):
if str(value or "").strip():
return str(value)
return None
def _strings(value: object) -> set[str]:
if not isinstance(value, (list, tuple, set, frozenset)):
return set()
return {str(item) for item in value if str(item).strip()}
def _sha256(value: object) -> str:
payload = value if isinstance(value, str) else json.dumps(
_json_value(value),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _json_value(value: object) -> Any:
if isinstance(value, datetime):
return _aware(value).isoformat()
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_value(item) for item in value]
return value
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _datetime_text(value: datetime) -> str:
return _aware(value).isoformat()
__all__ = [
"DRILL_CONTEXT_TTL",
"ReportingDrillError",
"create_drill_context",
"resolve_drill_context",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,399 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Literal, cast
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.policy import (
DefinitionGovernanceAction,
DefinitionGovernanceRequest,
DefinitionScopeRef,
PolicyDecision,
PolicySourceStep,
definition_governance_policy,
)
from govoplan_reporting.backend.domain import ReportingDefinitionRecord
from govoplan_reporting.backend.schemas import DefinitionGovernance
_LIMITS = (
"inherit_to_lower_scopes",
"allow_run",
"allow_reuse",
"allow_automation",
)
_SCOPE_RANK = {"system": 0, "tenant": 1, "group": 2, "user": 3}
class ReportingGovernanceError(ValueError):
pass
def normalize_definition_governance(
payload: Mapping[str, object],
principal: object,
*,
administrative: bool,
) -> dict[str, object]:
result = dict(payload)
raw = result.get("governance")
governance = DefinitionGovernance.model_validate(
raw if isinstance(raw, Mapping) else {}
)
scope_type = governance.scope_type
scope_id = str(governance.scope_id or "").strip() or None
tenant_id = _tenant(principal)
if scope_type == "system":
if not _has_scope(principal, "system:governance:write"):
raise PermissionError(
"System Reporting definitions require system governance permission."
)
elif scope_type == "tenant":
if scope_id not in {None, tenant_id}:
raise PermissionError(
"Reporting definitions can only target the active tenant."
)
scope_id = tenant_id
elif scope_type == "group":
if scope_id not in _string_set(getattr(principal, "group_ids", ())):
if not administrative:
raise PermissionError(
"Group Reporting definitions require membership in that group."
)
elif scope_type == "user":
own_ids = {
str(getattr(principal, "account_id", "") or ""),
str(getattr(principal, "membership_id", "") or ""),
}
if scope_id not in own_ids and not administrative:
raise PermissionError(
"User Reporting definitions can only target the current account."
)
if scope_id == str(getattr(principal, "membership_id", "") or ""):
scope_id = str(getattr(principal, "account_id", "") or "")
effective = _effective_limits(governance)
result["governance"] = governance.model_copy(
update={
"scope_id": scope_id,
"inherit_to_lower_scopes": effective["inherit_to_lower_scopes"],
"allow_run": effective["allow_run"],
"allow_reuse": effective["allow_reuse"],
"allow_automation": effective["allow_automation"],
"source_effective_limits": dict(effective),
}
).model_dump(mode="json")
return result
def validate_parent_governance(
child_payload: Mapping[str, object],
parent_payload: Mapping[str, object],
) -> None:
child = _governance(child_payload)
parent = _governance(parent_payload)
child_scope = _scope(child)
parent_scope = _scope(parent)
if _SCOPE_RANK[child_scope.scope_type] < _SCOPE_RANK[parent_scope.scope_type]:
raise ReportingGovernanceError(
"A Reporting definition cannot broaden the scope of its parent."
)
if child_scope != parent_scope and not parent.inherit_to_lower_scopes:
raise ReportingGovernanceError(
"The parent Reporting definition is not inherited by lower scopes."
)
parent_limits = _effective_limits(parent)
child_limits = _effective_limits(child)
broadened = [key for key in _LIMITS if child_limits[key] and not parent_limits[key]]
if broadened:
raise ReportingGovernanceError(
"A child Reporting definition cannot broaden inherited limits: "
+ ", ".join(sorted(broadened))
)
def apply_parent_governance(
child_payload: Mapping[str, object],
parent_payload: Mapping[str, object],
) -> dict[str, object]:
"""Persist the effective parent restriction and its immediate provenance."""
validate_parent_governance(child_payload, parent_payload)
child = _governance(child_payload)
parent = _governance(parent_payload)
parent_limits = _effective_limits(parent)
effective = {
key: bool(getattr(child, key)) and parent_limits[key] for key in _LIMITS
}
parent_scope = {
"scope_type": parent.scope_type,
"scope_id": parent.scope_id,
}
if parent.source_scope:
parent_scope["inherited_from"] = dict(parent.source_scope)
result = dict(child_payload)
result["governance"] = child.model_copy(
update={
"inherit_to_lower_scopes": effective["inherit_to_lower_scopes"],
"allow_run": effective["allow_run"],
"allow_reuse": effective["allow_reuse"],
"allow_automation": effective["allow_automation"],
"source_scope": parent_scope,
"source_effective_limits": effective,
"derivation_provenance": {
**dict(child.derivation_provenance),
"parent_scope": parent_scope,
"restriction_mode": "intersection",
},
}
).model_dump(mode="json")
return result
def definition_decision(
session: object,
principal: object,
*,
registry: object | None,
record: ReportingDefinitionRecord,
action: DefinitionGovernanceAction,
) -> PolicyDecision:
governance = _governance(record.payload)
source = _scope(governance)
target = _target_scope(source, principal)
request = DefinitionGovernanceRequest(
module_id="reporting",
definition_ref=f"{record.definition_kind}:{record.definition_id}:{record.revision}",
tenant_id=_tenant(principal),
definition_scope=source,
target_scope=target,
definition_kind=cast(Literal["flow", "template"], "flow"),
action=action,
actor=_principal_ref(principal),
status=record.status,
inherit_to_lower_scopes=governance.inherit_to_lower_scopes,
allow_run=governance.allow_run,
allow_reuse=governance.allow_reuse,
allow_automation=governance.allow_automation,
context={
"ancestor_limits": dict(governance.source_effective_limits),
"ancestor_source": dict(governance.source_scope or {}),
"reporting_definition_kind": record.definition_kind,
},
)
provider = definition_governance_policy(registry)
if provider is not None:
return provider.resolve_definition_action(session, request=request)
return _fallback_decision(request)
def require_definition_action(
session: object,
principal: object,
*,
registry: object | None,
record: ReportingDefinitionRecord,
action: DefinitionGovernanceAction,
) -> PolicyDecision:
decision = definition_decision(
session,
principal,
registry=registry,
record=record,
action=action,
)
if not decision.allowed:
raise PermissionError(
decision.reason or f"Reporting definition action is denied: {action}."
)
return decision
def governance_payload(payload: Mapping[str, object]) -> dict[str, object]:
governance = _governance(payload)
return {
**governance.model_dump(mode="json"),
"effective_limits": _effective_limits(governance),
}
def scope_visible(payload: Mapping[str, object], principal: object) -> bool:
governance = _governance(payload)
scope = _scope(governance)
if scope.scope_type == "system":
return governance.inherit_to_lower_scopes or _has_scope(
principal, "reporting:definition:admin"
)
if scope.scope_type == "tenant":
return scope.scope_id in {None, _tenant(principal)}
if scope.scope_type == "group":
return scope.scope_id in _string_set(getattr(principal, "group_ids", ()))
return scope.scope_id in {
str(getattr(principal, "account_id", "") or ""),
str(getattr(principal, "membership_id", "") or ""),
}
def _fallback_decision(request: DefinitionGovernanceRequest) -> PolicyDecision:
source = request.definition_scope
target = request.target_scope
same_scope = source == target
inherited = (
_SCOPE_RANK[target.scope_type] >= _SCOPE_RANK[source.scope_type]
and request.inherit_to_lower_scopes
)
visible = same_scope or inherited
if request.action == "view":
allowed = visible
elif request.action == "edit":
allowed = same_scope
elif request.action == "run":
allowed = visible and request.status == "active" and request.allow_run
elif request.action == "reuse":
allowed = visible and request.allow_reuse
elif request.action == "automate":
allowed = visible and request.allow_automation
else:
allowed = visible and request.allow_reuse
reason = (
None
if allowed
else (
"The Reporting definition's scope or inherited limits do not allow this action."
)
)
return PolicyDecision(
allowed=allowed,
reason=reason,
source_path=(
PolicySourceStep(
scope_type=source.scope_type,
scope_id=source.scope_id,
label="Reporting definition governance",
applied_fields=_LIMITS,
policy={
"inherit_to_lower_scopes": request.inherit_to_lower_scopes,
"allow_run": request.allow_run,
"allow_reuse": request.allow_reuse,
"allow_automation": request.allow_automation,
},
),
),
requirements=() if allowed else (f"reporting.definition.{request.action}",),
details={
"provider": "reporting.conservative_fallback",
"definition_scope": source.path,
"target_scope": target.path,
"action": request.action,
},
)
def _governance(payload: Mapping[str, object]) -> DefinitionGovernance:
raw = payload.get("governance")
return DefinitionGovernance.model_validate(raw if isinstance(raw, Mapping) else {})
def _scope(governance: DefinitionGovernance) -> DefinitionScopeRef:
return DefinitionScopeRef(
scope_type=governance.scope_type,
scope_id=governance.scope_id,
)
def _target_scope(source: DefinitionScopeRef, principal: object) -> DefinitionScopeRef:
if source.scope_type == "group" and source.scope_id in _string_set(
getattr(principal, "group_ids", ())
):
return source
own_ids = {
str(getattr(principal, "account_id", "") or ""),
str(getattr(principal, "membership_id", "") or ""),
}
if source.scope_type == "user" and source.scope_id in own_ids:
return source
return DefinitionScopeRef("tenant", _tenant(principal))
def _effective_limits(governance: DefinitionGovernance) -> dict[str, bool]:
source = governance.source_effective_limits
return {
key: bool(getattr(governance, key)) and source.get(key, True) is True
for key in _LIMITS
}
def _principal_ref(principal: object) -> PrincipalRef:
converter = getattr(principal, "to_platform_principal", None)
if callable(converter):
converted = converter()
if isinstance(converted, PrincipalRef):
return converted
return PrincipalRef(
account_id=str(getattr(principal, "account_id", "") or "system"),
membership_id=_optional(getattr(principal, "membership_id", None)),
tenant_id=_tenant(principal),
identity_id=_optional(getattr(principal, "identity_id", None)),
scopes=frozenset(_string_set(getattr(principal, "scopes", ()))),
group_ids=frozenset(_string_set(getattr(principal, "group_ids", ()))),
role_ids=frozenset(_string_set(getattr(principal, "role_ids", ()))),
function_assignment_ids=frozenset(
_string_set(getattr(principal, "function_assignment_ids", ()))
),
service_account_id=_optional(getattr(principal, "service_account_id", None)),
acting_assignment_id=_optional(
getattr(principal, "acting_assignment_id", None)
),
)
def _has_scope(principal: object, scope: str) -> bool:
method = getattr(principal, "has", None)
if callable(method):
return bool(method(scope))
return scope in _string_set(getattr(principal, "scopes", ()))
def _tenant(principal: object) -> str:
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id:
raise ReportingGovernanceError(
"Reporting governance requires a tenant-bound principal."
)
return tenant_id
def _string_set(value: object) -> set[str]:
if isinstance(value, (str, bytes)):
return {str(value)} if value else set()
try:
return {str(item) for item in value or () if str(item).strip()} # type: ignore[union-attr]
except TypeError:
return set()
def _optional(value: object) -> str | None:
clean = str(value or "").strip()
return clean or None
__all__ = [
"ReportingGovernanceError",
"apply_parent_governance",
"definition_decision",
"governance_payload",
"normalize_definition_governance",
"require_definition_action",
"scope_visible",
"validate_parent_governance",
]
__all__ = [
"ReportingGovernanceError",
"definition_decision",
"governance_payload",
"normalize_definition_governance",
"require_definition_action",
"scope_visible",
"validate_parent_governance",
]
+571
View File
@@ -0,0 +1,571 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_DATASET_OUTPUT
from govoplan_core.core.files import CAPABILITY_FILES_ARTIFACT_STORE
from govoplan_core.core.mail import CAPABILITY_MAIL_NOTIFICATION_DELIVERY
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
)
from govoplan_core.core.reporting import (
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
CAPABILITY_REPORTING_RETENTION,
)
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_reporting.backend.acl import ReportingScopeAclProvider
from govoplan_reporting.backend.contracts import (
CAPABILITY_REPORTING_CHART_RENDERER,
CAPABILITY_REPORTING_PUBLICATION_FILES,
CAPABILITY_REPORTING_PUBLICATION_MAIL,
CAPABILITY_REPORTING_REGISTRY,
CAPABILITY_REPORTING_RUNNER,
CAPABILITY_REPORTING_SCHEDULER,
)
from govoplan_reporting.backend.db import models as reporting_models
from govoplan_reporting.backend.definitions import (
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
)
from govoplan_reporting.backend.execution import (
QUALITY_SCOPE,
RUN_SCOPE,
SqlReportingRunner,
)
from govoplan_reporting.backend.operations import (
IMPORT_SCOPE,
PUBLISH_SCOPE,
SCHEDULE_SCOPE,
SqlReportingScheduler,
)
from govoplan_reporting.backend.query_engine import DefaultChartRenderer
from govoplan_reporting.backend.publication_targets import (
FilesReportingPublicationTarget,
MailReportingPublicationTarget,
)
from govoplan_reporting.backend.registry import SqlReportingRegistry
from govoplan_reporting.backend.search_source import create_reporting_search_source
MODULE_ID = "reporting"
MODULE_NAME = "Reporting"
MODULE_VERSION = "0.1.18"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category=MODULE_NAME,
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
PERMISSIONS = (
_permission(
READ_SCOPE,
"View reporting definitions",
"Read accessible datasets, semantic models, reports, and quality plans.",
),
_permission(
WRITE_SCOPE,
"Manage reporting definitions",
"Create immutable revisions of Reporting definitions.",
),
_permission(
ADMIN_SCOPE,
"Administer reporting",
"Manage restricted definitions and Reporting governance.",
),
_permission(
RUN_SCOPE,
"Run reports",
"Execute accessible report revisions and export their authorized result.",
),
_permission(
PUBLISH_SCOPE,
"Publish reports",
"Send successful report results to configured publication providers.",
),
_permission(
SCHEDULE_SCOPE,
"Schedule reports",
"Create schedules and dispatch due report runs.",
),
_permission(
QUALITY_SCOPE,
"Run report quality plans",
"Evaluate dataset quality plans and inspect evidence.",
),
_permission(
IMPORT_SCOPE,
"Assess report imports",
"Assess external BI metadata and accept bounded approximations.",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="reporting_analyst",
name="Reporting analyst",
description="Define semantic reports, run them, and save analytical views.",
permissions=(READ_SCOPE, WRITE_SCOPE, RUN_SCOPE, QUALITY_SCOPE),
),
RoleTemplate(
slug="reporting_publisher",
name="Reporting publisher",
description="Run, schedule, export, and publish accessible reports.",
permissions=(READ_SCOPE, RUN_SCOPE, PUBLISH_SCOPE, SCHEDULE_SCOPE),
),
RoleTemplate(
slug="reporting_administrator",
name="Reporting administrator",
description="Administer definitions, imports, quality, schedules, and publications.",
permissions=tuple(item.scope for item in PERMISSIONS),
),
)
def _router(context: ModuleContext):
from govoplan_reporting.backend.router import create_router
return create_router(context.registry)
def _registry(context: ModuleContext) -> SqlReportingRegistry:
del context
return SqlReportingRegistry()
def _runner(context: ModuleContext) -> SqlReportingRunner:
return SqlReportingRunner(context.registry)
def _scheduler(context: ModuleContext) -> SqlReportingScheduler:
return SqlReportingScheduler(context.registry)
def _chart_renderer(context: ModuleContext) -> DefaultChartRenderer:
del context
return DefaultChartRenderer()
def _files_publication(context: ModuleContext) -> FilesReportingPublicationTarget:
return FilesReportingPublicationTarget(context.registry)
def _mail_publication(context: ModuleContext) -> MailReportingPublicationTarget:
return MailReportingPublicationTarget(context.registry)
def _retention(context: ModuleContext):
del context
from govoplan_reporting.backend.retention import ReportingRetentionService
return ReportingRetentionService()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
definitions = (
session.query(reporting_models.ReportingDefinitionRevision)
.filter(
reporting_models.ReportingDefinitionRevision.tenant_id == tenant_id,
reporting_models.ReportingDefinitionRevision.superseded_at.is_(None),
)
.count()
)
reports = (
session.query(reporting_models.ReportingDefinitionRevision)
.filter(
reporting_models.ReportingDefinitionRevision.tenant_id == tenant_id,
reporting_models.ReportingDefinitionRevision.definition_kind == "report",
reporting_models.ReportingDefinitionRevision.superseded_at.is_(None),
)
.count()
)
executions = (
session.query(reporting_models.ReportingExecution)
.filter(reporting_models.ReportingExecution.tenant_id == tenant_id)
.count()
)
schedules = (
session.query(reporting_models.ReportingSchedule)
.filter(
reporting_models.ReportingSchedule.tenant_id == tenant_id,
reporting_models.ReportingSchedule.enabled.is_(True),
)
.count()
)
return {
"reporting_definitions": definitions,
"reports": reports,
"report_executions": executions,
"active_report_schedules": schedules,
}
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("access",),
optional_dependencies=(
"dataflow",
"datasources",
"connectors",
"dashboard",
"files",
"mail",
"templates",
"workflow_engine",
"policy",
"search",
"notifications",
),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
optional_capabilities=(
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
CAPABILITY_FILES_ARTIFACT_STORE,
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_router,
nav_items=(
NavItem(
path="/reports",
label="Reporting",
icon="clipboard-pen-line",
required_any=(READ_SCOPE,),
order=74,
surface_id="reporting.navigation",
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/reporting-webui",
routes=(
FrontendRoute(
path="/reports",
component="ReportingPage",
required_any=(READ_SCOPE,),
order=74,
surface_id="reporting.workspace",
),
FrontendRoute(
path="/reporting",
component="ReportingPage",
required_any=(READ_SCOPE,),
order=175,
surface_id="reporting.compatibility",
),
),
nav_items=(
NavItem(
path="/reports",
label="Reporting",
icon="clipboard-pen-line",
required_any=(READ_SCOPE,),
order=74,
surface_id="reporting.navigation",
),
),
view_surfaces=(
ViewSurface(
id="reporting.parameters",
module_id=MODULE_ID,
kind="section",
label="Report parameters and filters",
parent_id="reporting.workspace",
order=30,
),
ViewSurface(
id="reporting.results",
module_id=MODULE_ID,
kind="section",
label="Authorized report results",
parent_id="reporting.workspace",
order=40,
),
ViewSurface(
id="reporting.widget.reports",
module_id=MODULE_ID,
kind="section",
label="Reports dashboard widget",
order=75,
),
),
),
provides_interfaces=(
ModuleInterfaceProvider(name="reporting.registry", version="0.1.0"),
ModuleInterfaceProvider(name="reporting.runner", version="0.1.0"),
ModuleInterfaceProvider(name="reporting.scheduler", version="0.1.0"),
ModuleInterfaceProvider(name="reporting.chart_renderer", version="0.1.0"),
ModuleInterfaceProvider(
name=CAPABILITY_REPORTING_PUBLICATION_FILES, version="1.0.0"
),
ModuleInterfaceProvider(
name=CAPABILITY_REPORTING_PUBLICATION_MAIL, version="1.0.0"
),
ModuleInterfaceProvider(name=CAPABILITY_REPORTING_RETENTION, version="1.0.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="dataflow.dataset_output",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_POLICY_REPORTING_GOVERNANCE,
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_FILES_ARTIFACT_STORE,
version_min="0.1.14",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="mail.notification_delivery",
version_min="0.1.0",
version_max_exclusive="2.0.0",
optional=True,
),
),
capability_factories={
CAPABILITY_REPORTING_REGISTRY: _registry,
CAPABILITY_REPORTING_RUNNER: _runner,
CAPABILITY_REPORTING_SCHEDULER: _scheduler,
CAPABILITY_REPORTING_CHART_RENDERER: _chart_renderer,
CAPABILITY_REPORTING_PUBLICATION_FILES: _files_publication,
CAPABILITY_REPORTING_PUBLICATION_MAIL: _mail_publication,
CAPABILITY_REPORTING_RETENTION: _retention,
},
capability_documentation={
CAPABILITY_REPORTING_REGISTRY: CapabilityDocumentation(
label="Reporting definition registry",
summary="Stores versioned datasets, semantic models, reports, and quality plans.",
contract_version="0.1.0",
),
CAPABILITY_REPORTING_RUNNER: CapabilityDocumentation(
label="Governed report runner",
summary="Executes a pinned report graph over an authorized provider-owned dataset.",
contract_version="0.1.0",
),
CAPABILITY_REPORTING_SCHEDULER: CapabilityDocumentation(
label="Report schedule dispatcher",
summary="Claims due report schedules and records run/publication evidence.",
contract_version="0.1.0",
),
CAPABILITY_REPORTING_CHART_RENDERER: CapabilityDocumentation(
label="Report chart renderer",
summary="Builds provider-neutral chart models with an accessible tabular fallback.",
contract_version="0.1.0",
),
CAPABILITY_REPORTING_PUBLICATION_FILES: CapabilityDocumentation(
label="Files report publication",
summary="Stores an immutable authorized report output through Files managed artifact storage.",
contract_version="1.0.0",
),
CAPABILITY_REPORTING_PUBLICATION_MAIL: CapabilityDocumentation(
label="Mail report publication",
summary="Submits an idempotent report notice through Mail's durable delivery outbox.",
contract_version="1.0.0",
),
CAPABILITY_REPORTING_RETENTION: CapabilityDocumentation(
label="Reporting result retention",
summary="Minimizes expired provider-report detail while retaining audit hashes and provenance.",
contract_version="1.0",
documentation_types=("admin",),
audience=("privacy_officer", "operator", "system_admin"),
),
},
search_sources=(
SearchSourceProviderRegistration(
id="reporting.reports",
factory=create_reporting_search_source,
),
),
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
reporting_models.ReportingImportAssessment,
reporting_models.ReportingQualityResult,
reporting_models.ReportingPublication,
reporting_models.ReportingSchedule,
reporting_models.ReportingSavedView,
reporting_models.ReportingDefinitionGrant,
reporting_models.ReportingExecution,
reporting_models.ReportingDrillContext,
reporting_models.ReportingProviderExport,
reporting_models.ReportingProviderExecution,
reporting_models.ReportingDefinitionRevision,
reporting_models.ReportingDefinitionIdentity,
label="Reporting",
),
retirement_notes=(
"Destructive retirement requires a database snapshot and removes "
"Reporting definitions, results, quality evidence, schedules, and publications."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
reporting_models.ReportingDefinitionIdentity,
reporting_models.ReportingDefinitionRevision,
reporting_models.ReportingDefinitionGrant,
reporting_models.ReportingExecution,
reporting_models.ReportingDrillContext,
reporting_models.ReportingProviderExecution,
reporting_models.ReportingProviderExport,
reporting_models.ReportingSavedView,
reporting_models.ReportingSchedule,
reporting_models.ReportingPublication,
reporting_models.ReportingQualityResult,
reporting_models.ReportingImportAssessment,
label="Reporting",
),
),
resource_acl_providers=(
ReportingScopeAclProvider("analytical_dataset"),
ReportingScopeAclProvider("semantic_model"),
ReportingScopeAclProvider("report"),
ReportingScopeAclProvider("report_execution"),
),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="reporting.governed-bi",
title="Governed reporting and semantic BI",
summary="Build reproducible reports over provider-owned datasets without bypassing module or row-level access.",
body=(
"Reporting pins dataset, semantic-model, and report revisions. Runs retain "
"definition hashes, source fingerprints, policy provenance, quality evidence, "
"authorized result rows, diagnostics, and output hashes. Safe dimensions, "
"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. "
"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."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
links=(
DocumentationLink(
label="Reporting module boundary",
href="govoplan-reporting/docs/REPORTING_BOUNDARY.md",
kind="repository",
),
DocumentationLink(
label="SuperX capability assessment",
href="govoplan-reporting/docs/SUPERX_CAPABILITY_ASSESSMENT.md",
kind="repository",
),
DocumentationLink(
label="Reporting user guide",
href="govoplan-reporting/docs/USER_GUIDE.md",
kind="repository",
),
DocumentationLink(
label="Reporting administration guide",
href="govoplan-reporting/docs/ADMIN_GUIDE.md",
kind="repository",
),
DocumentationLink(
label="Reporting interface pattern audit",
href="govoplan-reporting/docs/INTERFACE_PATTERN_MIGRATION.md",
kind="repository",
),
),
),
),
architecture=ModuleArchitectureDeclaration(
layer="data_reporting_integration",
kind="domain",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_reporting_service.py",
summary="Proves revision pinning, safe semantic execution, quality gates, access, replay, exports, and import blocking.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/REPORTING_BOUNDARY.md",
summary="Defines governed analytical source, semantic, execution, and publication ownership.",
),
),
known_limits=(
"Dataflow is the first live dataset adapter; additional module read models use the provider-neutral contract.",
"Direct browser export supports CSV and JSON. Files supports CSV, JSON, and HTML publication; Mail submits a bounded report notice. XLSX/PDF require a renderer provider.",
"Import assessment produces blocking diagnostics but does not execute source SQL or automatically activate generated definitions.",
"The built-in chart catalogue covers bounded bar, column, line, area, pie, donut, and metric views; specialized visual renderers remain replaceable adapters.",
),
owned_concepts=(
"analytical dataset binding",
"semantic dimension hierarchy and measure",
"report definition and saved view",
"report execution and publication evidence",
"report quality plan and import assessment",
),
non_owned_concepts=(
"raw datasource ingestion",
"data transformation pipeline",
"source module authorization",
"template document rendering",
"file or DMS storage",
),
documentation=ModuleArchitectureDocumentation(
operations=("docs/OPERATIONS.md",),
recovery=("docs/OPERATIONS.md",),
security=("docs/OPERATIONS.md",),
),
),
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1 @@
"""Reporting Alembic revisions."""
@@ -0,0 +1 @@
"""Reporting migration versions."""
@@ -0,0 +1,142 @@
"""Add governed cross-module report executions and export history.
Revision ID: b7c4e1a9d2f6
Revises: e5b2c9d4f7a1
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b7c4e1a9d2f6"
down_revision = "e5b2c9d4f7a1"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"reporting_provider_executions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("execution_id", sa.String(length=36), nullable=False),
sa.Column("provider_id", sa.String(length=120), nullable=False),
sa.Column("report_id", sa.String(length=255), nullable=False),
sa.Column("report_revision", sa.String(length=255), nullable=False),
sa.Column("contract_version", sa.String(length=30), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("purpose", sa.Text(), nullable=False),
sa.Column("audience_scope", sa.JSON(), nullable=False),
sa.Column("parameters", sa.JSON(), nullable=False),
sa.Column("result_schema", sa.JSON(), nullable=False),
sa.Column("result_payload", sa.JSON(), nullable=False),
sa.Column("source_revisions", sa.JSON(), nullable=False),
sa.Column("effective_scope", sa.JSON(), nullable=False),
sa.Column("privacy_transforms", sa.JSON(), nullable=False),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("governance_provenance", sa.JSON(), nullable=False),
sa.Column("retention_class", sa.String(length=120), nullable=False),
sa.Column("retention_days", sa.Integer(), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("retention_redacted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("output_hash", sa.String(length=64), nullable=False),
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_provider_executions")),
sa.UniqueConstraint(
"tenant_id",
"execution_id",
name="uq_reporting_provider_execution",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_reporting_provider_execution_idempotency",
),
)
_indexes(
"reporting_provider_executions",
"tenant_id",
"execution_id",
"provider_id",
"report_id",
"expires_at",
"retention_redacted_at",
"output_hash",
"generated_at",
"actor_id",
)
op.create_index(
"ix_reporting_provider_execution_history",
"reporting_provider_executions",
["tenant_id", "provider_id", "report_id", "generated_at"],
unique=False,
)
op.create_index(
"ix_reporting_provider_execution_retention",
"reporting_provider_executions",
["tenant_id", "expires_at"],
unique=False,
)
op.create_table(
"reporting_provider_exports",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("export_id", sa.String(length=36), nullable=False),
sa.Column("provider_execution_id", sa.String(length=36), nullable=False),
sa.Column("execution_id", sa.String(length=36), nullable=False),
sa.Column("format", sa.String(length=30), nullable=False),
sa.Column("purpose", sa.Text(), nullable=False),
sa.Column("audience_scope", sa.JSON(), nullable=False),
sa.Column("output_hash", sa.String(length=64), nullable=False),
sa.Column("exported_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["provider_execution_id"],
["reporting_provider_executions.id"],
name=op.f(
"fk_reporting_provider_exports_provider_execution_id_reporting_provider_executions"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_provider_exports")),
sa.UniqueConstraint(
"tenant_id",
"export_id",
name="uq_reporting_provider_export",
),
)
_indexes(
"reporting_provider_exports",
"tenant_id",
"export_id",
"provider_execution_id",
"execution_id",
"output_hash",
"exported_at",
"actor_id",
)
op.create_index(
"ix_reporting_provider_export_history",
"reporting_provider_exports",
["tenant_id", "provider_execution_id", "exported_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("reporting_provider_exports")
op.drop_table("reporting_provider_executions")
def _indexes(table: str, *columns: str) -> None:
for column in columns:
op.create_index(op.f(f"ix_{table}_{column}"), table, [column], unique=False)
@@ -0,0 +1,71 @@
"""Add authorization-bound Reporting drill contexts.
Revision ID: c8d5e2f6a9b3
Revises: b7c4e1a9d2f6
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c8d5e2f6a9b3"
down_revision = "b7c4e1a9d2f6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"reporting_drill_contexts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("drill_context_id", sa.String(length=36), nullable=False),
sa.Column("execution_id", sa.String(length=36), nullable=False),
sa.Column("token_sha256", sa.String(length=64), nullable=False),
sa.Column("context_sha256", sa.String(length=64), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=False),
sa.Column("dimension_path", sa.JSON(), nullable=False),
sa.Column("source_fingerprints", sa.JSON(), nullable=False),
sa.Column("policy_provenance", sa.JSON(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_accessed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_drill_contexts")),
sa.UniqueConstraint(
"tenant_id",
"drill_context_id",
name="uq_reporting_drill_context",
),
)
for column in (
"tenant_id",
"drill_context_id",
"execution_id",
"actor_id",
"expires_at",
):
op.create_index(
op.f(f"ix_reporting_drill_contexts_{column}"),
"reporting_drill_contexts",
[column],
unique=False,
)
op.create_index(
"ix_reporting_drill_context_expiry",
"reporting_drill_contexts",
["tenant_id", "expires_at"],
unique=False,
)
op.create_index(
"ix_reporting_drill_context_execution",
"reporting_drill_contexts",
["tenant_id", "execution_id"],
unique=False,
)
def downgrade() -> None:
op.drop_table("reporting_drill_contexts")
@@ -0,0 +1,477 @@
"""v0.1.14 governed Reporting baseline.
Revision ID: e5b2c9d4f7a1
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e5b2c9d4f7a1"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"reporting_definition_identities",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("definition_kind", sa.String(length=40), nullable=False),
sa.Column("definition_id", sa.String(length=255), nullable=False),
sa.Column("definition_key", sa.String(length=120), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_definition_identities")),
sa.UniqueConstraint(
"tenant_id",
"definition_kind",
"definition_id",
name="uq_reporting_definition_identity",
),
sa.UniqueConstraint(
"tenant_id",
"definition_kind",
"definition_key",
name="uq_reporting_definition_key",
),
)
_indexes(
"reporting_definition_identities",
"tenant_id",
"definition_kind",
"definition_id",
"definition_key",
"created_by",
)
op.create_index(
"ix_reporting_definition_catalog",
"reporting_definition_identities",
["tenant_id", "definition_kind", "definition_key"],
unique=False,
)
op.create_table(
"reporting_definition_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("definition_kind", sa.String(length=40), nullable=False),
sa.Column("definition_id", sa.String(length=255), nullable=False),
sa.Column("definition_key", sa.String(length=120), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("parent_kind", sa.String(length=40), nullable=True),
sa.Column("parent_id", sa.String(length=255), nullable=True),
sa.Column("parent_revision", sa.Integer(), nullable=True),
sa.Column("name", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("visibility", sa.String(length=30), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("change_reason", sa.String(length=1000), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["identity_id"],
["reporting_definition_identities.id"],
name=op.f(
"fk_reporting_definition_revisions_identity_id_reporting_definition_identities"
),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["reporting_definition_revisions.id"],
name=op.f(
"fk_reporting_definition_revisions_previous_revision_id_reporting_definition_revisions"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_definition_revisions")),
sa.UniqueConstraint(
"tenant_id",
"definition_kind",
"definition_id",
"revision",
name="uq_reporting_definition_revision",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_reporting_definition_idempotency",
),
sa.UniqueConstraint(
"tenant_id",
"event_id",
name="uq_reporting_definition_event",
),
)
_indexes(
"reporting_definition_revisions",
"tenant_id",
"identity_id",
"definition_kind",
"definition_id",
"definition_key",
"previous_revision_id",
"parent_kind",
"parent_id",
"status",
"visibility",
"content_hash",
"event_id",
"recorded_at",
"superseded_at",
"changed_by",
)
op.create_index(
"ix_reporting_definition_current",
"reporting_definition_revisions",
["tenant_id", "definition_kind", "definition_id", "superseded_at"],
unique=False,
)
op.create_index(
"ix_reporting_definition_parent",
"reporting_definition_revisions",
["tenant_id", "parent_kind", "parent_id", "parent_revision"],
unique=False,
)
op.create_index(
"ix_reporting_definition_list",
"reporting_definition_revisions",
["tenant_id", "definition_kind", "status", "recorded_at"],
unique=False,
)
op.create_table(
"reporting_definition_grants",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("definition_kind", sa.String(length=40), nullable=False),
sa.Column("definition_id", sa.String(length=255), nullable=False),
sa.Column("subject_kind", sa.String(length=40), nullable=False),
sa.Column("subject_id", sa.String(length=255), nullable=False),
sa.Column("permissions", sa.JSON(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("source_revision", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_definition_grants")),
sa.UniqueConstraint(
"tenant_id",
"definition_kind",
"definition_id",
"subject_kind",
"subject_id",
name="uq_reporting_definition_grant",
),
)
_indexes(
"reporting_definition_grants",
"tenant_id",
"definition_kind",
"definition_id",
"subject_kind",
"subject_id",
"active",
)
op.create_index(
"ix_reporting_definition_grant_subject",
"reporting_definition_grants",
["tenant_id", "subject_kind", "subject_id", "active"],
unique=False,
)
op.create_index(
"ix_reporting_definition_grant_object",
"reporting_definition_grants",
["tenant_id", "definition_kind", "definition_id", "active"],
unique=False,
)
op.create_table(
"reporting_executions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("execution_id", sa.String(length=36), nullable=False),
sa.Column("report_id", sa.String(length=255), nullable=False),
sa.Column("report_revision", sa.Integer(), nullable=False),
sa.Column("semantic_model_id", sa.String(length=255), nullable=False),
sa.Column("semantic_model_revision", sa.Integer(), nullable=False),
sa.Column("dataset_id", sa.String(length=255), nullable=False),
sa.Column("dataset_revision", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("parameters", sa.JSON(), nullable=False),
sa.Column("query", sa.JSON(), nullable=False),
sa.Column("source_fingerprints", sa.JSON(), nullable=False),
sa.Column("definition_hashes", sa.JSON(), nullable=False),
sa.Column("output_hash", sa.String(length=64), nullable=True),
sa.Column("executor_version", sa.String(length=255), nullable=True),
sa.Column("result_schema", sa.JSON(), nullable=False),
sa.Column("result_rows", sa.JSON(), nullable=False),
sa.Column("total_rows", sa.Integer(), nullable=False),
sa.Column("truncated", sa.Boolean(), nullable=False),
sa.Column("diagnostics", sa.JSON(), nullable=False),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_executions")),
sa.UniqueConstraint("tenant_id", "execution_id", name="uq_reporting_execution"),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_reporting_execution_idempotency",
),
)
_indexes(
"reporting_executions",
"tenant_id",
"execution_id",
"report_id",
"semantic_model_id",
"dataset_id",
"status",
"output_hash",
"started_at",
"finished_at",
"actor_id",
)
op.create_index(
"ix_reporting_execution_history",
"reporting_executions",
["tenant_id", "report_id", "started_at"],
unique=False,
)
op.create_index(
"ix_reporting_execution_status",
"reporting_executions",
["tenant_id", "status", "started_at"],
unique=False,
)
op.create_table(
"reporting_saved_views",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("view_id", sa.String(length=36), nullable=False),
sa.Column("report_id", sa.String(length=255), nullable=False),
sa.Column("report_revision", sa.Integer(), nullable=False),
sa.Column("owner_kind", sa.String(length=40), nullable=False),
sa.Column("owner_id", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=500), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("state", sa.JSON(), nullable=False),
sa.Column("shared", sa.Boolean(), nullable=False),
sa.Column("access", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_saved_views")),
sa.UniqueConstraint("tenant_id", "view_id", name="uq_reporting_saved_view"),
)
_indexes(
"reporting_saved_views",
"tenant_id",
"view_id",
"report_id",
"owner_kind",
"owner_id",
"shared",
)
op.create_index(
"ix_reporting_saved_view_catalog",
"reporting_saved_views",
["tenant_id", "report_id", "owner_id", "shared"],
unique=False,
)
op.create_table(
"reporting_schedules",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("schedule_id", sa.String(length=36), nullable=False),
sa.Column("report_id", sa.String(length=255), nullable=False),
sa.Column("report_revision", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=500), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("trigger_kind", sa.String(length=30), nullable=False),
sa.Column("trigger_config", sa.JSON(), nullable=False),
sa.Column("parameters", sa.JSON(), nullable=False),
sa.Column("query", sa.JSON(), nullable=False),
sa.Column("publication_target", sa.JSON(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("next_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_execution_id", sa.String(length=36), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_schedules")),
sa.UniqueConstraint("tenant_id", "schedule_id", name="uq_reporting_schedule"),
)
_indexes(
"reporting_schedules",
"tenant_id",
"schedule_id",
"report_id",
"enabled",
"next_run_at",
)
op.create_index(
"ix_reporting_schedule_due",
"reporting_schedules",
["enabled", "next_run_at", "tenant_id"],
unique=False,
)
op.create_table(
"reporting_publications",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("publication_id", sa.String(length=36), nullable=False),
sa.Column("execution_id", sa.String(length=36), nullable=False),
sa.Column("target_capability", sa.String(length=255), nullable=False),
sa.Column("target_ref", sa.String(length=1000), nullable=True),
sa.Column("format", sa.String(length=30), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_publications")),
sa.UniqueConstraint(
"tenant_id", "publication_id", name="uq_reporting_publication"
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_reporting_publication_idempotency",
),
)
_indexes(
"reporting_publications",
"tenant_id",
"publication_id",
"execution_id",
"status",
)
op.create_index(
"ix_reporting_publication_history",
"reporting_publications",
["tenant_id", "execution_id", "created_at"],
unique=False,
)
op.create_table(
"reporting_quality_results",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("result_id", sa.String(length=36), nullable=False),
sa.Column("quality_plan_id", sa.String(length=255), nullable=False),
sa.Column("quality_plan_revision", sa.Integer(), nullable=False),
sa.Column("dataset_id", sa.String(length=255), nullable=False),
sa.Column("dataset_revision", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("output_hash", sa.String(length=64), nullable=False),
sa.Column("assertions", sa.JSON(), nullable=False),
sa.Column("source_fingerprints", sa.JSON(), nullable=False),
sa.Column("evaluated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_quality_results")),
sa.UniqueConstraint(
"tenant_id", "result_id", name="uq_reporting_quality_result"
),
)
_indexes(
"reporting_quality_results",
"tenant_id",
"result_id",
"quality_plan_id",
"dataset_id",
"status",
"output_hash",
"evaluated_at",
)
op.create_index(
"ix_reporting_quality_history",
"reporting_quality_results",
["tenant_id", "quality_plan_id", "evaluated_at"],
unique=False,
)
op.create_table(
"reporting_import_assessments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("assessment_id", sa.String(length=36), nullable=False),
sa.Column("source_system", sa.String(length=255), nullable=False),
sa.Column("source_id", sa.String(length=500), nullable=False),
sa.Column("source_fingerprint", sa.String(length=64), nullable=False),
sa.Column("mapping_report", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("accepted_approximations", sa.JSON(), nullable=False),
sa.Column("assessed_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_import_assessments")),
sa.UniqueConstraint(
"tenant_id",
"assessment_id",
name="uq_reporting_import_assessment",
),
)
_indexes(
"reporting_import_assessments",
"tenant_id",
"assessment_id",
"source_system",
"status",
)
op.create_index(
"ix_reporting_import_history",
"reporting_import_assessments",
["tenant_id", "source_system", "created_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("reporting_import_assessments")
op.drop_table("reporting_quality_results")
op.drop_table("reporting_publications")
op.drop_table("reporting_schedules")
op.drop_table("reporting_saved_views")
op.drop_table("reporting_definition_grants")
op.drop_table("reporting_executions")
op.drop_table("reporting_definition_revisions")
op.drop_table("reporting_definition_identities")
def _indexes(table: str, *columns: str) -> None:
for column in columns:
op.create_index(
op.f(f"ix_{table}_{column}"),
table,
[column],
unique=False,
)
@@ -0,0 +1,904 @@
from __future__ import annotations
from collections.abc import Mapping
import csv
from datetime import UTC, datetime, timedelta
from io import StringIO
import hashlib
import json
from typing import Any
import uuid
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.security.time import utc_now
from govoplan_reporting.backend.contracts import (
ReportingPublicationPayload,
ReportingPublicationTarget,
capability,
)
from govoplan_reporting.backend.db.models import (
ReportingImportAssessment,
ReportingPublication,
ReportingSavedView,
ReportingSchedule,
)
from govoplan_reporting.backend.definitions import ADMIN_SCOPE, get_definition
from govoplan_reporting.backend.execution import (
ReportingExecutionFailure,
execute_report,
get_execution,
)
from govoplan_reporting.backend.schemas import ReportQuery
PUBLISH_SCOPE = "reporting:report:publish"
SCHEDULE_SCOPE = "reporting:schedule:write"
IMPORT_SCOPE = "reporting:import:assess"
class ReportingOperationError(ValueError):
pass
class SqlReportingScheduler:
def __init__(self, registry: object | None) -> None:
self.registry = registry
def dispatch_due(
self,
session: object,
principal: object,
*,
now: datetime | None = None,
limit: int = 20,
) -> Mapping[str, object]:
return dispatch_due_schedules(
_session(session),
principal,
registry=self.registry,
now=now,
limit=limit,
)
def upsert_saved_view(
session: Session,
principal: object,
*,
view_id: str,
report_id: str,
report_revision: int,
name: str,
state: Mapping[str, object],
shared: bool,
access: Mapping[str, object],
expected_revision: int | None,
) -> dict[str, object]:
report = get_definition(
session,
principal,
definition_kind="report",
definition_id=report_id,
revision=report_revision,
)
if report is None:
raise LookupError("Reporting report definition not found.")
_validate_saved_view_state(state)
owner_id = _actor(principal)
if owner_id is None:
raise ReportingOperationError("Saved views require an account owner.")
row = (
session.query(ReportingSavedView)
.filter(
ReportingSavedView.tenant_id == _tenant(principal),
ReportingSavedView.view_id == view_id,
)
.with_for_update()
.one_or_none()
)
if row is None:
if expected_revision is not None:
raise ReportingOperationError(
"Saved-view revision conflict: no view exists."
)
row = ReportingSavedView(
tenant_id=_tenant(principal),
view_id=_required(view_id, "Saved-view identifier", 36),
report_id=report_id,
report_revision=report_revision,
owner_kind="account",
owner_id=owner_id,
name=_required(name, "Saved-view name", 500),
revision=1,
state=_json_value(state),
shared=shared,
access=_json_value(access),
)
session.add(row)
else:
if row.owner_id != owner_id and not _has_scope(principal, ADMIN_SCOPE):
raise PermissionError("Only the saved-view owner or an admin may edit it.")
if expected_revision != row.revision:
raise ReportingOperationError(
"Saved-view revision conflict: the expected revision is stale."
)
row.report_id = report_id
row.report_revision = report_revision
row.name = _required(name, "Saved-view name", 500)
row.state = _json_value(state)
row.shared = shared
row.access = _json_value(access)
row.revision += 1
session.flush()
return _saved_view_payload(row)
def list_saved_views(
session: Session,
principal: object,
*,
report_id: str,
) -> tuple[dict[str, object], ...]:
if (
get_definition(
session,
principal,
definition_kind="report",
definition_id=report_id,
)
is None
):
return ()
owner_id = _actor(principal)
rows = (
session.query(ReportingSavedView)
.filter(
ReportingSavedView.tenant_id == _tenant(principal),
ReportingSavedView.report_id == report_id,
or_(
ReportingSavedView.shared.is_(True),
ReportingSavedView.owner_id == owner_id,
),
)
.order_by(ReportingSavedView.name.asc())
.all()
)
return tuple(_saved_view_payload(row) for row in rows)
def delete_saved_view(
session: Session,
principal: object,
*,
view_id: str,
) -> bool:
row = (
session.query(ReportingSavedView)
.filter(
ReportingSavedView.tenant_id == _tenant(principal),
ReportingSavedView.view_id == view_id,
)
.with_for_update()
.one_or_none()
)
if row is None:
return False
if row.owner_id != _actor(principal) and not _has_scope(principal, ADMIN_SCOPE):
raise PermissionError("Only the saved-view owner or an admin may delete it.")
session.delete(row)
session.flush()
return True
def upsert_schedule(
session: Session,
principal: object,
*,
schedule_id: str,
report_id: str,
report_revision: int,
name: str,
trigger_kind: str,
trigger_config: Mapping[str, object],
parameters: Mapping[str, object],
query: ReportQuery,
publication_target: Mapping[str, object],
enabled: bool,
next_run_at: datetime | None,
expected_revision: int | None,
) -> dict[str, object]:
_require_scope(principal, SCHEDULE_SCOPE)
report = get_definition(
session,
principal,
definition_kind="report",
definition_id=report_id,
revision=report_revision,
)
if report is None or report.status != "active":
raise ReportingOperationError("Schedules require an active report revision.")
normalized_next = _validate_trigger(
trigger_kind,
trigger_config,
next_run_at=next_run_at,
)
row = (
session.query(ReportingSchedule)
.filter(
ReportingSchedule.tenant_id == _tenant(principal),
ReportingSchedule.schedule_id == schedule_id,
)
.with_for_update()
.one_or_none()
)
if row is None:
if expected_revision is not None:
raise ReportingOperationError(
"Reporting schedule revision conflict: no schedule exists."
)
row = ReportingSchedule(
tenant_id=_tenant(principal),
schedule_id=_required(schedule_id, "Reporting schedule identifier", 36),
report_id=report_id,
report_revision=report_revision,
name=_required(name, "Reporting schedule name", 500),
revision=1,
trigger_kind=trigger_kind,
trigger_config=_json_value(trigger_config),
parameters=_json_value(parameters),
query=query.model_dump(mode="json"),
publication_target=_json_value(publication_target),
enabled=enabled,
next_run_at=normalized_next,
created_by=_actor(principal),
)
session.add(row)
else:
if expected_revision != row.revision:
raise ReportingOperationError(
"Reporting schedule revision conflict: the expected revision is stale."
)
row.report_id = report_id
row.report_revision = report_revision
row.name = _required(name, "Reporting schedule name", 500)
row.trigger_kind = trigger_kind
row.trigger_config = _json_value(trigger_config)
row.parameters = _json_value(parameters)
row.query = query.model_dump(mode="json")
row.publication_target = _json_value(publication_target)
row.enabled = enabled
row.next_run_at = normalized_next
row.revision += 1
session.flush()
return _schedule_payload(row)
def list_schedules(
session: Session,
principal: object,
*,
report_id: str | None = None,
) -> tuple[dict[str, object], ...]:
statement = session.query(ReportingSchedule).filter(
ReportingSchedule.tenant_id == _tenant(principal)
)
if report_id:
if (
get_definition(
session,
principal,
definition_kind="report",
definition_id=report_id,
)
is None
):
return ()
statement = statement.filter(ReportingSchedule.report_id == report_id)
return tuple(
_schedule_payload(row)
for row in statement.order_by(ReportingSchedule.name.asc()).all()
)
def dispatch_due_schedules(
session: Session,
principal: object,
*,
registry: object | None,
now: datetime | None,
limit: int,
) -> dict[str, object]:
_require_scope(principal, SCHEDULE_SCOPE)
current = now or utc_now()
_aware(current, "Reporting scheduler time")
rows = (
session.query(ReportingSchedule)
.filter(
ReportingSchedule.enabled.is_(True),
ReportingSchedule.next_run_at.is_not(None),
ReportingSchedule.next_run_at <= current,
)
.order_by(ReportingSchedule.next_run_at.asc())
.limit(max(1, min(limit, 100)))
.with_for_update(skip_locked=True)
.all()
)
succeeded = 0
failed = 0
execution_ids: list[str] = []
for row in rows:
scheduled_for = row.next_run_at or current
try:
execution = execute_report(
session,
principal,
registry=registry,
report_id=row.report_id,
report_revision=row.report_revision,
parameters=dict(row.parameters or {}),
query=ReportQuery.model_validate(row.query or {}),
idempotency_key=f"schedule:{row.schedule_id}:{scheduled_for.isoformat()}",
)
execution_id = str(execution["execution_id"])
execution_ids.append(execution_id)
target = dict(row.publication_target or {})
if target:
publish_execution(
session,
principal,
registry=registry,
execution_id=execution_id,
target_capability=str(target.get("target_capability") or ""),
target_ref=(
str(target["target_ref"])
if target.get("target_ref") is not None
else None
),
format=str(target.get("format") or "csv"),
idempotency_key=f"schedule-publication:{row.schedule_id}:{scheduled_for.isoformat()}",
options=dict(target.get("options") or {}),
)
succeeded += 1
row.last_execution_id = execution_id
except (ReportingExecutionFailure, ReportingOperationError, LookupError):
failed += 1
row.last_run_at = current
_advance_schedule(row, scheduled_for)
session.flush()
return {
"claimed": len(rows),
"succeeded": succeeded,
"failed": failed,
"execution_ids": execution_ids,
}
def publish_execution(
session: Session,
principal: object,
*,
registry: object | None,
execution_id: str,
target_capability: str,
target_ref: str | None,
format: str,
idempotency_key: str,
options: Mapping[str, object],
) -> dict[str, object]:
_require_scope(principal, PUBLISH_SCOPE)
execution_payload = get_execution(
session,
principal,
execution_id=execution_id,
registry=registry,
)
if execution_payload is None:
raise LookupError("Reporting execution not found.")
if execution_payload["status"] != "succeeded":
raise ReportingOperationError("Only successful report executions can publish.")
clean_capability = _required(
target_capability,
"Reporting publication target capability",
255,
)
clean_format = str(format).casefold()
if clean_format not in {"json", "csv", "xlsx", "html", "pdf"}:
raise ReportingOperationError("Unsupported Reporting publication format.")
clean_key = _required(idempotency_key, "Reporting publication idempotency key", 255)
existing = (
session.query(ReportingPublication)
.filter(
ReportingPublication.tenant_id == _tenant(principal),
ReportingPublication.idempotency_key == clean_key,
)
.one_or_none()
)
if existing is not None:
if (
existing.execution_id != execution_id
or existing.target_capability != clean_capability
or existing.target_ref != target_ref
or existing.format != clean_format
):
raise ReportingOperationError("Reporting publication idempotency conflict.")
return _publication_payload(existing)
provider = capability(registry, clean_capability)
if not isinstance(provider, ReportingPublicationTarget):
raise ReportingOperationError(
f"Reporting publication provider {clean_capability!r} is unavailable."
)
publication = ReportingPublication(
tenant_id=_tenant(principal),
publication_id=str(uuid.uuid4()),
execution_id=execution_id,
target_capability=clean_capability,
target_ref=target_ref,
format=clean_format,
status="running",
idempotency_key=clean_key,
)
session.add(publication)
session.flush()
try:
evidence = provider.publish_report(
session,
principal,
payload=ReportingPublicationPayload(
publication_id=publication.publication_id,
execution_id=execution_id,
tenant_id=_tenant(principal),
report_id=str(execution_payload["report_id"]),
report_revision=int(execution_payload["report_revision"]),
format=clean_format,
target_ref=target_ref,
rows=tuple(execution_payload["rows"]), # type: ignore[arg-type]
schema=tuple(execution_payload["schema"]), # type: ignore[arg-type]
output_hash=str(execution_payload["output_hash"]),
options=dict(options),
),
)
publication.status = "succeeded"
publication.evidence = _json_value(evidence)
publication.completed_at = utc_now()
except Exception as exc:
publication.status = "failed"
publication.error = str(exc)
publication.completed_at = utc_now()
session.flush()
raise ReportingOperationError(str(exc)) from exc
session.flush()
emit_platform_event(
session,
PlatformEvent(
type=f"reporting.publication.{publication.status}",
module_id="reporting",
payload={
"publication_id": publication.publication_id,
"execution_id": publication.execution_id,
"target_capability": publication.target_capability,
"target_ref": publication.target_ref,
"format": publication.format,
"evidence": dict(publication.evidence or {}),
},
actor=EventActorRef(type="account", id=_actor(principal)),
tenant=EventTenantRef(id=publication.tenant_id),
resource=EventObjectRef(
type="report_publication",
id=publication.publication_id,
),
classification="internal",
),
)
return _publication_payload(publication)
def list_publications(
session: Session,
principal: object,
*,
execution_id: str | None = None,
limit: int = 100,
registry: object | None = None,
) -> tuple[dict[str, object], ...]:
_require_scope(principal, PUBLISH_SCOPE)
statement = session.query(ReportingPublication).filter(
ReportingPublication.tenant_id == _tenant(principal)
)
if execution_id:
if (
get_execution(
session,
principal,
execution_id=execution_id,
registry=registry,
)
is None
):
return ()
statement = statement.filter(
ReportingPublication.execution_id == execution_id
)
rows = (
statement.order_by(ReportingPublication.created_at.desc())
.limit(max(1, min(limit, 200)))
.all()
)
return tuple(_publication_payload(row) for row in rows)
def export_execution(
session: Session,
principal: object,
*,
execution_id: str,
format: str,
registry: object | None = None,
) -> tuple[bytes, str, str]:
payload = get_execution(
session,
principal,
execution_id=execution_id,
registry=registry,
)
if payload is None:
raise LookupError("Reporting execution not found.")
if payload["status"] != "succeeded":
raise ReportingOperationError("Only successful executions can be exported.")
rows = tuple(payload["rows"]) # type: ignore[arg-type]
if format == "json":
content = json.dumps(
{"schema": payload["schema"], "rows": rows},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
return content, "application/json", f"report-{execution_id}.json"
if format != "csv":
raise ReportingOperationError("Direct export supports CSV or JSON.")
fields = tuple(
dict.fromkeys(
str(key) for row in rows if isinstance(row, Mapping) for key in row
)
)
stream = StringIO(newline="")
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
for row in rows:
if isinstance(row, Mapping):
writer.writerow({key: _safe_csv_cell(row.get(key)) for key in fields})
return (
stream.getvalue().encode("utf-8-sig"),
"text/csv; charset=utf-8",
f"report-{execution_id}.csv",
)
def assess_import(
session: Session,
principal: object,
*,
source_system: str,
source_id: str,
metadata: Mapping[str, object],
accepted_approximations: list[str],
) -> dict[str, object]:
_require_scope(principal, IMPORT_SCOPE)
raw_features = metadata.get("features", [])
if not isinstance(raw_features, list):
raise ReportingOperationError("Import metadata features must be a list.")
features = tuple(dict.fromkeys(str(item) for item in raw_features))
exact_features = {
"dataset",
"dimension",
"hierarchy",
"measure",
"parameter",
"table",
"pivot",
"chart",
"quality_assertion",
"saved_view",
}
approximated_features = {
"provider_specific_format",
"dialect_function",
"dashboard_layout",
}
unsupported_features = {
"raw_sql",
"stored_procedure",
"runtime_script",
"implicit_authorization",
"unchecked_custom_function",
}
exact = sorted(set(features) & exact_features)
approximated = sorted(set(features) & approximated_features)
unsupported = sorted(
(set(features) & unsupported_features)
| (
set(features)
- exact_features
- approximated_features
- unsupported_features
)
)
accepted = sorted(set(accepted_approximations) & set(approximated))
pending = sorted(set(approximated) - set(accepted))
status = "ready" if not unsupported and not pending else "blocked"
mapping_report = {
"contract_version": "1",
"source_system": source_system,
"source_id": source_id,
"exact": exact,
"approximated": approximated,
"accepted_approximations": accepted,
"pending_approximations": pending,
"unsupported": unsupported,
"manual_bindings": list(metadata.get("manual_bindings", [])),
"provider_assumptions": list(metadata.get("provider_assumptions", [])),
"activation_allowed": status == "ready",
}
row = ReportingImportAssessment(
tenant_id=_tenant(principal),
assessment_id=str(uuid.uuid4()),
source_system=_required(source_system, "Import source system", 255),
source_id=_required(source_id, "Import source identifier", 500),
source_fingerprint=_sha256(metadata),
mapping_report=mapping_report,
status=status,
accepted_approximations=accepted,
assessed_by=_actor(principal),
)
session.add(row)
session.flush()
return _assessment_payload(row)
def list_import_assessments(
session: Session,
principal: object,
*,
limit: int = 100,
) -> tuple[dict[str, object], ...]:
_require_scope(principal, IMPORT_SCOPE)
rows = (
session.query(ReportingImportAssessment)
.filter(ReportingImportAssessment.tenant_id == _tenant(principal))
.order_by(ReportingImportAssessment.created_at.desc())
.limit(max(1, min(limit, 200)))
.all()
)
return tuple(_assessment_payload(row) for row in rows)
def _validate_saved_view_state(state: Mapping[str, object]) -> None:
query = state.get("query")
if query is not None:
if not isinstance(query, Mapping):
raise ReportingOperationError("Saved-view query must be an object.")
ReportQuery.model_validate(query)
if len(state) > 100:
raise ReportingOperationError("Saved-view state is limited to 100 entries.")
def _validate_trigger(
trigger_kind: str,
trigger_config: Mapping[str, object],
*,
next_run_at: datetime | None,
) -> datetime | None:
if trigger_kind not in {"scheduled", "interval"}:
raise ReportingOperationError("Unsupported Reporting schedule trigger.")
if next_run_at is not None:
_aware(next_run_at, "Reporting next_run_at")
if trigger_kind == "scheduled":
if next_run_at is None:
raise ReportingOperationError("Scheduled reports require next_run_at.")
return next_run_at
seconds = int(trigger_config.get("seconds", 0))
if not 60 <= seconds <= 31_536_000:
raise ReportingOperationError(
"Reporting intervals must be between 60 seconds and one year."
)
return next_run_at or utc_now() + timedelta(seconds=seconds)
def _advance_schedule(row: ReportingSchedule, scheduled_for: datetime) -> None:
if row.trigger_kind == "scheduled":
row.enabled = False
row.next_run_at = None
return
if scheduled_for.tzinfo is None or scheduled_for.utcoffset() is None:
scheduled_for = scheduled_for.replace(tzinfo=UTC)
seconds = int((row.trigger_config or {}).get("seconds", 0))
next_run = scheduled_for + timedelta(seconds=seconds)
now = utc_now()
while next_run <= now:
next_run += timedelta(seconds=seconds)
row.next_run_at = next_run
def _saved_view_payload(row: ReportingSavedView) -> dict[str, object]:
return {
"view_id": row.view_id,
"report_id": row.report_id,
"report_revision": row.report_revision,
"owner_kind": row.owner_kind,
"owner_id": row.owner_id,
"name": row.name,
"revision": row.revision,
"state": dict(row.state or {}),
"shared": row.shared,
"access": dict(row.access or {}),
"updated_at": _datetime_text(row.updated_at),
}
def _schedule_payload(row: ReportingSchedule) -> dict[str, object]:
return {
"schedule_id": row.schedule_id,
"report_id": row.report_id,
"report_revision": row.report_revision,
"name": row.name,
"revision": row.revision,
"trigger_kind": row.trigger_kind,
"trigger_config": dict(row.trigger_config or {}),
"parameters": dict(row.parameters or {}),
"query": dict(row.query or {}),
"publication_target": dict(row.publication_target or {}),
"enabled": row.enabled,
"next_run_at": _datetime_text(row.next_run_at),
"last_run_at": _datetime_text(row.last_run_at),
"last_execution_id": row.last_execution_id,
}
def _publication_payload(row: ReportingPublication) -> dict[str, object]:
return {
"publication_id": row.publication_id,
"execution_id": row.execution_id,
"target_capability": row.target_capability,
"target_ref": row.target_ref,
"format": row.format,
"status": row.status,
"evidence": dict(row.evidence or {}),
"error": row.error,
"completed_at": _datetime_text(row.completed_at),
}
def _assessment_payload(row: ReportingImportAssessment) -> dict[str, object]:
return {
"assessment_id": row.assessment_id,
"source_system": row.source_system,
"source_id": row.source_id,
"source_fingerprint": row.source_fingerprint,
"mapping_report": dict(row.mapping_report or {}),
"status": row.status,
"accepted_approximations": list(row.accepted_approximations or []),
"assessed_by": row.assessed_by,
"created_at": _datetime_text(row.created_at),
}
def _safe_csv_cell(value: object) -> object:
if isinstance(value, (dict, list, tuple)):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if isinstance(value, str) and value.startswith(("=", "+", "-", "@")):
return f"'{value}"
return value
def _has_scope(principal: object, scope: str) -> bool:
method = getattr(principal, "has", None)
if callable(method):
return bool(method(scope))
return scopes_grant_compatible(
frozenset(getattr(principal, "scopes", ()) or ()),
scope,
)
def _require_scope(principal: object, scope: str) -> None:
if not _has_scope(principal, scope):
raise PermissionError(f"Missing scope: {scope}")
def _tenant(principal: object) -> str:
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id:
raise ReportingOperationError(
"Reporting operations require a tenant-bound principal."
)
return tenant_id
def _actor(principal: object) -> str | None:
for value in (
getattr(principal, "account_id", None),
getattr(principal, "identity_id", None),
getattr(principal, "membership_id", None),
):
if str(value or "").strip():
return str(value)
return None
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Reporting operations require a SQLAlchemy session.")
return value
def _required(value: object, label: str, maximum: int) -> str:
result = str(value or "").strip()
if not result:
raise ReportingOperationError(f"{label} is required.")
if len(result) > maximum:
raise ReportingOperationError(f"{label} is limited to {maximum} characters.")
return result
def _aware(value: datetime, label: str) -> None:
if value.tzinfo is None or value.utcoffset() is None:
raise ReportingOperationError(f"{label} must include a timezone.")
def _sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(
_json_value(value),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("utf-8")
).hexdigest()
def _json_value(value: object) -> Any:
if isinstance(value, datetime):
return value.isoformat()
if hasattr(value, "model_dump"):
return _json_value(value.model_dump(mode="json"))
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_value(item) for item in value]
return value
def _datetime_text(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.isoformat()
__all__ = [
"IMPORT_SCOPE",
"PUBLISH_SCOPE",
"SCHEDULE_SCOPE",
"ReportingOperationError",
"SqlReportingScheduler",
"assess_import",
"delete_saved_view",
"dispatch_due_schedules",
"export_execution",
"list_import_assessments",
"list_publications",
"list_saved_views",
"list_schedules",
"publish_execution",
"upsert_saved_view",
"upsert_schedule",
]
@@ -0,0 +1,509 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import date, datetime
from decimal import Decimal
import json
import re
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from govoplan_reporting.backend.query_engine import (
QueryResult,
ReportingQueryError,
infer_query_schema,
)
from govoplan_reporting.backend.schemas import (
DatasetDefinition,
DimensionDefinition,
FilterClause,
MeasureDefinition,
ReportQuery,
SemanticModelDefinition,
TypedExpression,
)
POSTGRES_PLANNER_VERSION = "reporting-postgresql-v1"
_IDENTIFIER = re.compile(r"^[a-z0-9._-]{1,120}$")
class PostgresPlanningError(ReportingQueryError):
pass
def execute_postgres_query(
session: Session,
*,
rows: Sequence[Mapping[str, object]],
dataset: DatasetDefinition,
semantic_model: SemanticModelDefinition,
query: ReportQuery,
) -> QueryResult | None:
"""Execute a bounded semantic plan in PostgreSQL, or return None for fallback."""
if session.bind is None or session.bind.dialect.name != "postgresql":
return None
if query.mode == "pivot":
return None
plan = compile_postgres_query(dataset, semantic_model, query)
parameters = {
**plan.parameters,
"rows_json": json.dumps(
[_json_value(dict(item)) for item in rows],
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
),
"result_limit": query.limit,
"result_offset": query.offset,
}
result = session.execute(text(plan.sql), parameters).mappings().all()
total_rows = int(result[0]["__reporting_total"]) if result else 0
output = tuple(
{
str(key): _json_value(value)
for key, value in item.items()
if key != "__reporting_total"
}
for item in result
)
return QueryResult(
rows=output,
total_rows=total_rows,
schema=infer_query_schema(output),
truncated=query.offset + len(output) < total_rows,
diagnostics=(
{
"severity": "info",
"code": "postgresql_semantic_plan",
"message": "Filters, grouping, measures, ordering, and bounds were executed by the PostgreSQL Reporting planner.",
"planner_version": POSTGRES_PLANNER_VERSION,
},
),
)
class CompiledPostgresPlan:
__slots__ = ("sql", "parameters")
def __init__(self, sql: str, parameters: Mapping[str, object]) -> None:
self.sql = sql
self.parameters = dict(parameters)
def compile_postgres_query(
dataset: DatasetDefinition,
semantic_model: SemanticModelDefinition,
query: ReportQuery,
) -> CompiledPostgresPlan:
dimensions = {item.key: item for item in semantic_model.dimensions}
measures = {item.key: item for item in semantic_model.measures}
selected_dimensions = tuple(query.dimensions or semantic_model.default_dimensions)
selected_measures = tuple(query.measures or semantic_model.default_measures)
_known(selected_dimensions, dimensions, "dimensions")
_known(selected_measures, measures, "measures")
_known(
tuple(item.dimension for item in query.filters), dimensions, "filter dimensions"
)
selected_keys = set(selected_dimensions)
if query.mode != "detail":
selected_keys.update(selected_measures)
_known(
tuple(item.key for item in query.sort),
{key: True for key in selected_keys},
"sort fields",
)
parameters: dict[str, object] = {}
source = (
"WITH source AS ("
"SELECT value AS source_row "
"FROM jsonb_array_elements(CAST(:rows_json AS jsonb)) AS source_items(value)"
")"
)
where = _filter_sql(query.filters, dimensions, parameters)
if query.mode == "detail":
fields = selected_dimensions
if not fields:
if not dataset.fields:
raise PostgresPlanningError(
"PostgreSQL detail planning requires selected dimensions or a pinned dataset schema."
)
field_types = {item.name: item.type for item in dataset.fields}
projections = [
f"{_source_value(item.name, item.type, parameters, f'detail_{index}')} AS {_quote(item.name)}"
for index, item in enumerate(dataset.fields)
]
selected_keys = set(field_types)
else:
projections = [
f"{_dimension_value(dimensions[key], parameters, f'detail_{index}')} AS {_quote(key)}"
for index, key in enumerate(fields)
]
body = "SELECT " + ", ".join(projections) + " FROM source" + where
else:
dimension_projections = [
(
key,
_dimension_value(dimensions[key], parameters, f"dimension_{index}"),
)
for index, key in enumerate(selected_dimensions)
]
selected_base_keys = [
key
for key in selected_measures
if measures[key].aggregation != "calculated"
]
calculated = [
measures[key]
for key in selected_measures
if measures[key].aggregation == "calculated"
]
dependency_keys = list(
dict.fromkeys(
dependency
for item in calculated
for dependency in _calculated_dependencies(
item.expression, measures, stack=(item.key,)
)
)
)
base_measure_keys = list(dict.fromkeys((*selected_base_keys, *dependency_keys)))
base_measures = [measures[key] for key in base_measure_keys]
grouped_select = [
f"{expression} AS {_quote(key)}"
for key, expression in dimension_projections
] + [
f"{_aggregate_sql(item, parameters, index)} AS {_quote(item.key)}"
for index, item in enumerate(base_measures)
]
if not grouped_select:
raise PostgresPlanningError(
"Summary queries require at least one dimension or measure."
)
grouped = "SELECT " + ", ".join(grouped_select) + " FROM source" + where
if dimension_projections:
grouped += " GROUP BY " + ", ".join(
expression for _key, expression in dimension_projections
)
if calculated:
outer = [_quote(key) for key in selected_dimensions] + [
_quote(key) for key in selected_base_keys
]
outer.extend(
f"{_calculated_sql(item.expression, parameters, f'calculated_{index}', measures=measures, stack=(item.key,))} AS {_quote(item.key)}"
for index, item in enumerate(calculated)
)
body = "SELECT " + ", ".join(outer) + f" FROM ({grouped}) AS grouped"
else:
body = grouped
order = ""
if query.sort:
order = " ORDER BY " + ", ".join(
f"{_quote(item.key)} {item.direction.upper()} NULLS LAST"
for item in query.sort
)
sql = (
source
+ " SELECT planned.*, COUNT(*) OVER() AS __reporting_total FROM ("
+ body
+ ") AS planned"
+ order
+ " LIMIT :result_limit OFFSET :result_offset"
)
return CompiledPostgresPlan(sql, parameters)
def _filter_sql(
filters: Sequence[FilterClause],
dimensions: Mapping[str, DimensionDefinition],
parameters: dict[str, object],
) -> str:
clauses: list[str] = []
for index, clause in enumerate(filters):
value = _dimension_value(
dimensions[clause.dimension], parameters, f"filter_field_{index}"
)
prefix = f"filter_{index}"
if clause.operator == "is_null":
clauses.append(f"{value} IS NULL")
continue
if clause.operator == "not_null":
clauses.append(f"{value} IS NOT NULL")
continue
if clause.operator in {"in", "not_in"}:
if not isinstance(clause.value, (list, tuple)):
raise PostgresPlanningError("Set filters require a list value.")
if not clause.value or len(clause.value) > 500:
raise PostgresPlanningError(
"Set filters require between 1 and 500 values."
)
names: list[str] = []
for item_index, item in enumerate(clause.value):
name = f"{prefix}_{item_index}"
parameters[name] = item
names.append(f":{name}")
operator = "NOT IN" if clause.operator == "not_in" else "IN"
clauses.append(f"{value} {operator} ({', '.join(names)})")
continue
if clause.operator == "between":
if not isinstance(clause.value, (list, tuple)) or len(clause.value) != 2:
raise PostgresPlanningError("Between filters require two values.")
parameters[f"{prefix}_low"] = clause.value[0]
parameters[f"{prefix}_high"] = clause.value[1]
clauses.append(f"{value} BETWEEN :{prefix}_low AND :{prefix}_high")
continue
parameters[prefix] = clause.value
if clause.operator == "contains":
parameters[prefix] = f"%{_like(str(clause.value or ''))}%"
clauses.append(
f"LOWER(CAST({value} AS text)) LIKE LOWER(:{prefix}) ESCAPE '\\'"
)
elif clause.operator == "starts_with":
parameters[prefix] = f"{_like(str(clause.value or ''))}%"
clauses.append(
f"LOWER(CAST({value} AS text)) LIKE LOWER(:{prefix}) ESCAPE '\\'"
)
else:
operator = {
"eq": "=",
"ne": "<>",
"gt": ">",
"gte": ">=",
"lt": "<",
"lte": "<=",
}.get(clause.operator)
if operator is None:
raise PostgresPlanningError(
f"Unsupported PostgreSQL filter operator: {clause.operator}."
)
clauses.append(f"{value} {operator} :{prefix}")
return " WHERE " + " AND ".join(clauses) if clauses else ""
def _aggregate_sql(
measure: MeasureDefinition,
parameters: dict[str, object],
index: int,
) -> str:
if measure.aggregation == "count" and measure.field is None:
return "COUNT(*)"
field = _source_value(
measure.field or "",
"number" if measure.aggregation in {"sum", "average"} else "string",
parameters,
f"measure_{index}",
)
if measure.aggregation == "count":
return f"COUNT({field})"
if measure.aggregation == "count_distinct":
return f"COUNT(DISTINCT {field})"
function = {
"sum": "SUM",
"average": "AVG",
"minimum": "MIN",
"maximum": "MAX",
}.get(measure.aggregation)
if function is None:
raise PostgresPlanningError(
f"Unsupported PostgreSQL aggregation: {measure.aggregation}."
)
return f"{function}({field})"
def _calculated_sql(
expression: TypedExpression | None,
parameters: dict[str, object],
prefix: str,
*,
measures: Mapping[str, MeasureDefinition],
stack: tuple[str, ...],
) -> str:
if expression is None:
return "NULL"
if expression.op == "literal":
parameters[prefix] = expression.value
return f":{prefix}"
if expression.op == "measure":
reference = expression.ref or ""
target = measures.get(reference)
if target is None:
raise PostgresPlanningError(
f"Calculated measure references unknown measure: {reference}."
)
if target.aggregation != "calculated":
return _quote(reference)
if reference in stack:
raise PostgresPlanningError(
"Calculated measure dependency cycle: "
+ " -> ".join((*stack, reference))
)
return _calculated_sql(
target.expression,
parameters,
prefix + "_" + reference,
measures=measures,
stack=(*stack, reference),
)
if expression.op == "field":
raise PostgresPlanningError(
"Calculated aggregate measures may reference measures, not source fields."
)
values = [
_calculated_sql(
item,
parameters,
f"{prefix}_{index}",
measures=measures,
stack=stack,
)
for index, item in enumerate(expression.args)
]
if expression.op in {"add", "multiply", "and", "or"}:
operator = {"add": "+", "multiply": "*", "and": "AND", "or": "OR"}[
expression.op
]
return "(" + f" {operator} ".join(values) + ")"
if expression.op in {"subtract", "divide", "eq", "ne", "gt", "gte", "lt", "lte"}:
if len(values) != 2:
raise PostgresPlanningError(
f"Expression {expression.op} requires exactly two arguments."
)
operator = {
"subtract": "-",
"divide": "/",
"eq": "=",
"ne": "<>",
"gt": ">",
"gte": ">=",
"lt": "<",
"lte": "<=",
}[expression.op]
right = f"NULLIF({values[1]}, 0)" if expression.op == "divide" else values[1]
return f"({values[0]} {operator} {right})"
if expression.op == "not":
if len(values) != 1:
raise PostgresPlanningError("Expression not requires one argument.")
return f"(NOT {values[0]})"
if expression.op == "coalesce":
return "COALESCE(" + ", ".join(values) + ")"
if expression.op == "case":
if len(values) < 3 or len(values) % 2 == 0:
raise PostgresPlanningError(
"Case expressions require condition/value pairs and a default."
)
branches = " ".join(
f"WHEN {values[index]} THEN {values[index + 1]}"
for index in range(0, len(values) - 1, 2)
)
return f"(CASE {branches} ELSE {values[-1]} END)"
raise PostgresPlanningError(
f"Unsupported PostgreSQL expression operator: {expression.op}."
)
def _calculated_dependencies(
expression: TypedExpression | None,
measures: Mapping[str, MeasureDefinition],
*,
stack: tuple[str, ...],
) -> tuple[str, ...]:
if expression is None:
return ()
if expression.op == "measure":
reference = expression.ref or ""
target = measures.get(reference)
if target is None:
raise PostgresPlanningError(
f"Calculated measure references unknown measure: {reference}."
)
if target.aggregation != "calculated":
return (reference,)
if reference in stack:
raise PostgresPlanningError(
"Calculated measure dependency cycle: "
+ " -> ".join((*stack, reference))
)
return _calculated_dependencies(
target.expression,
measures,
stack=(*stack, reference),
)
dependencies: list[str] = []
for item in expression.args:
dependencies.extend(_calculated_dependencies(item, measures, stack=stack))
return tuple(dict.fromkeys(dependencies))
def _dimension_value(
dimension: DimensionDefinition,
parameters: dict[str, object],
prefix: str,
) -> str:
return _source_value(dimension.field, dimension.type, parameters, prefix)
def _source_value(
field: str,
field_type: str,
parameters: dict[str, object],
prefix: str,
) -> str:
parameters[prefix] = field
raw = f"source_row ->> :{prefix}"
if field_type == "integer":
return f"NULLIF({raw}, '')::bigint"
if field_type == "number":
return f"NULLIF({raw}, '')::numeric"
if field_type == "boolean":
return f"NULLIF({raw}, '')::boolean"
if field_type == "date":
return f"NULLIF({raw}, '')::date"
if field_type == "datetime":
return f"NULLIF({raw}, '')::timestamptz"
if field_type == "json":
return f"source_row -> :{prefix}"
return raw
def _known(keys: Sequence[str], available: Mapping[str, object], label: str) -> None:
unknown = set(keys) - set(available)
if unknown:
raise PostgresPlanningError(
f"Report query references unknown {label}: " + ", ".join(sorted(unknown))
)
def _quote(value: str) -> str:
if not _IDENTIFIER.fullmatch(value):
raise PostgresPlanningError(f"Unsafe Reporting identifier: {value!r}.")
return '"' + value.replace('"', '""') + '"'
def _like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _json_value(value: object) -> Any:
if isinstance(value, Decimal):
integral = value.to_integral_value()
return int(integral) if value == integral else float(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_value(item) for item in value]
return value
__all__ = [
"POSTGRES_PLANNER_VERSION",
"CompiledPostgresPlan",
"PostgresPlanningError",
"compile_postgres_query",
"execute_postgres_query",
]
@@ -0,0 +1,835 @@
"""Governed execution boundary for module-contributed reports."""
from __future__ import annotations
from collections.abc import Mapping
import csv
from datetime import UTC, date, datetime, timedelta
from hashlib import sha256
import io
import json
import uuid
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.reporting import (
ReportDescriptor,
ReportParameterOption,
ReportProvider,
ReportProviderRequest,
ReportingGovernanceDecision,
ReportingGovernanceRequest,
report_providers,
reporting_governance_provider,
)
from govoplan_reporting.backend.db.models import (
ReportingProviderExecution,
ReportingProviderExport,
)
class ProviderReportError(RuntimeError):
pass
def list_provider_reports(
session: Session,
principal: object,
*,
registry: object | None,
) -> dict[str, object]:
reports: list[dict[str, object]] = []
diagnostics: list[dict[str, str]] = []
for provider_id, provider in report_providers(registry):
try:
for descriptor in provider.list_reports(session, principal):
_validate_descriptor(provider_id, descriptor)
decision = _governance_decision(
session,
principal,
registry=registry,
descriptor=descriptor,
action="catalogue",
purpose=None,
audience_scope={},
)
reports.append(
{
**descriptor.to_dict(),
"available": decision.allowed,
"unavailable_reason": decision.reason,
"governance": _decision_payload(decision),
}
)
except Exception as exc: # noqa: BLE001 - isolate optional providers.
diagnostics.append(
{
"provider_id": provider_id,
"code": "provider_catalogue_failed",
"message": "The provider catalogue could not be loaded.",
"error_type": type(exc).__name__,
}
)
return {
"reports": sorted(
reports,
key=lambda item: (str(item["title"]), str(item["provider_id"])),
),
"diagnostics": diagnostics,
}
def provider_parameter_options(
session: Session,
principal: object,
*,
registry: object | None,
provider_id: str,
report_id: str,
parameter_key: str,
query: str,
limit: int,
) -> tuple[ReportParameterOption, ...]:
provider, descriptor = _provider_report(
session,
principal,
registry=registry,
provider_id=provider_id,
report_id=report_id,
)
parameter = next(
(item for item in descriptor.parameters if item.key == parameter_key),
None,
)
if parameter is None or not parameter.options_from_provider:
raise ProviderReportError("This report parameter has no provider options")
return provider.parameter_options(
session,
principal,
report_id=report_id,
parameter_key=parameter_key,
query=query,
limit=max(1, min(limit, 200)),
)
def execute_provider_report(
session: Session,
principal: object,
*,
registry: object | None,
provider_id: str,
report_id: str,
parameters: Mapping[str, object],
purpose: str,
audience_scope: Mapping[str, object],
idempotency_key: str,
) -> dict[str, object]:
provider, descriptor = _provider_report(
session,
principal,
registry=registry,
provider_id=provider_id,
report_id=report_id,
)
clean_purpose = purpose.strip()
clean_idempotency_key = idempotency_key.strip()
clean_parameters = _validated_parameters(descriptor, parameters)
clean_audience = _json_mapping(audience_scope, "audience scope")
if descriptor.purpose_required and not clean_purpose:
raise ProviderReportError("A report purpose is required")
if descriptor.audience_scope_required and not clean_audience:
raise ProviderReportError("An effective audience scope is required")
request_hash = _hash(
{
"provider_id": provider_id,
"report_id": report_id,
"revision": descriptor.revision,
"parameters": clean_parameters,
"purpose": clean_purpose,
"audience_scope": clean_audience,
}
)
replay = (
session.query(ReportingProviderExecution)
.filter(
ReportingProviderExecution.tenant_id == _tenant(principal),
ReportingProviderExecution.idempotency_key == clean_idempotency_key,
)
.one_or_none()
)
if replay is not None:
if replay.request_sha256 != request_hash:
raise ProviderReportError(
"The provider-report idempotency key belongs to another request"
)
if _expired(replay) or replay.retention_redacted_at is not None:
raise ProviderReportError(
"The replayed provider-report result has expired; use a new idempotency key"
)
_require_result_access(provider, session, principal, replay)
return provider_execution_payload(replay)
preflight = _governance_decision(
session,
principal,
registry=registry,
descriptor=descriptor,
action="execute",
purpose=clean_purpose,
audience_scope=clean_audience,
)
_require_allowed(preflight)
result = provider.execute_report(
session,
principal,
request=ReportProviderRequest(
report_id=report_id,
parameters=clean_parameters,
purpose=clean_purpose,
audience_scope=clean_audience,
),
)
_validate_result(descriptor, result, tenant_id=_tenant(principal))
decision = _governance_decision(
session,
principal,
registry=registry,
descriptor=descriptor,
action="execute",
purpose=clean_purpose,
audience_scope=clean_audience,
applied_privacy_transforms=result.applied_privacy_transforms,
)
_require_allowed(decision)
missing_policy_transforms = set(decision.required_privacy_transforms) - set(
result.applied_privacy_transforms
)
if missing_policy_transforms:
raise ProviderReportError(
"The report omitted Policy-required privacy transformations: "
+ ", ".join(sorted(missing_policy_transforms))
)
generated_at = _aware(result.generated_at)
result_payload = _json_mapping(result.payload, "provider report payload")
expires_at = (
generated_at + timedelta(days=decision.retention_days)
if decision.retention_days is not None
else None
)
row = ReportingProviderExecution(
tenant_id=_tenant(principal),
execution_id=str(uuid.uuid4()),
provider_id=provider_id,
report_id=report_id,
report_revision=descriptor.revision,
contract_version=descriptor.contract_version,
idempotency_key=clean_idempotency_key,
request_sha256=request_hash,
purpose=clean_purpose,
audience_scope=clean_audience,
parameters=clean_parameters,
result_schema=[item.to_dict() for item in descriptor.result_schema],
result_payload=result_payload,
source_revisions=[dict(item) for item in result.source_revisions],
effective_scope=dict(result.effective_scope),
privacy_transforms=list(result.applied_privacy_transforms),
provenance=dict(result.provenance),
governance_provenance=dict(decision.provenance),
retention_class=descriptor.retention_class,
retention_days=decision.retention_days,
expires_at=expires_at,
output_hash=_hash(result_payload),
generated_at=generated_at,
actor_id=_actor(principal),
)
session.add(row)
session.flush()
audit_from_principal(
session,
principal,
action="reporting.provider_report.executed",
object_type="reporting_provider_execution",
object_id=row.execution_id,
details={
"provider_id": provider_id,
"report_id": report_id,
"report_revision": descriptor.revision,
"purpose": clean_purpose,
"audience_scope": clean_audience,
"source_revision_count": len(row.source_revisions),
"privacy_transforms": row.privacy_transforms,
"retention_class": row.retention_class,
"retention_days": row.retention_days,
"output_hash": row.output_hash,
},
)
return provider_execution_payload(row)
def get_provider_execution(
session: Session,
principal: object,
*,
registry: object | None,
execution_id: str,
) -> dict[str, object] | None:
row = _provider_execution(session, principal, execution_id=execution_id)
if row is None or _expired(row):
return None
provider, _descriptor = _provider_report(
session,
principal,
registry=registry,
provider_id=row.provider_id,
report_id=row.report_id,
)
_require_result_access(provider, session, principal, row)
return provider_execution_payload(row)
def export_provider_execution(
session: Session,
principal: object,
*,
registry: object | None,
execution_id: str,
format: str,
purpose: str,
audience_scope: Mapping[str, object],
) -> tuple[bytes, str, str]:
row = _provider_execution(session, principal, execution_id=execution_id)
if row is None or _expired(row):
raise LookupError("Provider report execution not found")
provider, descriptor = _provider_report(
session,
principal,
registry=registry,
provider_id=row.provider_id,
report_id=row.report_id,
)
_require_result_access(provider, session, principal, row)
clean_format = format.strip().lower()
if clean_format not in descriptor.export_formats:
raise ProviderReportError("This report does not support the export format")
clean_purpose = purpose.strip()
clean_audience = _json_mapping(audience_scope, "audience scope")
decision = _governance_decision(
session,
principal,
registry=registry,
descriptor=descriptor,
action="export",
purpose=clean_purpose,
audience_scope=clean_audience,
export_format=clean_format,
applied_privacy_transforms=tuple(row.privacy_transforms or ()),
)
_require_allowed(decision)
if clean_format not in decision.export_formats:
raise ProviderReportError("Policy does not allow this export format")
content, media_type, extension = _serialize_export(
clean_format,
row.result_payload,
row.result_schema,
)
output_hash = sha256(content).hexdigest()
export = ReportingProviderExport(
tenant_id=row.tenant_id,
export_id=str(uuid.uuid4()),
provider_execution_id=row.id,
execution_id=row.execution_id,
format=clean_format,
purpose=clean_purpose,
audience_scope=clean_audience,
output_hash=output_hash,
exported_at=datetime.now(UTC),
actor_id=_actor(principal),
)
session.add(export)
session.flush()
audit_from_principal(
session,
principal,
action="reporting.provider_report.exported",
object_type="reporting_provider_execution",
object_id=row.execution_id,
details={
"export_id": export.export_id,
"format": clean_format,
"purpose": clean_purpose,
"audience_scope": clean_audience,
"output_hash": output_hash,
},
)
filename = f"{row.provider_id}-{row.report_id}-{row.execution_id}.{extension}"
return content, media_type, filename
def list_provider_exports(
session: Session,
principal: object,
*,
registry: object | None,
execution_id: str,
) -> tuple[dict[str, object], ...]:
row = _provider_execution(session, principal, execution_id=execution_id)
if row is None or _expired(row):
return ()
provider, _descriptor = _provider_report(
session,
principal,
registry=registry,
provider_id=row.provider_id,
report_id=row.report_id,
)
_require_result_access(provider, session, principal, row)
exports = (
session.query(ReportingProviderExport)
.filter(
ReportingProviderExport.tenant_id == row.tenant_id,
ReportingProviderExport.provider_execution_id == row.id,
)
.order_by(ReportingProviderExport.exported_at.desc())
.all()
)
return tuple(
{
"export_id": item.export_id,
"execution_id": item.execution_id,
"format": item.format,
"purpose": item.purpose,
"audience_scope": dict(item.audience_scope or {}),
"output_hash": item.output_hash,
"exported_at": item.exported_at.isoformat(),
"actor_id": item.actor_id,
}
for item in exports
)
def provider_execution_payload(row: ReportingProviderExecution) -> dict[str, object]:
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,
"purpose": row.purpose,
"audience_scope": dict(row.audience_scope or {}),
"parameters": dict(row.parameters or {}),
"result_schema": list(row.result_schema or []),
"result": dict(row.result_payload or {}),
"source_revisions": list(row.source_revisions or []),
"effective_scope": dict(row.effective_scope or {}),
"privacy_transforms": list(row.privacy_transforms or []),
"provenance": dict(row.provenance or {}),
"governance_provenance": dict(row.governance_provenance or {}),
"retention_class": row.retention_class,
"retention_days": row.retention_days,
"expires_at": row.expires_at.isoformat() if row.expires_at else None,
"output_hash": row.output_hash,
"generated_at": row.generated_at.isoformat(),
"actor_id": row.actor_id,
}
def _provider_report(
session: Session,
principal: object,
*,
registry: object | None,
provider_id: str,
report_id: str,
) -> tuple[ReportProvider, ReportDescriptor]:
provider = dict(report_providers(registry)).get(provider_id)
if provider is None:
raise LookupError("Report provider is unavailable")
descriptor = next(
(
item
for item in provider.list_reports(session, principal)
if item.report_id == report_id
),
None,
)
if descriptor is None:
raise LookupError("Provider report is unavailable")
_validate_descriptor(provider_id, descriptor)
return provider, descriptor
def _validate_descriptor(provider_id: str, descriptor: ReportDescriptor) -> None:
if descriptor.provider_id != provider_id:
raise ProviderReportError("Report descriptor provider id differs")
if not descriptor.report_id or not descriptor.revision:
raise ProviderReportError("Report descriptors require stable ids and revisions")
parameter_keys = [item.key for item in descriptor.parameters]
field_paths = [item.path for item in descriptor.result_schema]
transform_ids = [item.id for item in descriptor.privacy_transforms]
if len(parameter_keys) != len(set(parameter_keys)):
raise ProviderReportError("Report parameter keys must be unique")
if not field_paths or len(field_paths) != len(set(field_paths)):
raise ProviderReportError("Report result paths must be non-empty and unique")
if len(transform_ids) != len(set(transform_ids)):
raise ProviderReportError("Report privacy transforms must be unique")
if any(item.sensitive for item in descriptor.result_schema) and (
descriptor.reidentification_risk != "high"
):
raise ProviderReportError(
"Sensitive result fields require high risk classification"
)
def _validate_result(
descriptor: ReportDescriptor,
result: object,
*,
tenant_id: str,
) -> None:
if not hasattr(result, "report_id") or result.report_id != descriptor.report_id:
raise ProviderReportError("Provider result report id differs")
if not result.source_revisions:
raise ProviderReportError("Provider result has no source revision provenance")
if str(result.effective_scope.get("tenant_id") or "") != tenant_id:
raise ProviderReportError(
"Provider result effective scope differs from the tenant"
)
required = {item.id for item in descriptor.privacy_transforms if item.required}
missing = required - set(result.applied_privacy_transforms)
if missing:
raise ProviderReportError(
"Provider result omitted required privacy transformations: "
+ ", ".join(sorted(missing))
)
declared = {item.path: item for item in descriptor.result_schema}
for field in descriptor.result_schema:
if field.type != "suppressed_count":
continue
value = _path_value(result.payload, field.path)
if not isinstance(value, Mapping) or set(value) != {"value", "suppressed"}:
raise ProviderReportError(
f"Provider result has an invalid suppressed count: {field.path}"
)
if value["value"] is not None and (
not isinstance(value["value"], int) or isinstance(value["value"], bool)
):
raise ProviderReportError(
f"Provider result has an invalid suppressed-count value: {field.path}"
)
if not isinstance(value["suppressed"], bool):
raise ProviderReportError(
f"Provider result has an invalid suppression flag: {field.path}"
)
for path in _leaf_paths(result.payload):
if path in declared:
continue
if any(
path.startswith(declared_path + ".")
and field.type in {"object", "suppressed_count"}
for declared_path, field in declared.items()
):
continue
raise ProviderReportError(f"Provider result contains undeclared field: {path}")
def _validated_parameters(
descriptor: ReportDescriptor,
parameters: Mapping[str, object],
) -> dict[str, object]:
clean = _json_mapping(parameters, "report parameters")
declared = {item.key: item for item in descriptor.parameters}
unknown = set(clean) - set(declared)
if unknown:
raise ProviderReportError(
"Unknown report parameters: " + ", ".join(sorted(unknown))
)
missing = [
item.key
for item in descriptor.parameters
if item.required and clean.get(item.key) in (None, "")
]
if missing:
raise ProviderReportError(
"Missing report parameters: " + ", ".join(sorted(missing))
)
invalid = [
item.key
for item in descriptor.parameters
if item.key in clean
and clean[item.key] is not None
and not _valid_parameter_value(item.type, clean[item.key])
]
if invalid:
raise ProviderReportError(
"Invalid report parameter types: " + ", ".join(sorted(invalid))
)
return clean
def _valid_parameter_value(parameter_type: str, value: object) -> bool:
if parameter_type in {"string", "reference"}:
return isinstance(value, str)
if parameter_type == "boolean":
return isinstance(value, bool)
if parameter_type == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if parameter_type == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if parameter_type == "date":
if not isinstance(value, str):
return False
try:
date.fromisoformat(value)
except ValueError:
return False
return True
if parameter_type == "datetime":
if not isinstance(value, str):
return False
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return False
return True
return False
def _governance_decision(
session: Session,
principal: object,
*,
registry: object | None,
descriptor: ReportDescriptor,
action: str,
purpose: str | None,
audience_scope: Mapping[str, object],
export_format: str | None = None,
applied_privacy_transforms: tuple[str, ...] = (),
) -> ReportingGovernanceDecision:
required = tuple(item.id for item in descriptor.privacy_transforms if item.required)
provider = reporting_governance_provider(registry)
if provider is None:
allowed = descriptor.reidentification_risk != "high"
if descriptor.purpose_required and action != "catalogue" and not purpose:
allowed = False
if (
descriptor.audience_scope_required
and action != "catalogue"
and not audience_scope
):
allowed = False
if action == "export" and export_format not in descriptor.export_formats:
allowed = False
return ReportingGovernanceDecision(
allowed=allowed,
reason=None
if allowed
else "The built-in Reporting privacy baseline denied this action.",
retention_days=30,
export_formats=descriptor.export_formats,
required_privacy_transforms=required,
provenance={"provider": "reporting.built_in_baseline", "version": "1"},
)
decision = provider.decide_reporting_action(
session,
principal,
request=ReportingGovernanceRequest(
action=action, # type: ignore[arg-type]
tenant_id=_tenant(principal),
provider_id=descriptor.provider_id,
report_id=descriptor.report_id,
purpose=purpose,
audience_scope=audience_scope,
retention_class=descriptor.retention_class,
export_format=export_format,
reidentification_risk=descriptor.reidentification_risk,
declared_privacy_transforms=required,
applied_privacy_transforms=applied_privacy_transforms,
),
)
unsupported = set(decision.required_privacy_transforms) - {
item.id for item in descriptor.privacy_transforms
}
if not unsupported:
return decision
return ReportingGovernanceDecision(
allowed=False,
reason=(
"Policy requires privacy transformations this report does not support: "
+ ", ".join(sorted(unsupported))
),
retention_days=decision.retention_days,
export_formats=decision.export_formats,
required_privacy_transforms=decision.required_privacy_transforms,
provenance={
**dict(decision.provenance),
"unsupported_required_privacy_transforms": sorted(unsupported),
},
)
def _serialize_export(
format: str,
payload: Mapping[str, object],
schema: list[dict[str, object]],
) -> tuple[bytes, str, str]:
if format == "json":
return (
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True).encode(
"utf-8"
),
"application/json",
"json",
)
if format != "csv":
raise ProviderReportError("Unsupported provider report export format")
output = io.StringIO(newline="")
writer = csv.writer(output)
writer.writerow([str(item["label"]) for item in schema])
writer.writerow(
[_csv_value(_path_value(payload, str(item["path"]))) for item in schema]
)
return output.getvalue().encode("utf-8-sig"), "text/csv; charset=utf-8", "csv"
def _csv_value(value: object) -> object:
if isinstance(value, Mapping):
if value.get("suppressed") is True:
return "suppressed"
if set(value).issuperset({"value", "suppressed"}):
value = value.get("value")
else:
value = json.dumps(value, ensure_ascii=False, sort_keys=True)
if isinstance(value, (list, tuple)):
value = json.dumps(value, ensure_ascii=False, sort_keys=True)
if isinstance(value, str) and value.startswith(("=", "+", "-", "@", "\t", "\r")):
return "'" + value
return "" if value is None else value
def _path_value(payload: Mapping[str, object], path: str) -> object:
value: object = payload
for part in path.split("."):
if not isinstance(value, Mapping):
return None
value = value.get(part)
return value
def _leaf_paths(value: object, prefix: str = "") -> tuple[str, ...]:
if isinstance(value, Mapping):
rows: list[str] = []
for key, item in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
rows.extend(_leaf_paths(item, path))
return tuple(rows)
if isinstance(value, (list, tuple)):
return (prefix,)
return (prefix,)
def _provider_execution(
session: Session,
principal: object,
*,
execution_id: str,
) -> ReportingProviderExecution | None:
return (
session.query(ReportingProviderExecution)
.filter(
ReportingProviderExecution.tenant_id == _tenant(principal),
ReportingProviderExecution.execution_id == execution_id,
)
.one_or_none()
)
def _require_result_access(
provider: ReportProvider,
session: Session,
principal: object,
row: ReportingProviderExecution,
) -> None:
if not provider.authorize_result(
session,
principal,
report_id=row.report_id,
source_revisions=tuple(row.source_revisions or ()),
effective_scope=dict(row.effective_scope or {}),
):
raise PermissionError(
"The source module no longer permits access to this report result"
)
def _expired(row: ReportingProviderExecution) -> bool:
if row.expires_at is None:
return False
return _aware(row.expires_at) <= datetime.now(UTC)
def _require_allowed(decision: ReportingGovernanceDecision) -> None:
if not decision.allowed:
raise PermissionError(decision.reason or "Reporting Policy denied this action")
def _decision_payload(decision: ReportingGovernanceDecision) -> dict[str, object]:
return {
"allowed": decision.allowed,
"reason": decision.reason,
"retention_days": decision.retention_days,
"export_formats": list(decision.export_formats),
"required_privacy_transforms": list(decision.required_privacy_transforms),
"provenance": dict(decision.provenance),
}
def _json_mapping(value: Mapping[str, object], label: str) -> dict[str, object]:
try:
payload = json.loads(json.dumps(value, sort_keys=True, separators=(",", ":")))
except (TypeError, ValueError) as exc:
raise ProviderReportError(f"The {label} must contain JSON values") from exc
if not isinstance(payload, dict):
raise ProviderReportError(f"The {label} must be an object")
return payload
def _hash(value: object) -> str:
return sha256(
json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
def _tenant(principal: object) -> str:
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id:
raise PermissionError("A tenant principal is required")
return tenant_id
def _actor(principal: object) -> str | None:
user = getattr(principal, "user", None)
actor_id = getattr(user, "id", None)
return str(actor_id) if actor_id else None
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
__all__ = [
"ProviderReportError",
"execute_provider_report",
"export_provider_execution",
"get_provider_execution",
"list_provider_exports",
"list_provider_reports",
"provider_parameter_options",
]
@@ -0,0 +1,303 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
import csv
from html import escape
from io import StringIO
import json
import re
from govoplan_core.core.files import (
CAPABILITY_FILES_ARTIFACT_STORE,
ManagedArtifactStore,
ManagedArtifactWriteRequest,
)
from govoplan_core.core.mail import (
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
NotificationMailDeliveryProvider,
NotificationMailDeliveryRequest,
)
from govoplan_reporting.backend.contracts import (
CAPABILITY_REPORTING_PUBLICATION_FILES,
CAPABILITY_REPORTING_PUBLICATION_MAIL,
ReportingPublicationPayload,
capability,
)
class FilesReportingPublicationTarget:
def __init__(self, registry: object | None) -> None:
self.registry = registry
def publish_report(
self,
session: object,
principal: object,
*,
payload: ReportingPublicationPayload,
) -> Mapping[str, object]:
provider = capability(self.registry, CAPABILITY_FILES_ARTIFACT_STORE)
if not isinstance(provider, ManagedArtifactStore):
raise RuntimeError(
"Files publication requires the enabled files.artifact_store capability."
)
content, content_type, extension = _serialize(payload)
filename = _filename(payload, extension)
folder = str(payload.target_ref or "Generated/Reports").strip()
stored = provider.store_artifact(
session,
principal,
request=ManagedArtifactWriteRequest(
filename=filename,
payload=content,
content_type=content_type,
folder=folder,
description=(
f"Reporting publication for {payload.report_id} revision "
f"{payload.report_revision}."
),
idempotency_key=f"reporting:{payload.publication_id}",
metadata={
"producer_module": "reporting",
"publication_id": payload.publication_id,
"execution_id": payload.execution_id,
"report_id": payload.report_id,
"report_revision": payload.report_revision,
"output_hash": payload.output_hash,
},
),
)
return {
"provider": CAPABILITY_FILES_ARTIFACT_STORE,
"status": "stored",
"file_asset_id": stored.file_asset_id,
"file_version_id": stored.file_version_id,
"filename": stored.filename,
"display_path": stored.display_path,
"sha256": stored.sha256,
"size_bytes": stored.size_bytes,
"output_hash": payload.output_hash,
}
class MailReportingPublicationTarget:
def __init__(self, registry: object | None) -> None:
self.registry = registry
def publish_report(
self,
session: object,
principal: object,
*,
payload: ReportingPublicationPayload,
) -> Mapping[str, object]:
provider = capability(self.registry, CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
if not isinstance(provider, NotificationMailDeliveryProvider):
raise RuntimeError(
"Mail publication requires the enabled mail.notificationDelivery capability."
)
recipient = str(payload.target_ref or "").strip()
if not recipient:
raise ValueError("Mail publication requires a recipient address.")
options = dict(payload.options)
profile_id = _required_option(options, "mail_profile_id", "Mail profile")
from_address = _required_option(options, "from_address", "Sender address")
subject = str(
options.get("subject")
or f"Report {payload.report_id} revision {payload.report_revision}"
).strip()
action_url = str(options.get("action_url") or "").strip() or None
preview = _text_preview(payload.rows, payload.schema)
result = provider.submit_notification_mail(
session,
NotificationMailDeliveryRequest(
tenant_id=payload.tenant_id,
notification_id=f"reporting-publication:{payload.publication_id}",
recipient=recipient,
subject=subject,
body_text=(
f"Report: {payload.report_id}\n"
f"Revision: {payload.report_revision}\n"
f"Rows: {len(payload.rows)}\n"
f"Output hash: {payload.output_hash}\n\n"
f"{preview}"
),
action_url=action_url,
mail_profile_id=profile_id,
from_address=from_address,
smtp_server_id=_optional(options.get("smtp_server_id")),
smtp_credential_id=_optional(options.get("smtp_credential_id")),
metadata={
"producer_module": "reporting",
"publication_id": payload.publication_id,
"execution_id": payload.execution_id,
"report_id": payload.report_id,
"report_revision": payload.report_revision,
"output_hash": payload.output_hash,
},
),
)
status = str(result.get("status") or "").casefold()
if status not in {"accepted", "queued", "submitted", "succeeded"}:
raise RuntimeError(
str(result.get("error") or "Mail did not accept the report publication.")
)
return {
**dict(result),
"publication_id": payload.publication_id,
"recipient": recipient,
"output_hash": payload.output_hash,
}
def publication_target_catalog(registry: object | None) -> tuple[dict[str, object], ...]:
files_available = isinstance(
capability(registry, CAPABILITY_FILES_ARTIFACT_STORE), ManagedArtifactStore
)
mail_available = isinstance(
capability(registry, CAPABILITY_MAIL_NOTIFICATION_DELIVERY),
NotificationMailDeliveryProvider,
)
return (
{
"capability": CAPABILITY_REPORTING_PUBLICATION_FILES,
"label": "Files",
"available": files_available,
"reason": None
if files_available
else "Enable Files with managed artifact storage to publish durable report files.",
"formats": ["csv", "json", "html"],
"target_label": "Folder",
"target_required": False,
"required_options": [],
},
{
"capability": CAPABILITY_REPORTING_PUBLICATION_MAIL,
"label": "Mail",
"available": mail_available,
"reason": None
if mail_available
else "Enable Mail and configure its notification-delivery capability to publish report notices.",
"formats": ["html"],
"target_label": "Recipient",
"target_required": True,
"required_options": ["mail_profile_id", "from_address"],
},
)
def _serialize(payload: ReportingPublicationPayload) -> tuple[bytes, str, str]:
if payload.format == "json":
content = json.dumps(
{
"report_id": payload.report_id,
"report_revision": payload.report_revision,
"execution_id": payload.execution_id,
"output_hash": payload.output_hash,
"schema": list(payload.schema),
"rows": list(payload.rows),
},
ensure_ascii=False,
indent=2,
default=str,
).encode("utf-8")
return content, "application/json", "json"
if payload.format == "csv":
fields = _fields(payload.rows, payload.schema)
stream = StringIO(newline="")
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
for row in payload.rows:
writer.writerow({key: _safe_csv(row.get(key)) for key in fields})
return (
stream.getvalue().encode("utf-8-sig"),
"text/csv; charset=utf-8",
"csv",
)
if payload.format == "html":
fields = _fields(payload.rows, payload.schema)
headers = "".join(f"<th scope=\"col\">{escape(key)}</th>" for key in fields)
body = "".join(
"<tr>"
+ "".join(
f"<td>{escape(_display(row.get(key)))}</td>" for key in fields
)
+ "</tr>"
for row in payload.rows
)
content = (
"<!doctype html><html><head><meta charset=\"utf-8\"><title>"
+ escape(payload.report_id)
+ "</title></head><body><h1>"
+ escape(payload.report_id)
+ f"</h1><p>Revision {payload.report_revision}; output {escape(payload.output_hash)}</p>"
+ f"<table><thead><tr>{headers}</tr></thead><tbody>{body}</tbody></table>"
+ "</body></html>"
)
return content.encode("utf-8"), "text/html; charset=utf-8", "html"
raise ValueError(
"This publication target supports CSV, JSON, and accessible HTML. "
"XLSX and PDF require a renderer provider."
)
def _filename(payload: ReportingPublicationPayload, extension: str) -> str:
configured = str(payload.options.get("filename") or "").strip()
stem = configured.rsplit(".", 1)[0] if configured else payload.report_id
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip(".-") or "report"
return f"{safe}-r{payload.report_revision}.{extension}"
def _fields(
rows: Sequence[Mapping[str, object]], schema: Sequence[Mapping[str, object]]
) -> list[str]:
fields = [str(item.get("name")) for item in schema if item.get("name")]
if fields:
return fields
return list(dict.fromkeys(str(key) for row in rows for key in row))
def _safe_csv(value: object) -> object:
if isinstance(value, (dict, list, tuple)):
value = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if isinstance(value, str) and value.startswith(("=", "+", "-", "@")):
return "'" + value
return value
def _display(value: object) -> str:
if value is None:
return ""
if isinstance(value, (dict, list, tuple)):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
return str(value)
def _text_preview(
rows: Sequence[Mapping[str, object]], schema: Sequence[Mapping[str, object]]
) -> str:
fields = _fields(rows, schema)[:8]
lines = [" | ".join(fields)]
lines.extend(" | ".join(_display(row.get(key)) for key in fields) for row in rows[:10])
if len(rows) > 10:
lines.append(f"... {len(rows) - 10} more rows")
return "\n".join(lines)
def _required_option(options: Mapping[str, object], key: str, label: str) -> str:
value = str(options.get(key) or "").strip()
if not value:
raise ValueError(f"{label} is required for Mail publication.")
return value
def _optional(value: object) -> str | None:
clean = str(value or "").strip()
return clean or None
__all__ = [
"FilesReportingPublicationTarget",
"MailReportingPublicationTarget",
"publication_target_catalog",
]
@@ -0,0 +1,495 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from govoplan_reporting.backend.schemas import (
FilterClause,
MeasureDefinition,
ReportQuery,
SemanticModelDefinition,
TypedExpression,
VisualizationDefinition,
)
QUERY_ENGINE_VERSION = "reporting-query-v1"
class ReportingQueryError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class QueryResult:
rows: tuple[dict[str, Any], ...]
total_rows: int
schema: tuple[dict[str, Any], ...]
truncated: bool
diagnostics: tuple[dict[str, Any], ...] = ()
def execute_semantic_query(
rows: Sequence[Mapping[str, object]],
semantic_model: SemanticModelDefinition,
query: ReportQuery,
) -> QueryResult:
dimensions = {item.key: item for item in semantic_model.dimensions}
measures = {item.key: item for item in semantic_model.measures}
selected_dimensions = tuple(query.dimensions or semantic_model.default_dimensions)
selected_measures = tuple(query.measures or semantic_model.default_measures)
if query.mode == "pivot" and query.pivot is not None:
selected_dimensions = tuple(
dict.fromkeys((*query.pivot.rows, *query.pivot.columns))
)
selected_measures = tuple(query.pivot.measures or selected_measures)
_require_known(selected_dimensions, dimensions, "dimensions")
_require_known(selected_measures, measures, "measures")
_require_known(
tuple(item.dimension for item in query.filters),
dimensions,
"filter dimensions",
)
filtered = tuple(
dict(row)
for row in rows
if all(
_matches_filter(row.get(dimensions[item.dimension].field), item)
for item in query.filters
)
)
if query.mode == "detail":
result_rows = _detail_rows(filtered, selected_dimensions, dimensions)
else:
result_rows = _summary_rows(
filtered,
selected_dimensions,
selected_measures,
dimensions,
measures,
)
if query.mode == "pivot" and query.pivot is not None:
result_rows = _pivot_rows(
result_rows,
row_dimensions=tuple(query.pivot.rows),
column_dimensions=tuple(query.pivot.columns),
measures=selected_measures,
include_totals=query.pivot.include_totals,
)
sorted_rows = _sort_rows(result_rows, query)
total = len(sorted_rows)
selected = sorted_rows[query.offset : query.offset + query.limit]
return QueryResult(
rows=tuple(selected),
total_rows=total,
schema=infer_query_schema(selected or sorted_rows[:1]),
truncated=query.offset + len(selected) < total,
)
class DefaultChartRenderer:
"""Build a provider-neutral chart model with a mandatory table fallback."""
def render(
self,
*,
visualization: VisualizationDefinition,
result: QueryResult,
) -> Mapping[str, object]:
category = visualization.category_dimension
measure_keys = tuple(visualization.measures)
missing: set[str] = set()
if visualization.kind not in {"table", "pivot", "metric"}:
if not category or not measure_keys:
missing = {"chart category and measure configuration"}
else:
available = {str(item.get("name")) for item in result.schema}
missing = {category, *measure_keys} - available
if missing and not visualization.tabular_fallback:
raise ReportingQueryError(
"Chart references unavailable result fields: "
+ ", ".join(sorted(missing))
)
return {
"contract_version": "1",
"kind": "table" if missing else visualization.kind,
"requested_kind": visualization.kind,
"category": category,
"series": visualization.series_dimension,
"measures": list(measure_keys),
"options": dict(visualization.options),
"fallback_reason": (
"The selected query does not expose the fields required by the "
"saved visualization. Showing its accessible table fallback."
if missing
else None
),
"rows": list(result.rows),
"schema": list(result.schema),
"tabular_fallback": (
{
"rows": list(result.rows),
"schema": list(result.schema),
}
if visualization.tabular_fallback or missing
else None
),
}
def _detail_rows(
rows: Sequence[Mapping[str, object]],
selected_dimensions: Sequence[str],
dimensions: Mapping[str, object],
) -> list[dict[str, Any]]:
if not selected_dimensions:
return [
{str(key): _json_value(value) for key, value in row.items()} for row in rows
]
return [
{
key: _json_value(row.get(getattr(dimensions[key], "field")))
for key in selected_dimensions
}
for row in rows
]
def _summary_rows(
rows: Sequence[Mapping[str, object]],
selected_dimensions: Sequence[str],
selected_measures: Sequence[str],
dimensions: Mapping[str, object],
measures: Mapping[str, MeasureDefinition],
) -> list[dict[str, Any]]:
grouped: dict[tuple[object, ...], list[Mapping[str, object]]] = defaultdict(list)
if selected_dimensions:
for row in rows:
key = tuple(
row.get(getattr(dimensions[item], "field"))
for item in selected_dimensions
)
grouped[key].append(row)
else:
grouped[()] = list(rows)
result: list[dict[str, Any]] = []
for group_key, group_rows in grouped.items():
item: dict[str, Any] = {
dimension: _json_value(value)
for dimension, value in zip(selected_dimensions, group_key, strict=True)
}
pending: list[MeasureDefinition] = []
for key in selected_measures:
measure = measures[key]
if measure.aggregation == "calculated":
pending.append(measure)
else:
item[key] = _aggregate(group_rows, measure)
for measure in pending:
item[measure.key] = _evaluate_expression(
measure.expression,
row=None,
measures=item,
)
result.append(item)
return result
def _aggregate(
rows: Sequence[Mapping[str, object]],
measure: MeasureDefinition,
) -> object:
if measure.aggregation == "count":
if measure.field is None:
return len(rows)
return sum(row.get(measure.field) is not None for row in rows)
values = [
row.get(measure.field or "")
for row in rows
if row.get(measure.field or "") is not None
]
if measure.aggregation == "count_distinct":
return len({_hashable(value) for value in values})
if not values:
return None
if measure.aggregation == "sum":
return _numeric_result(sum(_number(value) for value in values))
if measure.aggregation == "average":
return _numeric_result(sum(_number(value) for value in values) / len(values))
if measure.aggregation == "minimum":
return _json_value(min(values))
if measure.aggregation == "maximum":
return _json_value(max(values))
raise ReportingQueryError(
f"Unsupported measure aggregation: {measure.aggregation!r}."
)
def _evaluate_expression(
expression: TypedExpression | None,
*,
row: Mapping[str, object] | None,
measures: Mapping[str, object],
) -> object:
if expression is None:
return None
if expression.op == "literal":
return expression.value
if expression.op == "field":
return row.get(expression.ref or "") if row is not None else None
if expression.op == "measure":
return measures.get(expression.ref or "")
values = [
_evaluate_expression(item, row=row, measures=measures)
for item in expression.args
]
if expression.op == "add":
return _numeric_result(sum(_number(item) for item in values))
if expression.op == "subtract":
_arity(values, 2, expression.op)
return _numeric_result(_number(values[0]) - _number(values[1]))
if expression.op == "multiply":
total = Decimal(1)
for value in values:
total *= _number(value)
return _numeric_result(total)
if expression.op == "divide":
_arity(values, 2, expression.op)
divisor = _number(values[1])
return None if divisor == 0 else _numeric_result(_number(values[0]) / divisor)
if expression.op == "coalesce":
return next((item for item in values if item is not None), None)
if expression.op == "case":
if len(values) < 3:
raise ReportingQueryError(
"Case expressions require condition, value, and default."
)
pairs = values[:-1]
for index in range(0, len(pairs) - 1, 2):
if bool(pairs[index]):
return pairs[index + 1]
return values[-1]
if expression.op in {"eq", "ne", "gt", "gte", "lt", "lte"}:
_arity(values, 2, expression.op)
return _compare(values[0], values[1], expression.op)
if expression.op == "and":
return all(bool(item) for item in values)
if expression.op == "or":
return any(bool(item) for item in values)
if expression.op == "not":
_arity(values, 1, expression.op)
return not bool(values[0])
raise ReportingQueryError(f"Unsupported expression operator: {expression.op!r}.")
def _pivot_rows(
rows: Sequence[Mapping[str, object]],
*,
row_dimensions: Sequence[str],
column_dimensions: Sequence[str],
measures: Sequence[str],
include_totals: bool,
) -> list[dict[str, Any]]:
grouped: dict[tuple[object, ...], dict[str, Any]] = {}
totals: dict[tuple[object, ...], dict[str, Decimal]] = defaultdict(
lambda: defaultdict(Decimal)
)
for source in rows:
row_key = tuple(source.get(item) for item in row_dimensions)
target = grouped.setdefault(
row_key,
{
item: _json_value(value)
for item, value in zip(row_dimensions, row_key, strict=True)
},
)
column_key = (
" / ".join(
str(source.get(item) if source.get(item) is not None else "(blank)")
for item in column_dimensions
)
or "value"
)
for measure in measures:
field_name = f"{column_key}.{measure}"
value = source.get(measure)
target[field_name] = _json_value(value)
if include_totals and value is not None:
try:
totals[row_key][measure] += _number(value)
except ReportingQueryError:
pass
if include_totals:
for row_key, target in grouped.items():
for measure in measures:
if measure in totals[row_key]:
target[f"total.{measure}"] = _numeric_result(
totals[row_key][measure]
)
return list(grouped.values())
def _matches_filter(value: object, clause: FilterClause) -> bool:
expected = clause.value
if clause.operator == "is_null":
return value is None
if clause.operator == "not_null":
return value is not None
if clause.operator == "eq":
return value == expected
if clause.operator == "ne":
return value != expected
if clause.operator in {"in", "not_in"}:
if not isinstance(expected, (list, tuple, set, frozenset)):
raise ReportingQueryError("Set filters require a list value.")
result = value in expected
return result if clause.operator == "in" else not result
if clause.operator == "contains":
return str(expected or "").casefold() in str(value or "").casefold()
if clause.operator == "starts_with":
return str(value or "").casefold().startswith(str(expected or "").casefold())
if clause.operator == "between":
if not isinstance(expected, (list, tuple)) or len(expected) != 2:
raise ReportingQueryError("Between filters require two values.")
return value is not None and expected[0] <= value <= expected[1]
if clause.operator in {"gt", "gte", "lt", "lte"}:
if value is None or expected is None:
return False
return bool(_compare(value, expected, clause.operator))
raise ReportingQueryError(f"Unsupported filter operator: {clause.operator!r}.")
def _sort_rows(
rows: Sequence[dict[str, Any]],
query: ReportQuery,
) -> list[dict[str, Any]]:
result = list(rows)
for clause in reversed(query.sort):
result.sort(
key=lambda item: _sort_key(item.get(clause.key)),
reverse=clause.direction == "desc",
)
return result
def infer_query_schema(rows: Sequence[Mapping[str, object]]) -> tuple[dict[str, Any], ...]:
names = tuple(dict.fromkeys(str(key) for row in rows for key in row))
return tuple(
{
"name": name,
"type": _value_type(
next((row.get(name) for row in rows if row.get(name) is not None), None)
),
"nullable": any(row.get(name) is None for row in rows),
}
for name in names
)
def _value_type(value: object) -> str:
if value is None:
return "string"
if isinstance(value, bool):
return "boolean"
if isinstance(value, int):
return "integer"
if isinstance(value, (float, Decimal)):
return "number"
if isinstance(value, datetime):
return "datetime"
if isinstance(value, date):
return "date"
if isinstance(value, (dict, list, tuple)):
return "json"
return "string"
def _require_known(
keys: Sequence[str],
available: Mapping[str, object],
label: str,
) -> None:
unknown = set(keys) - set(available)
if unknown:
raise ReportingQueryError(
f"Report query references unknown {label}: " + ", ".join(sorted(unknown))
)
def _compare(left: object, right: object, operator: str) -> bool:
if operator == "eq":
return left == right
if operator == "ne":
return left != right
try:
if operator == "gt":
return left > right # type: ignore[operator]
if operator == "gte":
return left >= right # type: ignore[operator]
if operator == "lt":
return left < right # type: ignore[operator]
if operator == "lte":
return left <= right # type: ignore[operator]
except TypeError as exc:
raise ReportingQueryError("Report comparison values are incompatible.") from exc
raise ReportingQueryError(f"Unsupported comparison operator: {operator!r}.")
def _arity(values: Sequence[object], count: int, operator: str) -> None:
if len(values) != count:
raise ReportingQueryError(
f"Expression {operator} requires exactly {count} arguments."
)
def _number(value: object) -> Decimal:
if value is None or isinstance(value, bool):
raise ReportingQueryError("A numeric report expression received a non-number.")
try:
return Decimal(str(value))
except Exception as exc:
raise ReportingQueryError("A numeric report expression is invalid.") from exc
def _numeric_result(value: Decimal) -> int | float:
if value == value.to_integral_value():
return int(value)
return float(value)
def _sort_key(value: object) -> tuple[bool, str, str]:
return value is None, type(value).__name__, str(value).casefold()
def _hashable(value: object) -> object:
if isinstance(value, Mapping):
return tuple(sorted((str(key), _hashable(item)) for key, item in value.items()))
if isinstance(value, (list, tuple)):
return tuple(_hashable(item) for item in value)
return value
def _json_value(value: object) -> Any:
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return _numeric_result(value)
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_value(item) for item in value]
return value
__all__ = [
"QUERY_ENGINE_VERSION",
"DefaultChartRenderer",
"QueryResult",
"ReportingQueryError",
"execute_semantic_query",
"infer_query_schema",
]
@@ -0,0 +1,81 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from govoplan_reporting.backend.definitions import (
create_definition,
get_definition,
list_definitions,
update_definition,
)
class SqlReportingRegistry:
def get_definition(
self,
session: object,
principal: object,
*,
definition_kind: str,
definition_id: str,
revision: int | None = None,
):
return get_definition(
session, # type: ignore[arg-type]
principal,
definition_kind=definition_kind,
definition_id=definition_id,
revision=revision,
)
def list_definitions(
self,
session: object,
principal: object,
*,
definition_kinds: Sequence[str] | None = None,
limit: int = 100,
):
records, _total = list_definitions(
session, # type: ignore[arg-type]
principal,
definition_kinds=definition_kinds,
limit=limit,
)
return records
def create_definition(
self,
session: object,
principal: object,
**values,
):
return create_definition(session, principal, **values) # type: ignore[arg-type]
def update_definition(
self,
session: object,
principal: object,
*,
definition_kind: str,
definition_id: str,
expected_revision: int,
recorded_at,
change_reason: str,
idempotency_key: str,
changes: Mapping[str, object],
):
return update_definition(
session, # type: ignore[arg-type]
principal,
definition_kind=definition_kind,
definition_id=definition_id,
expected_revision=expected_revision,
recorded_at=recorded_at,
change_reason=change_reason,
idempotency_key=idempotency_key,
changes=changes,
)
__all__ = ["SqlReportingRegistry"]
@@ -0,0 +1,69 @@
"""Retention minimization for governed provider-report results."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from govoplan_reporting.backend.db.models import ReportingProviderExecution
class ReportingRetentionService:
def apply_retention(
self,
session: object,
*,
dry_run: bool,
now: datetime,
limit: int = 500,
) -> dict[str, int]:
if not isinstance(session, Session):
raise TypeError("Reporting retention requires a SQLAlchemy Session")
observed_at = _aware(now)
rows = (
session.query(ReportingProviderExecution)
.filter(
ReportingProviderExecution.expires_at.is_not(None),
ReportingProviderExecution.expires_at <= observed_at,
ReportingProviderExecution.retention_redacted_at.is_(None),
)
.order_by(
ReportingProviderExecution.expires_at.asc(),
ReportingProviderExecution.id.asc(),
)
.limit(max(1, min(int(limit), 5_000)))
.all()
)
counts = {
"eligible": len(rows),
"redacted": 0,
"remaining_in_batch": 0,
}
if dry_run:
return counts
for row in rows:
# Keep immutable request/output hashes and provenance as audit
# evidence while removing the retained report detail itself.
row.result_payload = {}
row.retention_redacted_at = observed_at
counts["redacted"] += 1
session.flush()
counts["remaining_in_batch"] = (
session.query(ReportingProviderExecution.id)
.filter(
ReportingProviderExecution.expires_at.is_not(None),
ReportingProviderExecution.expires_at <= observed_at,
ReportingProviderExecution.retention_redacted_at.is_(None),
)
.limit(1)
.count()
)
return counts
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
__all__ = ["ReportingRetentionService"]
+769
View File
@@ -0,0 +1,769 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.dataflows import dataflow_dataset_output
from govoplan_core.db.session import get_session
from govoplan_reporting.backend.definitions import (
READ_SCOPE,
WRITE_SCOPE,
ReportingDefinitionError,
create_definition,
definition_history,
get_definition,
list_definitions,
update_definition,
)
from govoplan_reporting.backend.drilldown import (
ReportingDrillError,
create_drill_context,
resolve_drill_context,
)
from govoplan_reporting.backend.execution import (
QUALITY_SCOPE,
RUN_SCOPE,
ReportingExecutionError,
ReportingExecutionFailure,
execute_report,
get_execution,
list_executions,
run_quality_plan,
)
from govoplan_reporting.backend.operations import (
IMPORT_SCOPE,
PUBLISH_SCOPE,
SCHEDULE_SCOPE,
ReportingOperationError,
assess_import,
delete_saved_view,
dispatch_due_schedules,
export_execution,
list_import_assessments,
list_publications,
list_saved_views,
list_schedules,
publish_execution,
upsert_saved_view,
upsert_schedule,
)
from govoplan_reporting.backend.provider_reports import (
ProviderReportError,
execute_provider_report,
export_provider_execution,
get_provider_execution,
list_provider_exports,
list_provider_reports,
provider_parameter_options,
)
from govoplan_reporting.backend.publication_targets import publication_target_catalog
from govoplan_reporting.backend.query_engine import ReportingQueryError
from govoplan_reporting.backend.schemas import (
DefinitionUpdateRequest,
DefinitionWriteRequest,
DrillContextCreateRequest,
ImportAssessmentRequest,
PublicationRequest,
ProviderReportExecutionRequest,
ProviderReportExportRequest,
QualityRunRequest,
ReportExecutionRequest,
SavedViewWriteRequest,
ScheduleWriteRequest,
)
def create_router(registry: object | None) -> APIRouter:
router = APIRouter(prefix="/reporting", tags=["reporting"])
@router.get("/provider-reports")
def api_list_provider_reports(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, READ_SCOPE)
try:
return list_provider_reports(session, principal, registry=registry)
except (ProviderReportError, TypeError, ValueError) as exc:
raise _error(exc) from exc
@router.get(
"/provider-reports/{provider_id}/{report_id}/parameters/{parameter_key}/options"
)
def api_provider_parameter_options(
provider_id: str,
report_id: str,
parameter_key: str,
query: str = "",
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, READ_SCOPE)
try:
options = provider_parameter_options(
session,
principal,
registry=registry,
provider_id=provider_id,
report_id=report_id,
parameter_key=parameter_key,
query=query,
limit=limit,
)
except (ProviderReportError, PermissionError, LookupError) as exc:
raise _error(exc) from exc
return {"options": [item.to_dict() for item in options]}
@router.post("/provider-reports/{provider_id}/{report_id}/executions")
def api_execute_provider_report(
provider_id: str,
report_id: str,
payload: ProviderReportExecutionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, RUN_SCOPE)
try:
result = execute_provider_report(
session,
principal,
registry=registry,
provider_id=provider_id,
report_id=report_id,
**payload.model_dump(),
)
session.commit()
except (ProviderReportError, PermissionError, LookupError, ValueError) as exc:
session.rollback()
raise _error(exc) from exc
return result
@router.get("/provider-executions/{execution_id}")
def api_get_provider_execution(
execution_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, RUN_SCOPE)
try:
result = get_provider_execution(
session,
principal,
registry=registry,
execution_id=execution_id,
)
except (ProviderReportError, PermissionError, LookupError) as exc:
raise _error(exc) from exc
if result is None:
raise HTTPException(
status_code=404, detail="Provider report execution not found"
)
return result
@router.get("/provider-executions/{execution_id}/exports")
def api_list_provider_exports(
execution_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, RUN_SCOPE)
try:
return {
"exports": list(
list_provider_exports(
session,
principal,
registry=registry,
execution_id=execution_id,
)
)
}
except (ProviderReportError, PermissionError, LookupError) as exc:
raise _error(exc) from exc
@router.post("/provider-executions/{execution_id}/exports")
def api_export_provider_execution(
execution_id: str,
payload: ProviderReportExportRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> Response:
_require(principal, RUN_SCOPE)
try:
content, media_type, filename = export_provider_execution(
session,
principal,
registry=registry,
execution_id=execution_id,
**payload.model_dump(),
)
session.commit()
except (ProviderReportError, PermissionError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
return Response(
content=content,
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/definitions")
def api_list_definitions(
definition_kind: list[str] | None = Query(default=None),
definition_status: list[str] | None = Query(default=None, alias="status"),
query: str = "",
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, READ_SCOPE)
try:
records, total = list_definitions(
session,
principal,
definition_kinds=definition_kind,
statuses=definition_status,
query=query,
offset=offset,
limit=limit,
)
except ReportingDefinitionError as exc:
raise _error(exc) from exc
return {
"definitions": [item.to_dict() for item in records],
"total": total,
"offset": offset,
"limit": limit,
}
@router.post(
"/definitions",
status_code=status.HTTP_201_CREATED,
)
def api_create_definition(
payload: DefinitionWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
try:
record = create_definition(
session,
principal,
**payload.model_dump(),
)
session.commit()
except (ReportingDefinitionError, ValueError) as exc:
session.rollback()
raise _error(exc) from exc
return record.to_dict()
@router.get("/definitions/{definition_kind}/{definition_id}")
def api_get_definition(
definition_kind: str,
definition_id: str,
revision: int | None = Query(default=None, ge=1),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, READ_SCOPE)
try:
record = get_definition(
session,
principal,
definition_kind=definition_kind,
definition_id=definition_id,
revision=revision,
)
except ReportingDefinitionError as exc:
raise _error(exc) from exc
if record is None:
raise HTTPException(
status_code=404, detail="Reporting definition not found"
)
return record.to_dict()
@router.patch("/definitions/{definition_kind}/{definition_id}")
def api_update_definition(
definition_kind: str,
definition_id: str,
payload: DefinitionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, WRITE_SCOPE)
changes = payload.model_dump(
exclude={
"expected_revision",
"recorded_at",
"change_reason",
"idempotency_key",
},
exclude_unset=True,
)
try:
record = update_definition(
session,
principal,
definition_kind=definition_kind,
definition_id=definition_id,
expected_revision=payload.expected_revision,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
changes=changes,
)
session.commit()
except (
ReportingDefinitionError,
PermissionError,
LookupError,
ValueError,
) as exc:
session.rollback()
raise _error(exc) from exc
return record.to_dict()
@router.get("/definitions/{definition_kind}/{definition_id}/history")
def api_definition_history(
definition_kind: str,
definition_id: str,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, READ_SCOPE)
records = definition_history(
session,
principal,
definition_kind=definition_kind,
definition_id=definition_id,
limit=limit,
)
if not records:
raise HTTPException(
status_code=404, detail="Reporting definition not found"
)
return {"revisions": [item.to_dict() for item in records]}
@router.get("/sources/dataflow")
def api_dataflow_sources(
query: str = "",
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, WRITE_SCOPE)
provider = dataflow_dataset_output(registry)
if provider is None:
return {
"available": False,
"reason": "The Dataflow dataset provider is not enabled.",
"sources": [],
}
sources = provider.list_outputs(
session,
principal,
query=query,
limit=limit,
)
return {
"available": True,
"reason": None,
"sources": [
{
"pipeline_ref": item.pipeline_ref,
"name": item.name,
"description": item.description,
"revision": item.revision,
"definition_hash": item.definition_hash,
"status": item.status,
"updated_at": item.updated_at.isoformat()
if item.updated_at
else None,
"parameters": dict(item.parameters),
"provenance": dict(item.provenance),
}
for item in sources
],
}
@router.post("/reports/{report_id}/executions")
def api_execute_report(
report_id: str,
payload: ReportExecutionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, RUN_SCOPE)
try:
result = execute_report(
session,
principal,
registry=registry,
report_id=report_id,
report_revision=payload.report_revision,
parameters=payload.parameters,
query=payload.query,
idempotency_key=payload.idempotency_key,
)
session.commit()
except ReportingExecutionFailure as exc:
session.commit()
raise HTTPException(
status_code=422,
detail={"message": str(exc), "execution_id": exc.execution_id},
) from exc
except (
ReportingExecutionError,
ReportingQueryError,
PermissionError,
LookupError,
ValueError,
) as exc:
session.rollback()
raise _error(exc) from exc
return result
@router.get("/reports/{report_id}/executions")
def api_list_executions(
report_id: str,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, RUN_SCOPE)
return {
"executions": list(
list_executions(
session,
principal,
report_id=report_id,
limit=limit,
registry=registry,
)
)
}
@router.get("/executions/{execution_id}")
def api_get_execution(
execution_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, RUN_SCOPE)
result = get_execution(
session,
principal,
execution_id=execution_id,
registry=registry,
)
if result is None:
raise HTTPException(status_code=404, detail="Reporting execution not found")
return result
@router.post("/executions/{execution_id}/drill-contexts", status_code=201)
def api_create_drill_context(
execution_id: str,
payload: DrillContextCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, RUN_SCOPE)
try:
result = create_drill_context(
session,
principal,
registry=registry,
execution_id=execution_id,
aggregate_row=payload.aggregate_row,
limit=payload.limit,
)
session.commit()
except (
ReportingDrillError,
ReportingExecutionError,
PermissionError,
LookupError,
) as exc:
session.rollback()
raise _error(exc) from exc
return result
@router.get("/drill-contexts/{token}")
def api_resolve_drill_context(
token: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, RUN_SCOPE)
try:
result = resolve_drill_context(
session,
principal,
registry=registry,
token=token,
)
session.commit()
except (
ReportingDrillError,
ReportingExecutionError,
PermissionError,
LookupError,
) as exc:
session.rollback()
raise _error(exc) from exc
return result
@router.get("/executions/{execution_id}/export")
def api_export_execution(
execution_id: str,
format: str = Query(default="csv", pattern="^(csv|json)$"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> Response:
_require(principal, RUN_SCOPE)
try:
content, media_type, filename = export_execution(
session,
principal,
execution_id=execution_id,
format=format,
registry=registry,
)
except (ReportingOperationError, LookupError) as exc:
raise _error(exc) from exc
return Response(
content=content,
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/executions/{execution_id}/publications")
def api_publish_execution(
execution_id: str,
payload: PublicationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, PUBLISH_SCOPE)
try:
result = publish_execution(
session,
principal,
registry=registry,
execution_id=execution_id,
**payload.model_dump(),
)
session.commit()
except (ReportingOperationError, PermissionError, LookupError) as exc:
session.commit()
raise _error(exc) from exc
return result
@router.get("/publication-targets")
def api_publication_targets(
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, PUBLISH_SCOPE)
return {"targets": list(publication_target_catalog(registry))}
@router.get("/publications")
def api_list_publications(
execution_id: str | None = None,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, PUBLISH_SCOPE)
return {
"publications": list(
list_publications(
session,
principal,
execution_id=execution_id,
limit=limit,
registry=registry,
)
)
}
@router.get("/reports/{report_id}/saved-views")
def api_list_saved_views(
report_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, READ_SCOPE)
return {
"views": list(list_saved_views(session, principal, report_id=report_id))
}
@router.put("/reports/{report_id}/saved-views/{view_id}")
def api_upsert_saved_view(
report_id: str,
view_id: str,
payload: SavedViewWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, READ_SCOPE)
if view_id != payload.view_id:
raise HTTPException(status_code=400, detail="Saved-view identifiers differ")
try:
result = upsert_saved_view(
session,
principal,
report_id=report_id,
**payload.model_dump(),
)
session.commit()
except (ReportingOperationError, PermissionError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
return result
@router.delete("/saved-views/{view_id}", status_code=204)
def api_delete_saved_view(
view_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> Response:
_require(principal, READ_SCOPE)
try:
deleted = delete_saved_view(session, principal, view_id=view_id)
session.commit()
except PermissionError as exc:
session.rollback()
raise _error(exc) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Saved view not found")
return Response(status_code=204)
@router.get("/schedules")
def api_list_schedules(
report_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, SCHEDULE_SCOPE)
return {
"schedules": list(list_schedules(session, principal, report_id=report_id))
}
@router.put("/schedules/{schedule_id}")
def api_upsert_schedule(
schedule_id: str,
payload: ScheduleWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, SCHEDULE_SCOPE)
if schedule_id != payload.schedule_id:
raise HTTPException(status_code=400, detail="Schedule identifiers differ")
try:
result = upsert_schedule(session, principal, **payload.model_dump())
session.commit()
except (ReportingOperationError, PermissionError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
return result
@router.post("/schedules/dispatch")
def api_dispatch_schedules(
limit: int = Query(default=20, ge=1, le=100),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, SCHEDULE_SCOPE)
result = dispatch_due_schedules(
session,
principal,
registry=registry,
now=None,
limit=limit,
)
session.commit()
return result
@router.post("/quality-plans/{quality_plan_id}/runs")
def api_run_quality_plan(
quality_plan_id: str,
payload: QualityRunRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, QUALITY_SCOPE)
try:
result = run_quality_plan(
session,
principal,
registry=registry,
quality_plan_id=quality_plan_id,
quality_plan_revision=payload.quality_plan_revision,
parameters=payload.parameters,
)
session.commit()
except (ReportingExecutionError, PermissionError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
return result
@router.post("/imports/assessments", status_code=201)
def api_assess_import(
payload: ImportAssessmentRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, IMPORT_SCOPE)
try:
result = assess_import(session, principal, **payload.model_dump())
session.commit()
except (ReportingOperationError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return result
@router.get("/imports/assessments")
def api_list_import_assessments(
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, IMPORT_SCOPE)
return {
"assessments": list(
list_import_assessments(session, principal, limit=limit)
)
}
return router
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _error(exc: Exception) -> HTTPException:
message = str(exc)
lowered = message.casefold()
if isinstance(exc, LookupError):
code = 404
elif isinstance(exc, PermissionError):
code = 403
elif any(word in lowered for word in ("conflict", "already", "stale")):
code = 409
elif "unavailable" in lowered or "not enabled" in lowered:
code = 503
else:
code = 400
return HTTPException(status_code=code, detail=message)
__all__ = ["create_router"]
+576
View File
@@ -0,0 +1,576 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
DefinitionKind = Literal[
"dataset",
"semantic_model",
"report",
"quality_plan",
]
FieldType = Literal[
"string",
"integer",
"number",
"boolean",
"date",
"datetime",
"json",
]
class ReportingReference(BaseModel):
model_config = ConfigDict(extra="forbid")
owner_module: str = Field(min_length=1, max_length=100)
resource_type: str = Field(min_length=1, max_length=100)
resource_id: str = Field(min_length=1, max_length=255)
revision: str | None = Field(default=None, max_length=255)
relationship: str = Field(default="related", min_length=1, max_length=80)
label: str | None = Field(default=None, max_length=500)
class DatasetField(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
type: FieldType = "string"
label: str | None = Field(default=None, max_length=500)
nullable: bool = True
description: str | None = Field(default=None, max_length=4_000)
classification: Literal[
"public",
"internal",
"confidential",
"restricted",
] = "internal"
class FreshnessPolicy(BaseModel):
model_config = ConfigDict(extra="forbid")
max_age_seconds: int | None = Field(default=None, ge=1, le=31_536_000)
stale_action: Literal["allow", "warn", "block"] = "warn"
require_source_fingerprints: bool = True
class DefinitionGovernance(BaseModel):
"""Versioned scope and restrictive inheritance metadata for a definition."""
model_config = ConfigDict(extra="forbid")
scope_type: Literal["system", "tenant", "group", "user"] = "tenant"
scope_id: str | None = Field(default=None, max_length=255)
inherit_to_lower_scopes: bool = False
allow_run: bool = True
allow_reuse: bool = False
allow_automation: bool = False
source_scope: dict[str, Any] | None = None
source_effective_limits: dict[str, bool] = Field(default_factory=dict)
derivation_provenance: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_scope(self) -> "DefinitionGovernance":
if self.scope_type == "system":
if self.scope_id:
raise ValueError("System Reporting definitions do not carry a scope ID.")
elif self.scope_type in {"group", "user"} and not str(self.scope_id or "").strip():
raise ValueError(
f"{self.scope_type.capitalize()} Reporting definitions require a scope ID."
)
unknown = set(self.source_effective_limits) - {
"inherit_to_lower_scopes",
"allow_run",
"allow_reuse",
"allow_automation",
}
if unknown:
raise ValueError(
"Unknown inherited Reporting limits: " + ", ".join(sorted(unknown))
)
return self
class DatasetDefinition(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
source_kind: Literal["dataflow", "read_model", "static"]
source_ref: str = Field(min_length=1, max_length=500)
source_revision: int | None = Field(default=None, ge=1)
source_run_ref: str | None = Field(default=None, min_length=1, max_length=500)
definition_hash: str | None = Field(default=None, min_length=1, max_length=128)
source_parameters: dict[str, Any] = Field(default_factory=dict)
static_rows: list[dict[str, Any]] = Field(default_factory=list, max_length=2_000)
expected_source_fingerprints: list[dict[str, Any]] = Field(
default_factory=list,
max_length=100,
)
fields: list[DatasetField] = Field(
default_factory=list,
max_length=500,
validation_alias="schema",
serialization_alias="schema",
)
freshness: FreshnessPolicy = Field(default_factory=FreshnessPolicy)
row_policy_ref: str | None = Field(default=None, max_length=500)
purpose: str = Field(min_length=1, max_length=4_000)
privacy: dict[str, Any] = Field(default_factory=dict)
retention: dict[str, Any] = Field(default_factory=dict)
policy_provenance: dict[str, Any] = Field(default_factory=dict)
institutional_references: list[ReportingReference] = Field(
default_factory=list,
max_length=200,
)
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
@model_validator(mode="after")
def validate_source_pin(self) -> "DatasetDefinition":
if self.source_kind == "dataflow" and self.source_revision is None:
raise ValueError("Dataflow datasets require a pinned source revision.")
if self.source_run_ref is not None and self.source_kind != "dataflow":
raise ValueError("Only Dataflow datasets can pin a source run.")
if self.source_kind == "static" and not self.static_rows:
raise ValueError("Static analytical datasets require static_rows.")
names = [item.name for item in self.fields]
if len(names) != len(set(names)):
raise ValueError("Dataset schema field names must be unique.")
return self
class DimensionDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9._-]+$")
field: str = Field(min_length=1, max_length=255)
label: str = Field(min_length=1, max_length=500)
type: FieldType = "string"
format: str | None = Field(default=None, max_length=255)
default_sort: Literal["asc", "desc"] | None = None
class HierarchyLevel(BaseModel):
model_config = ConfigDict(extra="forbid")
dimension: str = Field(min_length=1, max_length=120)
label: str | None = Field(default=None, max_length=500)
class HierarchyDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9._-]+$")
label: str = Field(min_length=1, max_length=500)
levels: list[HierarchyLevel] = Field(min_length=1, max_length=20)
class TypedExpression(BaseModel):
model_config = ConfigDict(extra="forbid")
op: Literal[
"literal",
"field",
"measure",
"add",
"subtract",
"multiply",
"divide",
"coalesce",
"case",
"eq",
"ne",
"gt",
"gte",
"lt",
"lte",
"and",
"or",
"not",
]
value: Any = None
ref: str | None = Field(default=None, max_length=255)
args: list["TypedExpression"] = Field(default_factory=list, max_length=50)
result_type: FieldType | None = None
@model_validator(mode="after")
def validate_shape(self) -> "TypedExpression":
if self.op in {"field", "measure"} and not self.ref:
raise ValueError(f"Expression {self.op} requires ref.")
if self.op == "literal" and self.args:
raise ValueError("Literal expressions cannot have arguments.")
if self.op not in {"literal", "field", "measure"} and not self.args:
raise ValueError(f"Expression {self.op} requires arguments.")
return self
class MeasureDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9._-]+$")
label: str = Field(min_length=1, max_length=500)
aggregation: Literal[
"sum",
"count",
"count_distinct",
"average",
"minimum",
"maximum",
"calculated",
]
field: str | None = Field(default=None, max_length=255)
expression: TypedExpression | None = None
format: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_measure(self) -> "MeasureDefinition":
if self.aggregation == "calculated" and self.expression is None:
raise ValueError("Calculated measures require an expression.")
if self.aggregation not in {"count", "calculated"} and not self.field:
raise ValueError(f"{self.aggregation} measures require a field.")
return self
class SemanticModelDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
dataset_id: str = Field(min_length=1, max_length=255)
dataset_revision: int = Field(ge=1)
dimensions: list[DimensionDefinition] = Field(default_factory=list, max_length=300)
hierarchies: list[HierarchyDefinition] = Field(default_factory=list, max_length=100)
measures: list[MeasureDefinition] = Field(default_factory=list, max_length=300)
default_dimensions: list[str] = Field(default_factory=list, max_length=50)
default_measures: list[str] = Field(default_factory=list, max_length=50)
metadata: dict[str, Any] = Field(default_factory=dict)
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
@model_validator(mode="after")
def validate_semantics(self) -> "SemanticModelDefinition":
dimensions = {item.key for item in self.dimensions}
measures = {item.key for item in self.measures}
if len(dimensions) != len(self.dimensions):
raise ValueError("Semantic dimensions require unique keys.")
if len(measures) != len(self.measures):
raise ValueError("Semantic measures require unique keys.")
unknown_dimensions = set(self.default_dimensions) - dimensions
unknown_measures = set(self.default_measures) - measures
hierarchy_dimensions = {
level.dimension
for hierarchy in self.hierarchies
for level in hierarchy.levels
}
unknown_dimensions |= hierarchy_dimensions - dimensions
if unknown_dimensions or unknown_measures:
raise ValueError(
"Semantic model references unknown dimensions or measures: "
+ ", ".join(sorted(unknown_dimensions | unknown_measures))
)
return self
class ParameterDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9._-]+$")
label: str = Field(min_length=1, max_length=500)
type: FieldType = "string"
required: bool = False
default: Any = None
allowed_values: list[Any] = Field(default_factory=list, max_length=500)
class FilterClause(BaseModel):
model_config = ConfigDict(extra="forbid")
dimension: str = Field(min_length=1, max_length=120)
operator: Literal[
"eq",
"ne",
"in",
"not_in",
"contains",
"starts_with",
"gt",
"gte",
"lt",
"lte",
"between",
"is_null",
"not_null",
] = "eq"
value: Any = None
class SortClause(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120)
direction: Literal["asc", "desc"] = "asc"
class PivotDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
rows: list[str] = Field(default_factory=list, max_length=10)
columns: list[str] = Field(default_factory=list, max_length=5)
measures: list[str] = Field(default_factory=list, max_length=20)
include_totals: bool = True
class ReportQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
mode: Literal["summary", "detail", "pivot"] = "summary"
dimensions: list[str] = Field(default_factory=list, max_length=50)
measures: list[str] = Field(default_factory=list, max_length=50)
filters: list[FilterClause] = Field(default_factory=list, max_length=100)
sort: list[SortClause] = Field(default_factory=list, max_length=20)
pivot: PivotDefinition | None = None
offset: int = Field(default=0, ge=0)
limit: int = Field(default=200, ge=1, le=2_000)
@model_validator(mode="after")
def validate_pivot(self) -> "ReportQuery":
if self.mode == "pivot" and self.pivot is None:
raise ValueError("Pivot mode requires a pivot definition.")
return self
class VisualizationDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal[
"table",
"pivot",
"bar",
"line",
"area",
"column",
"pie",
"donut",
"metric",
] = "table"
category_dimension: str | None = Field(default=None, max_length=120)
series_dimension: str | None = Field(default=None, max_length=120)
measures: list[str] = Field(default_factory=list, max_length=20)
options: dict[str, Any] = Field(default_factory=dict)
tabular_fallback: bool = True
class ReportDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
semantic_model_id: str = Field(min_length=1, max_length=255)
semantic_model_revision: int = Field(ge=1)
parameters: list[ParameterDefinition] = Field(default_factory=list, max_length=100)
default_query: ReportQuery = Field(default_factory=ReportQuery)
visualization: VisualizationDefinition = Field(
default_factory=VisualizationDefinition
)
layout: dict[str, Any] = Field(default_factory=dict)
access_policy: dict[str, Any] = Field(default_factory=dict)
publication_defaults: dict[str, Any] = Field(default_factory=dict)
institutional_references: list[ReportingReference] = Field(
default_factory=list,
max_length=200,
)
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
class QualityAssertion(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9._-]+$")
kind: Literal[
"not_null",
"unique",
"range",
"accepted_values",
"row_count",
"comparison",
]
field: str | None = Field(default=None, max_length=255)
severity: Literal["info", "warning", "error", "blocker"] = "error"
config: dict[str, Any] = Field(default_factory=dict)
class QualityPlanDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
dataset_id: str = Field(min_length=1, max_length=255)
dataset_revision: int = Field(ge=1)
assertions: list[QualityAssertion] = Field(min_length=1, max_length=200)
block_report_execution: bool = True
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
DEFINITION_PAYLOAD_TYPES = {
"dataset": DatasetDefinition,
"semantic_model": SemanticModelDefinition,
"report": ReportDefinition,
"quality_plan": QualityPlanDefinition,
}
def validate_definition_payload(
kind: str,
payload: dict[str, Any],
) -> dict[str, Any]:
model = DEFINITION_PAYLOAD_TYPES.get(kind)
if model is None:
raise ValueError(f"Unsupported Reporting definition kind: {kind!r}.")
return model.model_validate(payload).model_dump(mode="json", by_alias=True)
class DefinitionWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
definition_kind: DefinitionKind
definition_id: str = Field(min_length=1, max_length=255)
definition_key: str = Field(
min_length=1,
max_length=120,
pattern=r"^[a-z0-9._-]+$",
)
name: str = Field(min_length=1, max_length=500)
description: str | None = Field(default=None, max_length=100_000)
status: Literal["draft", "active", "retired"] = "draft"
visibility: Literal["tenant", "restricted"] = "tenant"
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
payload: dict[str, Any]
idempotency_key: str = Field(min_length=1, max_length=255)
class DefinitionUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=500)
description: str | None = Field(default=None, max_length=100_000)
status: Literal["draft", "active", "retired"] | None = None
visibility: Literal["tenant", "restricted"] | None = None
payload: dict[str, Any] | None = None
class ReportExecutionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
report_revision: int | None = Field(default=None, ge=1)
parameters: dict[str, Any] = Field(default_factory=dict)
query: ReportQuery | None = None
idempotency_key: str = Field(min_length=1, max_length=255)
class ProviderReportExecutionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
parameters: dict[str, Any] = Field(default_factory=dict)
purpose: str = Field(min_length=1, max_length=1_000)
audience_scope: dict[str, Any] = Field(min_length=1, max_length=50)
idempotency_key: str = Field(min_length=1, max_length=255)
class ProviderReportExportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
format: Literal["json", "csv"] = "json"
purpose: str = Field(min_length=1, max_length=1_000)
audience_scope: dict[str, Any] = Field(min_length=1, max_length=50)
class SavedViewWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
view_id: str = Field(min_length=1, max_length=36)
report_revision: int = Field(ge=1)
name: str = Field(min_length=1, max_length=500)
state: dict[str, Any]
shared: bool = False
access: dict[str, Any] = Field(default_factory=dict)
expected_revision: int | None = Field(default=None, ge=1)
class ScheduleWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
schedule_id: str = Field(min_length=1, max_length=36)
report_id: str = Field(min_length=1, max_length=255)
report_revision: int = Field(ge=1)
name: str = Field(min_length=1, max_length=500)
trigger_kind: Literal["scheduled", "interval"]
trigger_config: dict[str, Any]
parameters: dict[str, Any] = Field(default_factory=dict)
query: ReportQuery = Field(default_factory=ReportQuery)
publication_target: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
next_run_at: datetime | None = None
expected_revision: int | None = Field(default=None, ge=1)
class PublicationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
target_capability: str = Field(min_length=1, max_length=255)
target_ref: str | None = Field(default=None, max_length=1_000)
format: Literal["json", "csv", "xlsx", "html", "pdf"] = "csv"
idempotency_key: str = Field(min_length=1, max_length=255)
options: dict[str, Any] = Field(default_factory=dict)
class DrillContextCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
aggregate_row: dict[str, Any] = Field(max_length=500)
limit: int = Field(default=200, ge=1, le=500)
class QualityRunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
quality_plan_revision: int | None = Field(default=None, ge=1)
parameters: dict[str, Any] = Field(default_factory=dict)
class ImportAssessmentRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_system: str = Field(min_length=1, max_length=255)
source_id: str = Field(min_length=1, max_length=500)
metadata: dict[str, Any]
accepted_approximations: list[str] = Field(default_factory=list, max_length=500)
TypedExpression.model_rebuild()
__all__ = [
"DatasetDefinition",
"DefinitionGovernance",
"DefinitionUpdateRequest",
"DefinitionWriteRequest",
"DimensionDefinition",
"DrillContextCreateRequest",
"FilterClause",
"ImportAssessmentRequest",
"MeasureDefinition",
"PublicationRequest",
"ProviderReportExecutionRequest",
"ProviderReportExportRequest",
"QualityPlanDefinition",
"QualityRunRequest",
"ReportDefinition",
"ReportExecutionRequest",
"ReportQuery",
"SavedViewWriteRequest",
"ScheduleWriteRequest",
"SemanticModelDefinition",
"TypedExpression",
"VisualizationDefinition",
"validate_definition_payload",
]
@@ -0,0 +1,202 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchResourceType,
)
from govoplan_reporting.backend.db.models import (
ReportingDefinitionGrant,
ReportingDefinitionIdentity,
ReportingDefinitionRevision,
)
from govoplan_reporting.backend.definitions import can_read_definition
PROVIDER_ID = "reporting.reports"
RESOURCE_TYPE = "report"
class ReportingSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="reporting",
resource_type=RESOURCE_TYPE,
label="Reports",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
if request.provider_id != PROVIDER_ID or request.resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Reporting search source.")
db = _session(session)
query = db.query(ReportingDefinitionRevision).filter(
ReportingDefinitionRevision.tenant_id == request.tenant_id,
ReportingDefinitionRevision.definition_kind == "report",
ReportingDefinitionRevision.status != "retired",
ReportingDefinitionRevision.superseded_at.is_(None),
)
if request.cursor:
query = query.filter(ReportingDefinitionRevision.id > request.cursor)
rows = (
query.order_by(ReportingDefinitionRevision.id.asc())
.limit(request.limit + 1)
.all()
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
tokens = _acl_tokens(db, selected)
high_watermark = (
db.query(func.max(ReportingDefinitionRevision.updated_at))
.filter(
ReportingDefinitionRevision.tenant_id == request.tenant_id,
ReportingDefinitionRevision.definition_kind == "report",
ReportingDefinitionRevision.superseded_at.is_(None),
)
.scalar()
)
return SearchBackfillPage(
documents=tuple(
_document(row, tokens[row.definition_id]) for row in selected
),
next_cursor=selected[-1].id if has_more and selected else None,
complete=not has_more,
high_watermark=(
high_watermark.isoformat() if high_watermark is not None else None
),
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
db = _session(session)
tenant_id = str(getattr(principal, "tenant_id", "") or "")
decisions = {request.reference.key: False for request in requests}
for request in requests:
reference = request.reference
if (
reference.tenant_id != tenant_id
or reference.module_id != "reporting"
or reference.resource_type != RESOURCE_TYPE
):
continue
decisions[reference.key] = can_read_definition(
db,
principal,
definition_kind="report",
definition_id=reference.resource_id,
)
return decisions
def create_reporting_search_source(
context: ModuleContext,
) -> ReportingSearchSource:
del context
return ReportingSearchSource()
def _document(
row: ReportingDefinitionRevision,
tokens: tuple[str, ...],
) -> SearchDocument:
restricted_tokens = tuple(
dict.fromkeys((*tokens, "scope:reporting:definition:admin"))
)
return SearchDocument(
tenant_id=row.tenant_id,
module_id="reporting",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=row.definition_id,
title=row.name,
url=f"/reporting?reportId={quote(row.definition_id, safe='')}",
summary=row.description,
body=row.description,
keywords=(row.definition_key, row.status),
visibility=row.visibility,
acl_tokens=restricted_tokens if row.visibility == "restricted" else (),
source_revision=str(row.revision),
source_updated_at=row.updated_at or row.recorded_at,
metadata={
"definition_key": row.definition_key,
"status": row.status,
"content_hash": row.content_hash,
},
requires_authorization_recheck=True,
)
def _acl_tokens(
session: Session,
rows: Sequence[ReportingDefinitionRevision],
) -> Mapping[str, tuple[str, ...]]:
result: dict[str, list[str]] = defaultdict(list)
if not rows:
return result
identity_ids = {row.identity_id for row in rows}
for identity in (
session.query(ReportingDefinitionIdentity)
.filter(ReportingDefinitionIdentity.id.in_(identity_ids))
.all()
):
if identity.created_by:
result[identity.definition_id].append(f"account:{identity.created_by}")
definition_ids = {row.definition_id for row in rows}
grants = (
session.query(ReportingDefinitionGrant)
.filter(
ReportingDefinitionGrant.tenant_id == rows[0].tenant_id,
ReportingDefinitionGrant.definition_kind == "report",
ReportingDefinitionGrant.definition_id.in_(definition_ids),
ReportingDefinitionGrant.active.is_(True),
)
.all()
)
for grant in grants:
prefix = (
"function"
if grant.subject_kind == "function_assignment"
else grant.subject_kind
)
result[grant.definition_id].append(f"{prefix}:{grant.subject_id}")
return {
definition_id: tuple(dict.fromkeys(values))
for definition_id, values in result.items()
}
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Reporting search requires a SQLAlchemy session.")
return value
__all__ = [
"PROVIDER_ID",
"RESOURCE_TYPE",
"ReportingSearchSource",
"create_reporting_search_source",
]
+1
View File
@@ -0,0 +1 @@
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import unittest
from govoplan_reporting.backend.manifest import get_manifest
class ReportingManifestTests(unittest.TestCase):
def test_manifest_exposes_the_governed_reporting_vertical(self) -> None:
manifest = get_manifest()
self.assertEqual("reporting", manifest.id)
self.assertEqual("vertical_slice", manifest.architecture.maturity)
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(1, len(manifest.search_sources))
self.assertIn("dataflow", manifest.optional_dependencies)
self.assertIn("policy", manifest.optional_dependencies)
self.assertEqual("/reports", manifest.nav_items[0].path)
self.assertEqual(
{"/reports", "/reporting"},
{route.path for route in manifest.frontend.routes},
)
self.assertIn(
"report execution and publication evidence",
manifest.architecture.owned_concepts,
)
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from pathlib import Path
import tempfile
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_reporting.backend.manifest import get_manifest
def test_fresh_migration_creates_provider_evidence_tables_and_current_head() -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-reporting-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'reporting.db'}"
migrate_database(
database_url=url,
enabled_modules=("reporting",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
with engine.connect() as connection:
tables = set(inspect(connection).get_table_names())
assert {
"reporting_provider_executions",
"reporting_drill_contexts",
"reporting_provider_exports",
}.issubset(tables)
assert "b7c4e1a9d2f6" in set(
MigrationContext.configure(connection).get_current_heads()
)
columns = {
item["name"]
for item in inspect(connection).get_columns(
"reporting_provider_executions"
)
}
assert "retention_redacted_at" in columns
finally:
engine.dispose()
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
_ROOT = Path(__file__).resolve().parents[1]
def test_campaign_starts_and_contributes_its_provider_without_reporting() -> None:
script = """
import importlib.abc
import sys
class Blocker(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname == 'govoplan_reporting' or fullname.startswith('govoplan_reporting.'):
raise ModuleNotFoundError("Reporting is physically absent", name=fullname)
return None
sys.meta_path.insert(0, Blocker())
from govoplan_campaign.backend.manifest import get_manifest
manifest = get_manifest()
assert 'reporting' not in manifest.dependencies
assert 'reporting' in manifest.optional_dependencies
assert 'reporting.report_provider.campaigns' in manifest.capability_factories
"""
_run_probe(script)
def test_reporting_starts_without_campaign_and_owns_the_global_route() -> None:
script = """
import importlib.abc
import sys
class Blocker(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname == 'govoplan_campaign' or fullname.startswith('govoplan_campaign.'):
raise ModuleNotFoundError("Campaign is physically absent", name=fullname)
return None
sys.meta_path.insert(0, Blocker())
from govoplan_reporting.backend.manifest import get_manifest
manifest = get_manifest()
assert 'campaigns' not in manifest.dependencies
assert manifest.nav_items[0].path == '/reports'
assert {item.path for item in manifest.frontend.routes} == {'/reports', '/reporting'}
"""
_run_probe(script)
def test_reporting_with_campaign_uses_only_the_provider_contract() -> None:
script = """
from govoplan_campaign.backend.manifest import get_manifest as campaign_manifest
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
from govoplan_reporting.backend.manifest import get_manifest as reporting_manifest
reporting = reporting_manifest()
campaign = campaign_manifest()
provider_name = REPORT_PROVIDER_CAPABILITY_PREFIX + 'campaigns'
assert provider_name in campaign.capability_factories
assert provider_name in {item.name for item in campaign.provides_interfaces}
assert 'campaigns' not in reporting.dependencies
assert '/reports' not in {route.path for route in campaign.frontend.routes}
provider = campaign.capability_factories[provider_name](None)
assert provider.provider_id == 'campaigns'
assert provider.contract_version == '1.0'
"""
_run_probe(script)
def test_reporting_starts_without_files_or_mail_and_keeps_targets_optional() -> None:
script = """
import importlib.abc
import sys
class Blocker(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname == 'govoplan_files' or fullname.startswith('govoplan_files.'):
raise ModuleNotFoundError("Files is physically absent", name=fullname)
if fullname == 'govoplan_mail' or fullname.startswith('govoplan_mail.'):
raise ModuleNotFoundError("Mail is physically absent", name=fullname)
return None
sys.meta_path.insert(0, Blocker())
from govoplan_reporting.backend.contracts import (
CAPABILITY_REPORTING_PUBLICATION_FILES,
CAPABILITY_REPORTING_PUBLICATION_MAIL,
)
from govoplan_reporting.backend.manifest import get_manifest
manifest = get_manifest()
assert 'files' in manifest.optional_dependencies
assert 'mail' in manifest.optional_dependencies
assert CAPABILITY_REPORTING_PUBLICATION_FILES in manifest.capability_factories
assert CAPABILITY_REPORTING_PUBLICATION_MAIL in manifest.capability_factories
"""
_run_probe(script)
def _run_probe(source: str) -> None:
environment = dict(os.environ)
environment["PYTHONPATH"] = os.pathsep.join(
(
str(_ROOT / "src"),
str(_ROOT.parent / "govoplan-campaign" / "src"),
str(_ROOT.parent / "govoplan-core" / "src"),
environment.get("PYTHONPATH", ""),
)
)
result = subprocess.run(
[sys.executable, "-c", source],
cwd=_ROOT,
env=environment,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout
+336
View File
@@ -0,0 +1,336 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.reporting import (
REPORT_PROVIDER_CAPABILITY_PREFIX,
REPORT_PROVIDER_CONTRACT_VERSION,
ReportDescriptor,
ReportParameterDescriptor,
ReportParameterOption,
ReportPrivacyTransform,
ReportProviderRequest,
ReportProviderResult,
ReportResultField,
)
from govoplan_core.db.base import Base
from govoplan_reporting.backend.db.models import (
ReportingProviderExecution,
ReportingProviderExport,
)
from govoplan_reporting.backend.provider_reports import (
ProviderReportError,
execute_provider_report,
export_provider_execution,
list_provider_reports,
)
from govoplan_reporting.backend.retention import ReportingRetentionService
class _Principal:
tenant_id = "tenant-1"
user = SimpleNamespace(id="analyst-1")
api_key = None
class _Provider:
provider_id = "example"
contract_version = REPORT_PROVIDER_CONTRACT_VERSION
def __init__(self) -> None:
self.result_access = True
def list_reports(self, session, principal):
del session, principal
return (
ReportDescriptor(
provider_id=self.provider_id,
report_id="summary",
revision="example.summary.v1",
title="Example summary",
summary="A minimized example report.",
parameters=(
ReportParameterDescriptor(
key="source_id",
label="Source",
type="reference",
required=True,
options_from_provider=True,
),
),
result_schema=(
ReportResultField(
path="metric",
label="Metric",
type="integer",
group="Outcome",
),
),
privacy_transforms=(
ReportPrivacyTransform(id="aggregate", label="Aggregate"),
),
export_formats=("json",),
reidentification_risk="low",
),
)
def parameter_options(self, session, principal, **kwargs):
del session, principal, kwargs
return (ReportParameterOption(value="source-1", label="Source 1"),)
def execute_report(
self,
session,
principal,
*,
request: ReportProviderRequest,
):
del session
return ReportProviderResult(
report_id=request.report_id,
generated_at=datetime(2026, 8, 2, 10, 0, tzinfo=UTC),
payload={"metric": 12},
source_revisions=(
{
"module_id": "example",
"resource_id": request.parameters["source_id"],
"revision_id": "7",
},
),
effective_scope={
"tenant_id": principal.tenant_id,
"source_id": request.parameters["source_id"],
},
applied_privacy_transforms=("aggregate",),
provenance={"executor": "example-v1"},
)
def authorize_result(self, session, principal, **kwargs):
del session, principal, kwargs
return self.result_access
class _Registry:
def __init__(self, provider=None):
self.provider = provider or _Provider()
def capability_names(self):
return (REPORT_PROVIDER_CAPABILITY_PREFIX + "example",)
def has_capability(self, name):
return name == REPORT_PROVIDER_CAPABILITY_PREFIX + "example"
def capability(self, name):
return self.provider if self.has_capability(name) else None
def test_provider_report_execution_is_governed_replayable_and_audited() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
principal = _Principal()
registry = _Registry()
with Session(engine, expire_on_commit=False) as session:
catalogue = list_provider_reports(session, principal, registry=registry)
assert catalogue["reports"][0]["available"] is True
execution = execute_provider_report(
session,
principal,
registry=registry,
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="provider-run-1",
)
replay = execute_provider_report(
session,
principal,
registry=registry,
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="provider-run-1",
)
assert replay["execution_id"] == execution["execution_id"]
assert execution["source_revisions"][0]["revision_id"] == "7"
assert execution["privacy_transforms"] == ["aggregate"]
assert execution["retention_days"] == 30
content, media_type, filename = export_provider_execution(
session,
principal,
registry=registry,
execution_id=str(execution["execution_id"]),
format="json",
purpose="Archive approved aggregate",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
)
assert b'"metric": 12' in content
assert media_type == "application/json"
assert filename.endswith(".json")
assert session.query(ReportingProviderExport).count() == 1
engine.dispose()
def test_provider_result_missing_a_required_transform_is_rejected() -> None:
class _UnsafeProvider(_Provider):
def execute_report(self, session, principal, *, request):
result = super().execute_report(
session,
principal,
request=request,
)
return ReportProviderResult(
report_id=result.report_id,
generated_at=result.generated_at,
payload=result.payload,
source_revisions=result.source_revisions,
effective_scope=result.effective_scope,
applied_privacy_transforms=(),
provenance=result.provenance,
)
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
try:
execute_provider_report(
session,
_Principal(),
registry=_Registry(_UnsafeProvider()),
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant"},
idempotency_key="unsafe-provider-run",
)
except ProviderReportError as exc:
assert "privacy transformations" in str(exc)
else:
raise AssertionError("unsafe provider report was accepted")
engine.dispose()
def test_expired_provider_result_is_minimized_but_evidence_is_retained() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine, expire_on_commit=False) as session:
execution = execute_provider_report(
session,
_Principal(),
registry=_Registry(),
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="expired-provider-run",
)
row = session.query(ReportingProviderExecution).one()
original_hash = row.output_hash
row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
session.flush()
preview = ReportingRetentionService().apply_retention(
session,
dry_run=True,
now=datetime.now(UTC),
)
assert preview["eligible"] == 1
assert row.result_payload == {"metric": 12}
applied = ReportingRetentionService().apply_retention(
session,
dry_run=False,
now=datetime.now(UTC),
)
assert applied["redacted"] == 1
assert row.result_payload == {}
assert row.retention_redacted_at is not None
assert row.output_hash == original_hash == execution["output_hash"]
assert row.source_revisions[0]["revision_id"] == "7"
try:
execute_provider_report(
session,
_Principal(),
registry=_Registry(),
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="expired-provider-run",
)
except ProviderReportError as exc:
assert "expired" in str(exc)
else:
raise AssertionError("expired provider-report detail was replayed")
engine.dispose()
def test_provider_parameters_are_checked_against_the_declared_type() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
try:
execute_provider_report(
session,
_Principal(),
registry=_Registry(),
provider_id="example",
report_id="summary",
parameters={"source_id": 123},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="invalid-provider-parameters",
)
except ProviderReportError as exc:
assert "parameter types" in str(exc)
else:
raise AssertionError("invalid provider-report parameters were accepted")
engine.dispose()
def test_source_access_is_rechecked_before_retained_result_export() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
provider = _Provider()
registry = _Registry(provider)
with Session(engine) as session:
execution = execute_provider_report(
session,
_Principal(),
registry=registry,
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="source-access-provider-run",
)
provider.result_access = False
try:
export_provider_execution(
session,
_Principal(),
registry=registry,
execution_id=str(execution["execution_id"]),
format="json",
purpose="Archive aggregate",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
)
except PermissionError as exc:
assert "source module" in str(exc)
else:
raise AssertionError("revoked source result was exported")
engine.dispose()
+902
View File
@@ -0,0 +1,902 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.db.base import Base
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
DataflowDatasetResult,
)
from govoplan_core.core.files import (
CAPABILITY_FILES_ARTIFACT_STORE,
ManagedArtifactRef,
)
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_reporting.backend.definitions import (
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
ReportingDefinitionError,
create_definition,
definition_history,
get_definition,
list_definitions,
update_definition,
)
from govoplan_reporting.backend.execution import (
QUALITY_SCOPE,
RUN_SCOPE,
ReportingExecutionFailure,
execute_report,
run_quality_plan,
)
from govoplan_reporting.backend.drilldown import (
ReportingDrillError,
create_drill_context,
resolve_drill_context,
)
from govoplan_reporting.backend.operations import (
IMPORT_SCOPE,
PUBLISH_SCOPE,
SCHEDULE_SCOPE,
ReportingOperationError,
assess_import,
dispatch_due_schedules,
export_execution,
list_publications,
publish_execution,
upsert_saved_view,
upsert_schedule,
)
from govoplan_reporting.backend.schemas import ReportQuery
from govoplan_reporting.backend.postgres_planner import compile_postgres_query
from govoplan_reporting.backend.contracts import (
CAPABILITY_REPORTING_PUBLICATION_FILES,
)
from govoplan_reporting.backend.publication_targets import (
FilesReportingPublicationTarget,
publication_target_catalog,
)
from govoplan_reporting.backend.schemas import (
DatasetDefinition,
SemanticModelDefinition,
)
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
ALL_SCOPES = (
READ_SCOPE,
WRITE_SCOPE,
ADMIN_SCOPE,
RUN_SCOPE,
QUALITY_SCOPE,
PUBLISH_SCOPE,
SCHEDULE_SCOPE,
IMPORT_SCOPE,
)
class Principal:
def __init__(
self,
tenant_id: str = "tenant-1",
account_id: str = "analyst-1",
*,
scopes: tuple[str, ...] = ALL_SCOPES,
group_ids: tuple[str, ...] = (),
) -> None:
self.tenant_id = tenant_id
self.account_id = account_id
self.identity_id = f"identity-{account_id}"
self.membership_id = f"membership-{account_id}"
self.group_ids = frozenset(group_ids)
self.role_ids = frozenset()
self.function_assignment_ids = frozenset()
self.acting_assignment_id = None
self.service_account_id = None
self.scopes = frozenset(scopes)
self.user = SimpleNamespace(id=f"user-{account_id}")
def has(self, scope: str) -> bool:
return scopes_grant_compatible(self.scopes, scope)
class CapabilityRegistry:
def __init__(self) -> None:
self.providers: dict[str, object] = {}
def has_capability(self, name: str) -> bool:
return name in self.providers
def capability(self, name: str) -> object | None:
return self.providers.get(name)
class ArtifactStore:
def __init__(self) -> None:
self.requests: list[object] = []
def store_artifact(self, session, principal, *, request):
del session, principal
self.requests.append(request)
return ManagedArtifactRef(
file_asset_id="asset-1",
file_version_id="version-1",
filename=request.filename,
display_path=f"{request.folder}/{request.filename}",
content_type=request.content_type,
size_bytes=len(request.payload),
sha256="a" * 64,
provenance={"stored": True},
)
class DataflowOutput:
def __init__(self, rows: list[dict[str, object]]) -> None:
self.rows = tuple(dict(item) for item in rows)
self.last_request = None
def list_outputs(self, *_args, **_kwargs):
return ()
def read_output(self, _session, _principal, *, request):
self.last_request = request
return DataflowDatasetResult(
pipeline_ref=request.pipeline_ref,
revision=request.revision,
definition_hash=request.expected_definition_hash or "pipeline-hash",
rows=self.rows,
total_rows=len(self.rows),
truncated=False,
output_hash="d" * 64,
executor_version="duckdb-v1",
run_ref=request.run_ref,
source_fingerprints=(
{"node_id": "source", "fingerprint": "source-v1"},
),
generated_at=NOW,
provenance={"immutable_run": bool(request.run_ref)},
)
class ReportingServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine, expire_on_commit=False)
self.principal = Principal()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_definition_graph_is_pinned_versioned_and_occ_guarded(self) -> None:
dataset, semantic, report = self._create_report_graph()
self.assertEqual(
("dataset", dataset.definition_id, 1),
(
semantic.parent_kind,
semantic.parent_id,
semantic.parent_revision,
),
)
replay = self._create(
"report",
"report-1",
report_payload(),
idempotency_key="create-report",
)
self.assertEqual(report.content_hash, replay.content_hash)
updated = update_definition(
self.session,
self.principal,
definition_kind="report",
definition_id="report-1",
expected_revision=1,
recorded_at=NOW.replace(hour=11),
change_reason="Clarify the institutional question.",
idempotency_key="update-report",
changes={"description": "Regional workload and value."},
)
self.assertEqual(2, updated.revision)
self.assertEqual(
[2, 1],
[
item.revision
for item in definition_history(
self.session,
self.principal,
definition_kind="report",
definition_id="report-1",
)
],
)
with self.assertRaisesRegex(ReportingDefinitionError, "stale"):
update_definition(
self.session,
self.principal,
definition_kind="report",
definition_id="report-1",
expected_revision=1,
recorded_at=NOW.replace(hour=12),
change_reason="Stale edit.",
idempotency_key="stale-report",
changes={"name": "Stale report"},
)
def test_active_children_require_exact_active_parent_revision(self) -> None:
self._create("dataset", "dataset-draft", dataset_payload(), status="draft")
payload = semantic_payload(dataset_id="dataset-draft")
with self.assertRaisesRegex(ReportingDefinitionError, "active dataset"):
self._create("semantic_model", "semantic-invalid", payload)
payload["dataset_id"] = "missing"
with self.assertRaisesRegex(ReportingDefinitionError, "missing dataset"):
self._create("semantic_model", "semantic-missing", payload)
def test_semantic_execution_replay_pivot_saved_view_and_schedule(self) -> None:
self._create_report_graph()
result = execute_report(
self.session,
self.principal,
registry=None,
report_id="report-1",
report_revision=1,
parameters={},
query=None,
idempotency_key="run-report-1",
)
self.assertEqual("succeeded", result["status"])
self.assertEqual(
[
{"region": "North", "amount": 150, "cases": 15, "value_per_case": 10},
{"region": "South", "amount": 40, "cases": 8, "value_per_case": 5},
],
result["rows"],
)
self.assertEqual(
result["execution_id"],
execute_report(
self.session,
self.principal,
registry=None,
report_id="report-1",
report_revision=1,
parameters={},
query=None,
idempotency_key="run-report-1",
)["execution_id"],
)
self.assertEqual("bar", result["visualization"]["kind"])
self.assertIsNotNone(result["visualization"]["tabular_fallback"])
pivot = execute_report(
self.session,
self.principal,
registry=None,
report_id="report-1",
report_revision=1,
parameters={},
query=ReportQuery.model_validate(
{
"mode": "pivot",
"pivot": {
"rows": ["region"],
"columns": ["category"],
"measures": ["amount"],
},
}
),
idempotency_key="run-report-pivot",
)
self.assertEqual(2, pivot["total_rows"])
self.assertEqual(100, pivot["rows"][0]["A.amount"])
view = upsert_saved_view(
self.session,
self.principal,
view_id="view-1",
report_id="report-1",
report_revision=1,
name="Regional pivot",
state={"query": pivot["query"]},
shared=True,
access={},
expected_revision=None,
)
self.assertEqual(1, view["revision"])
schedule = upsert_schedule(
self.session,
self.principal,
schedule_id="schedule-1",
report_id="report-1",
report_revision=1,
name="Daily regional report",
trigger_kind="interval",
trigger_config={"seconds": 86_400},
parameters={},
query=ReportQuery(),
publication_target={},
enabled=True,
next_run_at=NOW,
expected_revision=None,
)
self.assertEqual(1, schedule["revision"])
dispatched = dispatch_due_schedules(
self.session,
self.principal,
registry=None,
now=NOW,
limit=10,
)
self.assertEqual(
(1, 1, 0),
(
dispatched["claimed"],
dispatched["succeeded"],
dispatched["failed"],
),
)
def test_quality_gate_blocks_and_preserves_failure_evidence(self) -> None:
self._create_report_graph()
self._create(
"quality_plan",
"quality-1",
{
"dataset_id": "dataset-1",
"dataset_revision": 1,
"block_report_execution": True,
"assertions": [
{
"key": "region-unique",
"kind": "unique",
"field": "region",
"severity": "blocker",
}
],
},
)
quality = run_quality_plan(
self.session,
self.principal,
registry=None,
quality_plan_id="quality-1",
quality_plan_revision=1,
parameters={},
)
self.assertEqual("failed", quality["status"])
with self.assertRaises(ReportingExecutionFailure) as raised:
execute_report(
self.session,
self.principal,
registry=None,
report_id="report-1",
report_revision=1,
parameters={},
query=None,
idempotency_key="quality-blocked-run",
)
self.assertTrue(raised.exception.execution_id)
def test_dataflow_dataset_can_pin_an_exact_published_run(self) -> None:
payload = dataset_payload()
rows = list(payload.pop("static_rows"))
payload.update(
{
"source_kind": "dataflow",
"source_ref": "pipeline:monthly-comparison",
"source_revision": 4,
"source_run_ref": "dataflow-run:published-july",
"definition_hash": "pipeline-hash",
}
)
self._create("dataset", "dataset-1", payload)
self._create("semantic_model", "semantic-1", semantic_payload())
self._create("report", "report-1", report_payload())
provider = DataflowOutput(rows)
registry = CapabilityRegistry()
registry.providers[CAPABILITY_DATAFLOW_DATASET_OUTPUT] = provider
result = execute_report(
self.session,
self.principal,
registry=registry,
report_id="report-1",
report_revision=1,
parameters={},
query=None,
idempotency_key="published-dataflow-run",
)
self.assertEqual("succeeded", result["status"])
self.assertIsNotNone(provider.last_request)
self.assertEqual(
"dataflow-run:published-july",
provider.last_request.run_ref,
)
self.assertTrue(result["provenance"]["source"]["immutable_run"])
def test_restricted_access_and_service_scope_guards(self) -> None:
self._create_report_graph(
report_access={
"subjects": [
{
"kind": "group",
"id": "group-auditors",
"permissions": ["read"],
}
]
},
report_visibility="restricted",
)
regular_scopes = (READ_SCOPE, WRITE_SCOPE, RUN_SCOPE)
outsider = Principal(account_id="outsider", scopes=regular_scopes)
auditor = Principal(
account_id="auditor",
scopes=regular_scopes,
group_ids=("group-auditors",),
)
self.assertIsNone(
get_definition(
self.session,
outsider,
definition_kind="report",
definition_id="report-1",
)
)
self.assertIsNotNone(
get_definition(
self.session,
auditor,
definition_kind="report",
definition_id="report-1",
)
)
self.assertEqual(
0,
list_definitions(
self.session,
outsider,
definition_kinds=("report",),
)[1],
)
without_permissions = Principal(account_id="none", scopes=())
with self.assertRaises(PermissionError):
list_definitions(self.session, without_permissions)
with self.assertRaises(PermissionError):
self._create(
"dataset",
"unauthorized",
dataset_payload(),
principal=without_permissions,
)
def test_safe_export_and_import_activation_assessment(self) -> None:
self._create_report_graph()
detail = execute_report(
self.session,
self.principal,
registry=None,
report_id="report-1",
report_revision=1,
parameters={},
query=ReportQuery(mode="detail", dimensions=["note"]),
idempotency_key="detail-export",
)
content, content_type, _filename = export_execution(
self.session,
self.principal,
execution_id=str(detail["execution_id"]),
format="csv",
)
self.assertEqual("text/csv; charset=utf-8", content_type)
self.assertIn("'=cmd", content.decode("utf-8-sig"))
blocked = assess_import(
self.session,
self.principal,
source_system="SuperX",
source_id="legacy-report",
metadata={"features": ["dataset", "raw_sql", "dashboard_layout"]},
accepted_approximations=[],
)
self.assertEqual("blocked", blocked["status"])
self.assertFalse(blocked["mapping_report"]["activation_allowed"])
ready = assess_import(
self.session,
self.principal,
source_system="SuperX",
source_id="mapped-report",
metadata={"features": ["dataset", "dashboard_layout"]},
accepted_approximations=["dashboard_layout"],
)
self.assertEqual("ready", ready["status"])
with self.assertRaisesRegex(ReportingOperationError, "must be a list"):
assess_import(
self.session,
self.principal,
source_system="invalid",
source_id="invalid",
metadata={"features": "raw_sql"},
accepted_approximations=[],
)
def test_drill_context_is_bounded_actor_bound_and_reauthorized(self) -> None:
self._create_report_graph()
execution = execute_report(
self.session,
self.principal,
registry=None,
report_id="report-1",
report_revision=1,
parameters={},
query=None,
idempotency_key="drill-source",
)
north = next(row for row in execution["rows"] if row["region"] == "North")
context = create_drill_context(
self.session,
self.principal,
registry=None,
execution_id=str(execution["execution_id"]),
aggregate_row=north,
limit=50,
)
detail = resolve_drill_context(
self.session,
self.principal,
registry=None,
token=str(context["token"]),
)
self.assertEqual(2, detail["total_rows"])
self.assertEqual({"North"}, {row["region"] for row in detail["rows"]})
self.assertEqual("region", detail["dimension_path"][0]["dimension"])
with self.assertRaises(PermissionError):
resolve_drill_context(
self.session,
Principal(account_id="another-analyst"),
registry=None,
token=str(context["token"]),
)
with self.assertRaises(ReportingDrillError):
create_drill_context(
self.session,
self.principal,
registry=None,
execution_id=str(execution["execution_id"]),
aggregate_row={"region": "Not an execution row"},
limit=50,
)
def test_governance_scope_inheritance_never_broadens_parent_limits(self) -> None:
system = Principal(
scopes=(*ALL_SCOPES, "system:governance:write"),
group_ids=("group-reporters",),
)
dataset = dataset_payload()
dataset["governance"] = {
"scope_type": "system",
"inherit_to_lower_scopes": True,
"allow_run": True,
"allow_reuse": True,
"allow_automation": False,
}
self._create("dataset", "dataset-governed", dataset, principal=system)
semantic = semantic_payload(dataset_id="dataset-governed")
semantic["governance"] = {
"scope_type": "tenant",
"inherit_to_lower_scopes": True,
"allow_run": True,
"allow_reuse": True,
"allow_automation": True,
}
with self.assertRaisesRegex(ValueError, "cannot broaden inherited limits"):
self._create(
"semantic_model",
"semantic-broadened",
semantic,
principal=system,
)
semantic["governance"]["allow_automation"] = False
semantic_record = self._create(
"semantic_model",
"semantic-governed",
semantic,
principal=system,
)
semantic_governance = semantic_record.payload["governance"]
self.assertEqual("system", semantic_governance["source_scope"]["scope_type"])
self.assertFalse(
semantic_governance["source_effective_limits"]["allow_automation"]
)
report = report_payload()
report["semantic_model_id"] = "semantic-governed"
report["governance"] = {
"scope_type": "group",
"scope_id": "group-reporters",
"inherit_to_lower_scopes": False,
"allow_run": True,
"allow_reuse": False,
"allow_automation": False,
}
self._create("report", "report-governed", report, principal=system)
self.assertIsNotNone(
get_definition(
self.session,
system,
definition_kind="report",
definition_id="report-governed",
)
)
self.assertIsNone(
get_definition(
self.session,
Principal(account_id="outsider"),
definition_kind="report",
definition_id="report-governed",
)
)
def test_postgres_plan_is_bounded_and_parameterized(self) -> None:
dataset = DatasetDefinition.model_validate(dataset_payload())
semantic = SemanticModelDefinition.model_validate(semantic_payload())
query = ReportQuery.model_validate(
{
"mode": "summary",
"dimensions": ["region"],
"measures": ["amount", "value_per_case"],
"filters": [
{
"dimension": "region",
"operator": "contains",
"value": "North%' OR TRUE --",
}
],
"sort": [{"key": "amount", "direction": "desc"}],
"limit": 25,
}
)
plan = compile_postgres_query(dataset, semantic, query)
self.assertIn("GROUP BY", plan.sql)
self.assertIn("LIMIT :result_limit OFFSET :result_offset", plan.sql)
self.assertNotIn("North%' OR TRUE --", plan.sql)
self.assertIn("North", str(plan.parameters["filter_0"]))
calculated_only = compile_postgres_query(
dataset,
semantic,
ReportQuery(
mode="summary",
dimensions=["region"],
measures=["value_per_case"],
),
)
self.assertIn('SUM(NULLIF(source_row ->> :measure_0, \'\')::numeric)', calculated_only.sql)
self.assertIn('AS "value_per_case"', calculated_only.sql)
def test_files_publication_is_idempotent_and_retains_evidence(self) -> None:
self._create_report_graph()
registry = CapabilityRegistry()
store = ArtifactStore()
registry.providers[CAPABILITY_FILES_ARTIFACT_STORE] = store
registry.providers[CAPABILITY_REPORTING_PUBLICATION_FILES] = (
FilesReportingPublicationTarget(registry)
)
execution = execute_report(
self.session,
self.principal,
registry=registry,
report_id="report-1",
report_revision=1,
parameters={},
query=None,
idempotency_key="publish-source",
)
first = publish_execution(
self.session,
self.principal,
registry=registry,
execution_id=str(execution["execution_id"]),
target_capability=CAPABILITY_REPORTING_PUBLICATION_FILES,
target_ref="Reports/Monthly",
format="csv",
idempotency_key="publish-files-once",
options={"filename": "regional workload.csv"},
)
replay = publish_execution(
self.session,
self.principal,
registry=registry,
execution_id=str(execution["execution_id"]),
target_capability=CAPABILITY_REPORTING_PUBLICATION_FILES,
target_ref="Reports/Monthly",
format="csv",
idempotency_key="publish-files-once",
options={"filename": "regional workload.csv"},
)
self.assertEqual(first["publication_id"], replay["publication_id"])
self.assertEqual(1, len(store.requests))
self.assertEqual("version-1", first["evidence"]["file_version_id"])
self.assertEqual(
1,
len(
list_publications(
self.session,
self.principal,
execution_id=str(execution["execution_id"]),
)
),
)
targets = publication_target_catalog(registry)
self.assertTrue(targets[0]["available"])
self.assertFalse(targets[1]["available"])
self.assertIn("Enable Mail", str(targets[1]["reason"]))
def _create_report_graph(
self,
*,
report_access: dict[str, object] | None = None,
report_visibility: str = "tenant",
):
dataset = self._create("dataset", "dataset-1", dataset_payload())
semantic = self._create(
"semantic_model",
"semantic-1",
semantic_payload(),
)
report = self._create(
"report",
"report-1",
report_payload(access_policy=report_access),
visibility=report_visibility,
idempotency_key="create-report",
)
return dataset, semantic, report
def _create(
self,
kind: str,
definition_id: str,
payload: dict[str, object],
*,
status: str = "active",
visibility: str = "tenant",
idempotency_key: str | None = None,
principal: Principal | None = None,
):
return create_definition(
self.session,
principal or self.principal,
definition_kind=kind,
definition_id=definition_id,
definition_key=definition_id,
name=definition_id.replace("-", " ").title(),
description="Governed reporting test definition.",
status=status,
visibility=visibility,
recorded_at=NOW,
change_reason="Initial governed baseline.",
payload=payload,
idempotency_key=idempotency_key or f"create-{definition_id}",
)
def dataset_payload() -> dict[str, object]:
return {
"source_kind": "static",
"source_ref": "fixture.regional-workload",
"purpose": "Verify governed institutional reporting.",
"schema": [
{"name": "region", "type": "string", "nullable": False},
{"name": "category", "type": "string", "nullable": False},
{"name": "amount", "type": "number", "nullable": False},
{"name": "cases", "type": "integer", "nullable": False},
{"name": "note", "type": "string", "nullable": False},
],
"static_rows": [
{
"region": "North",
"category": "A",
"amount": 100,
"cases": 10,
"note": "=cmd",
},
{
"region": "North",
"category": "B",
"amount": 50,
"cases": 5,
"note": "ordinary",
},
{
"region": "South",
"category": "A",
"amount": 40,
"cases": 8,
"note": "ordinary",
},
],
}
def semantic_payload(*, dataset_id: str = "dataset-1") -> dict[str, object]:
return {
"dataset_id": dataset_id,
"dataset_revision": 1,
"dimensions": [
{"key": "region", "field": "region", "label": "Region"},
{"key": "category", "field": "category", "label": "Category"},
{"key": "note", "field": "note", "label": "Note"},
],
"hierarchies": [
{
"key": "regional-category",
"label": "Region and category",
"levels": [{"dimension": "region"}, {"dimension": "category"}],
}
],
"measures": [
{
"key": "amount",
"label": "Amount",
"aggregation": "sum",
"field": "amount",
},
{"key": "cases", "label": "Cases", "aggregation": "sum", "field": "cases"},
{
"key": "value_per_case",
"label": "Value per case",
"aggregation": "calculated",
"expression": {
"op": "divide",
"args": [
{"op": "measure", "ref": "amount"},
{"op": "measure", "ref": "cases"},
],
},
},
],
"default_dimensions": ["region"],
"default_measures": ["amount", "cases", "value_per_case"],
}
def report_payload(
*,
access_policy: dict[str, object] | None = None,
) -> dict[str, object]:
return {
"semantic_model_id": "semantic-1",
"semantic_model_revision": 1,
"default_query": {
"mode": "summary",
"dimensions": ["region"],
"measures": ["amount", "cases", "value_per_case"],
"sort": [{"key": "region", "direction": "asc"}],
},
"visualization": {
"kind": "bar",
"category_dimension": "region",
"measures": ["amount"],
"tabular_fallback": True,
},
"access_policy": access_policy or {},
"institutional_references": [
{
"owner_module": "projects",
"resource_type": "outcome",
"resource_id": "faster-decisions",
"revision": "1",
"relationship": "measures",
}
],
}
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@govoplan/reporting-webui",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/reporting.css": "./src/styles/reporting.css"
},
"scripts": {
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"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
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/reporting/ReportingPage.tsx", "utf8");
const provider = fs.readFileSync("src/features/reporting/ProviderReportWorkspace.tsx", "utf8");
const styles = fs.readFileSync("src/styles/reporting.css", "utf8");
assert.ok(page.includes("DocumentationHelpLink"), "Reporting exposes configured-system help");
assert.ok(page.includes("PageScrollViewport"), "Reporting owns bounded catalogue and inspector scrolling");
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.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");
assert.ok(provider.includes("DismissibleAlert"), "Provider failures and unavailable states use shared alerts");
assert.ok(!page.includes("window.alert("), "Reporting must not use browser alerts");
assert.ok(styles.includes("@media (max-width: 760px)"), "Reporting retains a narrow-viewport task order");
console.log("Reporting interface pattern contract passed.");
+543
View File
@@ -0,0 +1,543 @@
import {
apiFetch,
apiPath,
apiUrl,
authHeaders,
type ApiSettings
} from "@govoplan/core-webui";
export type ReportingDefinitionKind = "dataset" | "semantic_model" | "report" | "quality_plan";
export type ReportingQueryMode = "summary" | "detail" | "pivot";
export type ReportingQuery = {
mode: ReportingQueryMode;
dimensions: string[];
measures: string[];
filters: Array<Record<string, unknown>>;
sort: Array<{ key: string; direction: "asc" | "desc" }>;
pivot?: {
rows: string[];
columns: string[];
measures: string[];
include_totals: boolean;
} | null;
offset: number;
limit: number;
};
export type ReportingDefinition = {
tenant_id: string;
definition_kind: ReportingDefinitionKind;
definition_id: string;
definition_key: string;
revision: number;
name: string;
description?: string | null;
status: "draft" | "active" | "retired";
visibility: "tenant" | "restricted";
content_hash: string;
parent_kind?: ReportingDefinitionKind | null;
parent_id?: string | null;
parent_revision?: number | null;
recorded_at: string;
change_reason: string;
payload: Record<string, unknown>;
};
export type SemanticModelPayload = {
dataset_id: string;
dataset_revision: number;
dimensions: Array<{ key: string; field: string; label: string }>;
measures: Array<{ key: string; label: string; aggregation: string }>;
default_dimensions: string[];
default_measures: string[];
};
export type ReportPayload = {
semantic_model_id: string;
semantic_model_revision: number;
parameters: Array<{
key: string;
label: string;
type: string;
required: boolean;
default?: unknown;
allowed_values: unknown[];
}>;
default_query: ReportingQuery;
visualization: {
kind: string;
category_dimension?: string | null;
series_dimension?: string | null;
measures: string[];
tabular_fallback: boolean;
};
institutional_references: Array<Record<string, unknown>>;
};
export type ReportExecution = {
execution_id: string;
report_id: string;
report_revision: number;
semantic_model_id: string;
semantic_model_revision: number;
dataset_id: string;
dataset_revision: number;
status: "running" | "succeeded" | "failed";
parameters: Record<string, unknown>;
query: ReportingQuery;
definition_hashes: Record<string, string>;
source_fingerprints: Array<Record<string, unknown>>;
output_hash?: string | null;
executor_version?: string | null;
schema: Array<{ name: string; type: string }>;
rows: Array<Record<string, unknown>>;
total_rows: number;
truncated: boolean;
diagnostics: Array<{ severity?: string; code?: string; message?: string }>;
provenance: Record<string, unknown>;
started_at: string;
finished_at?: string | null;
visualization?: {
kind: string;
requested_kind?: string;
category?: string | null;
series?: string | null;
measures?: string[];
options?: Record<string, unknown>;
fallback_reason?: string | null;
};
delivery_authorization?: Record<string, unknown>;
};
export type ReportingDrillContext = {
token: string;
drill_context_id: string;
execution_id: string;
dimension_path: Array<{ dimension: string; label: string; value: unknown }>;
expires_at: string;
};
export type ReportingDrillResult = Omit<ReportingDrillContext, "token"> & {
rows: Array<Record<string, unknown>>;
schema: Array<{ name: string; type: string }>;
total_rows: number;
truncated: boolean;
source_fingerprints: Array<Record<string, unknown>>;
policy_provenance: Record<string, unknown>;
};
export type ReportingSchedule = {
schedule_id: string;
report_id: string;
report_revision: number;
name: string;
revision: number;
trigger_kind: "scheduled" | "interval";
trigger_config: Record<string, unknown>;
parameters: Record<string, unknown>;
query: ReportingQuery;
publication_target: Record<string, unknown>;
enabled: boolean;
next_run_at?: string | null;
last_run_at?: string | null;
last_execution_id?: string | null;
};
export type ReportingPublicationTarget = {
capability: string;
label: string;
available: boolean;
reason?: string | null;
formats: string[];
target_label: string;
target_required: boolean;
required_options: string[];
};
export type ReportingPublication = {
publication_id: string;
execution_id: string;
target_capability: string;
target_ref?: string | null;
format: string;
status: string;
evidence: Record<string, unknown>;
error?: string | null;
completed_at?: string | null;
};
export type ReportingSavedView = {
view_id: string;
report_id: string;
report_revision: number;
name: string;
revision: number;
state: { query?: ReportingQuery };
shared: boolean;
updated_at: string;
};
export type ProviderReportParameter = {
key: string;
label: string;
type: string;
required: boolean;
description?: string | null;
options_from_provider: boolean;
};
export type ProviderReportField = {
path: string;
label: string;
type: string;
group: string;
nullable: boolean;
sensitive: boolean;
};
export type ProviderReportDescriptor = {
contract_version: string;
provider_id: string;
report_id: string;
revision: string;
title: string;
summary: string;
parameters: ProviderReportParameter[];
result_schema: ProviderReportField[];
privacy_transforms: Array<{ id: string; label: string; required: boolean }>;
purpose_required: boolean;
audience_scope_required: boolean;
retention_class: string;
export_formats: Array<"json" | "csv">;
reidentification_risk: "low" | "moderate" | "high";
presentation: { kind?: string };
available: boolean;
unavailable_reason?: string | null;
governance: {
retention_days?: number | null;
export_formats: string[];
required_privacy_transforms: string[];
provenance: Record<string, unknown>;
};
};
export type ProviderReportExecution = {
execution_id: string;
provider_id: string;
report_id: string;
report_revision: string;
contract_version: string;
purpose: string;
audience_scope: Record<string, unknown>;
parameters: Record<string, unknown>;
result_schema: ProviderReportField[];
result: Record<string, unknown>;
source_revisions: Array<Record<string, unknown>>;
effective_scope: Record<string, unknown>;
privacy_transforms: string[];
provenance: Record<string, unknown>;
governance_provenance: Record<string, unknown>;
retention_class: string;
retention_days?: number | null;
expires_at?: string | null;
output_hash: string;
generated_at: string;
actor_id?: string | null;
};
export function listDefinitions(
settings: ApiSettings,
options: { kinds?: ReportingDefinitionKind[]; status?: string[]; query?: string; limit?: number },
signal?: AbortSignal
): Promise<{ definitions: ReportingDefinition[]; total: number }> {
return apiFetch(settings, apiPath("/api/v1/reporting/definitions", {
definition_kind: options.kinds,
status: options.status,
query: options.query,
limit: options.limit ?? 200
}), { signal });
}
export function listProviderReports(
settings: ApiSettings,
signal?: AbortSignal
): Promise<{ reports: ProviderReportDescriptor[]; diagnostics: Array<Record<string, string>> }> {
return apiFetch(settings, "/api/v1/reporting/provider-reports", { signal });
}
export function listProviderParameterOptions(
settings: ApiSettings,
report: ProviderReportDescriptor,
parameterKey: string,
query = "",
signal?: AbortSignal
): Promise<{ options: Array<{ value: string; label: string; description?: string | null }> }> {
return apiFetch(settings, apiPath(
`/api/v1/reporting/provider-reports/${encodeURIComponent(report.provider_id)}/${encodeURIComponent(report.report_id)}/parameters/${encodeURIComponent(parameterKey)}/options`,
{ query, limit: 200 }
), { signal });
}
export function runProviderReport(
settings: ApiSettings,
report: ProviderReportDescriptor,
parameters: Record<string, unknown>,
purpose: string,
audienceScope: Record<string, unknown>
): Promise<ProviderReportExecution> {
return apiFetch(
settings,
`/api/v1/reporting/provider-reports/${encodeURIComponent(report.provider_id)}/${encodeURIComponent(report.report_id)}/executions`,
{
method: "POST",
body: JSON.stringify({
parameters,
purpose,
audience_scope: audienceScope,
idempotency_key: crypto.randomUUID()
})
}
);
}
export async function downloadProviderExecution(
settings: ApiSettings,
execution: ProviderReportExecution,
format: "json" | "csv",
purpose: string,
audienceScope: Record<string, unknown>
): Promise<void> {
const response = await fetch(
apiUrl(settings, `/api/v1/reporting/provider-executions/${encodeURIComponent(execution.execution_id)}/exports`),
{
method: "POST",
headers: { ...authHeaders(settings), "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ format, purpose, audience_scope: audienceScope })
}
);
if (!response.ok) throw new Error(await response.text());
const blob = await response.blob();
const disposition = response.headers.get("Content-Disposition") ?? "";
const filename = disposition.match(/filename="?([^";]+)"?/)?.[1] ?? `provider-report.${format}`;
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
export function getDefinition(
settings: ApiSettings,
kind: ReportingDefinitionKind,
id: string,
revision?: number,
signal?: AbortSignal
): Promise<ReportingDefinition> {
return apiFetch(settings, apiPath(
`/api/v1/reporting/definitions/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`,
{ revision }
), { signal });
}
export function runReport(
settings: ApiSettings,
report: ReportingDefinition,
query: ReportingQuery,
parameters: Record<string, unknown>
): Promise<ReportExecution> {
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(report.definition_id)}/executions`, {
method: "POST",
body: JSON.stringify({
report_revision: report.revision,
parameters,
query,
idempotency_key: crypto.randomUUID()
})
});
}
export function listExecutions(
settings: ApiSettings,
reportId: string,
signal?: AbortSignal
): Promise<{ executions: ReportExecution[] }> {
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(reportId)}/executions?limit=30`, { signal });
}
export function createDrillContext(
settings: ApiSettings,
executionId: string,
aggregateRow: Record<string, unknown>,
limit = 200
): Promise<ReportingDrillContext> {
return apiFetch(settings, `/api/v1/reporting/executions/${encodeURIComponent(executionId)}/drill-contexts`, {
method: "POST",
body: JSON.stringify({ aggregate_row: aggregateRow, limit })
});
}
export function resolveDrillContext(
settings: ApiSettings,
token: string,
signal?: AbortSignal
): Promise<ReportingDrillResult> {
return apiFetch(settings, `/api/v1/reporting/drill-contexts/${encodeURIComponent(token)}`, { signal });
}
export function listSavedViews(
settings: ApiSettings,
reportId: string,
signal?: AbortSignal
): Promise<{ views: ReportingSavedView[] }> {
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(reportId)}/saved-views`, { signal });
}
export function saveView(
settings: ApiSettings,
report: ReportingDefinition,
name: string,
query: ReportingQuery
): Promise<ReportingSavedView> {
const viewId = crypto.randomUUID();
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(report.definition_id)}/saved-views/${viewId}`, {
method: "PUT",
body: JSON.stringify({
view_id: viewId,
report_revision: report.revision,
name,
state: { query },
shared: false,
access: {},
expected_revision: null
})
});
}
export function createIntervalSchedule(
settings: ApiSettings,
report: ReportingDefinition,
name: string,
seconds: number,
query: ReportingQuery,
parameters: Record<string, unknown>
): Promise<Record<string, unknown>> {
const scheduleId = crypto.randomUUID();
return apiFetch(settings, `/api/v1/reporting/schedules/${scheduleId}`, {
method: "PUT",
body: JSON.stringify({
schedule_id: scheduleId,
report_id: report.definition_id,
report_revision: report.revision,
name,
trigger_kind: "interval",
trigger_config: { seconds },
parameters,
query,
publication_target: {},
enabled: true,
next_run_at: null,
expected_revision: null
})
});
}
export function listSchedules(
settings: ApiSettings,
reportId: string,
signal?: AbortSignal
): Promise<{ schedules: ReportingSchedule[] }> {
return apiFetch(settings, apiPath("/api/v1/reporting/schedules", { report_id: reportId }), { signal });
}
export function updateSchedule(
settings: ApiSettings,
schedule: ReportingSchedule,
changes: Partial<Pick<ReportingSchedule, "enabled" | "name">>
): Promise<ReportingSchedule> {
return apiFetch(settings, `/api/v1/reporting/schedules/${encodeURIComponent(schedule.schedule_id)}`, {
method: "PUT",
body: JSON.stringify({
schedule_id: schedule.schedule_id,
report_id: schedule.report_id,
report_revision: schedule.report_revision,
name: changes.name ?? schedule.name,
trigger_kind: schedule.trigger_kind,
trigger_config: schedule.trigger_config,
parameters: schedule.parameters,
query: schedule.query,
publication_target: schedule.publication_target,
enabled: changes.enabled ?? schedule.enabled,
next_run_at: schedule.next_run_at ?? null,
expected_revision: schedule.revision
})
});
}
export function listPublicationTargets(
settings: ApiSettings,
signal?: AbortSignal
): Promise<{ targets: ReportingPublicationTarget[] }> {
return apiFetch(settings, "/api/v1/reporting/publication-targets", { signal });
}
export function listPublications(
settings: ApiSettings,
executionId: string,
signal?: AbortSignal
): Promise<{ publications: ReportingPublication[] }> {
return apiFetch(settings, apiPath("/api/v1/reporting/publications", { execution_id: executionId }), { signal });
}
export function publishExecution(
settings: ApiSettings,
executionId: string,
request: {
target_capability: string;
target_ref?: string | null;
format: string;
options: Record<string, unknown>;
}
): Promise<ReportingPublication> {
return apiFetch(settings, `/api/v1/reporting/executions/${encodeURIComponent(executionId)}/publications`, {
method: "POST",
body: JSON.stringify({
...request,
idempotency_key: crypto.randomUUID()
})
});
}
export async function downloadExecution(
settings: ApiSettings,
executionId: string,
format: "csv" | "json"
): Promise<void> {
const response = await fetch(apiUrl(settings, apiPath(
`/api/v1/reporting/executions/${encodeURIComponent(executionId)}/export`,
{ format }
)), {
headers: authHeaders(settings),
credentials: "include"
});
if (!response.ok) throw new Error(await response.text() || "The report export failed.");
const blob = await response.blob();
const disposition = response.headers.get("content-disposition") ?? "";
const filename = disposition.match(/filename="?([^";]+)"?/i)?.[1] ?? `report.${format}`;
const href = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = href;
link.download = filename;
link.click();
URL.revokeObjectURL(href);
}
export function reportPayload(definition: ReportingDefinition): ReportPayload {
return definition.payload as unknown as ReportPayload;
}
export function semanticPayload(definition: ReportingDefinition): SemanticModelPayload {
return definition.payload as unknown as SemanticModelPayload;
}
@@ -0,0 +1,307 @@
import { Download, FileJson, Play, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
Card,
DismissibleAlert,
IconButton,
MetricCard,
StatusBadge,
hasScope,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
downloadProviderExecution,
listProviderParameterOptions,
runProviderReport,
type ProviderReportDescriptor,
type ProviderReportExecution,
type ProviderReportField
} from "../../api/reporting";
export function ProviderReportWorkspace({ settings, auth, report }: {
settings: ApiSettings;
auth: AuthInfo;
report: ProviderReportDescriptor;
}) {
const [parameters, setParameters] = useState<Record<string, unknown>>({});
const [options, setOptions] = useState<Record<string, Array<{ value: string; label: string; description?: string | null }>>>({});
const [purpose, setPurpose] = useState("");
const [execution, setExecution] = useState<ProviderReportExecution | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const tenant = auth.active_tenant ?? auth.tenant;
const canRun = hasScope(auth, "reporting:report:run");
const audienceScope = useMemo(() => ({
scope_type: "tenant",
scope_id: tenant.id,
label: tenant.name
}), [tenant.id, tenant.name]);
useEffect(() => {
setParameters({});
setPurpose("");
setExecution(null);
setError("");
const controller = new AbortController();
const optionParameters = report.parameters.filter((item) => item.options_from_provider);
void Promise.all(optionParameters.map(async (parameter) => {
const result = await listProviderParameterOptions(
settings,
report,
parameter.key,
"",
controller.signal
);
return [parameter.key, result.options] as const;
})).then((entries) => {
setOptions(Object.fromEntries(entries));
setParameters(Object.fromEntries(entries.flatMap(([key, values]) =>
values[0] ? [[key, values[0].value]] : []
)));
}).catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason));
});
return () => controller.abort();
}, [settings, report.provider_id, report.report_id, report.revision]);
async function run() {
setRunning(true);
setError("");
try {
setExecution(await runProviderReport(
settings,
report,
parameters,
purpose.trim(),
audienceScope
));
} catch (reason) {
setError(message(reason));
} finally {
setRunning(false);
}
}
async function download(format: "json" | "csv") {
if (!execution) return;
try {
await downloadProviderExecution(
settings,
execution,
format,
purpose.trim(),
audienceScope
);
} catch (reason) {
setError(message(reason));
}
}
const missingRequired = report.parameters.some((item) =>
item.required && (parameters[item.key] === undefined || parameters[item.key] === "")
);
const runDisabledReason = !canRun
? "Report run permission is required."
: !report.available
? report.unavailable_reason ?? "Policy does not allow this report."
: missingRequired
? "Complete the required report parameters."
: !purpose.trim()
? "Record the purpose for this governed report run."
: undefined;
return (
<>
<header className="reporting-result-header">
<div className="reporting-title">
<span>{report.provider_id} · {report.revision}</span>
<h1>{report.title}</h1>
<p>{report.summary}</p>
</div>
<div className="reporting-run-actions">
<Button
variant="primary"
onClick={() => void run()}
disabled={running || Boolean(runDisabledReason)}
disabledReason={runDisabledReason}>
<Play size={16} aria-hidden="true" /> {running ? "Running" : "Run"}
</Button>
</div>
</header>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{!report.available &&
<DismissibleAlert tone="warning" dismissible={false}>
{report.unavailable_reason ?? "Policy does not allow this report."}
</DismissibleAlert>
}
<div className="reporting-query-controls reporting-provider-controls">
{report.parameters.map((parameter) =>
<label className="reporting-parameter" key={parameter.key}>
<span>{parameter.label}{parameter.required ? " *" : ""}</span>
{parameter.options_from_provider ?
<select
value={String(parameters[parameter.key] ?? "")}
onChange={(event) => setParameters((current) => ({ ...current, [parameter.key]: event.target.value }))}>
{!parameter.required && <option value="">Current/default</option>}
{(options[parameter.key] ?? []).map((item) =>
<option value={item.value} key={item.value}>{item.label}{item.description ? ` · ${item.description}` : ""}</option>
)}
</select> :
<input
type={parameter.type === "integer" || parameter.type === "number" ? "number" : parameter.type === "date" ? "date" : "text"}
value={String(parameters[parameter.key] ?? "")}
onChange={(event) => setParameters((current) => ({ ...current, [parameter.key]: event.target.value }))}
/>
}
{parameter.description && <small>{parameter.description}</small>}
</label>
)}
<label className="reporting-parameter reporting-provider-purpose">
<span>Purpose *</span>
<input
value={purpose}
maxLength={1000}
onChange={(event) => setPurpose(event.target.value)}
placeholder="Purpose recorded with this execution"
/>
</label>
<label className="reporting-parameter">
<span>Effective audience</span>
<input value={tenant.name} readOnly />
</label>
</div>
{execution ?
<>
<div className="reporting-output-toolbar">
<span>Generated {formatDateTime(execution.generated_at)}</span>
{report.export_formats.includes("csv") &&
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void download("csv")} />
}
{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>
}
</>
);
}
export function ProviderReportInspector({ report }: { report: ProviderReportDescriptor }) {
return (
<div className="reporting-inspector-content">
<section className="reporting-provenance">
<h2><ShieldCheck size={16} /> Governance</h2>
<dl>
<dt>Risk</dt><dd><StatusBadge status={report.reidentification_risk === "high" ? "warning" : "active"} label={humanize(report.reidentification_risk)} /></dd>
<dt>Retention</dt><dd>{report.governance.retention_days == null ? "Policy managed" : `${report.governance.retention_days} days`}</dd>
<dt>Exports</dt><dd>{report.governance.export_formats.join(", ") || "Disabled"}</dd>
<dt>Contract</dt><dd>{report.contract_version}</dd>
</dl>
</section>
<section>
<h2>Privacy transforms</h2>
{report.privacy_transforms.map((item) =>
<p key={item.id}>{item.label}{item.required ? " · required" : ""}</p>
)}
</section>
</div>
);
}
function ProviderResult({ execution }: { execution: ProviderReportExecution }) {
const groups = groupFields(execution.result_schema);
return (
<>
{[...groups].map(([group, fields]) => {
const metrics = fields.filter((field) => field.type === "suppressed_count");
const details = fields.filter((field) => field.type !== "suppressed_count");
return (
<Card title={group} key={group}>
{metrics.length > 0 &&
<div className="dashboard-grid reporting-provider-metrics">
{metrics.map((field) =>
<MetricCard key={field.path} label={field.label} value={displayValue(pathValue(execution.result, field.path))} />
)}
</div>
}
{details.length > 0 &&
<dl className="detail-list">
{details.map((field) =>
<div key={field.path}>
<dt>{field.label}</dt>
<dd>{displayValue(pathValue(execution.result, field.path), field)}</dd>
</div>
)}
</dl>
}
</Card>
);
})}
<Card title="Provenance">
<dl className="detail-list">
<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>
</Card>
</>
);
}
function groupFields(fields: ProviderReportField[]): Map<string, ProviderReportField[]> {
const groups = new Map<string, ProviderReportField[]>();
for (const field of fields) groups.set(field.group, [...(groups.get(field.group) ?? []), field]);
return groups;
}
function pathValue(payload: Record<string, unknown>, path: string): unknown {
let value: unknown = payload;
for (const part of path.split(".")) {
if (!value || typeof value !== "object") return null;
value = (value as Record<string, unknown>)[part];
}
return value;
}
function displayValue(value: unknown, field?: ProviderReportField): string | number {
if (value && typeof value === "object") {
const count = value as { value?: unknown; suppressed?: boolean };
if (count.suppressed) return "Suppressed";
if ("value" in count) return displayValue(count.value);
return JSON.stringify(value);
}
if (value === null || value === undefined || value === "") return "—";
if (field?.type === "datetime" || field?.type === "date") return formatDateTime(String(value));
if (typeof value === "number") return new Intl.NumberFormat().format(value);
if (typeof value === "boolean") return value ? "Yes" : "No";
return String(value);
}
function formatDateTime(value: string): string {
const date = new Date(value);
return Number.isNaN(date.valueOf()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function humanize(value: string): string {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function shortHash(value: string): string {
return `${value.slice(0, 10)}${value.slice(-6)}`;
}
function message(reason: unknown): string {
return reason instanceof Error ? reason.message : "The provider report could not be loaded.";
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
import { useCallback } from "react";
import { BarChart3 } from "lucide-react";
import { Link } from "react-router";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import { listDefinitions } from "../../api/reporting";
export default function ReportingReportsWidget({ settings, refreshKey, configuration }: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = boundedNumber(configuration.maxItems, 5, 1, 12);
const load = useCallback(async () => {
const result = await listDefinitions(settings, {
kinds: ["report"],
status: ["active"],
limit: maxItems
});
return result.definitions.slice(0, maxItems);
}, [maxItems, settings]);
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
return (
<LoadingFrame loading={loading} label="Loading reports">
{error && <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>}
<DashboardWidgetList
emptyText="No active reports are available."
items={(data ?? []).map((report) => ({
id: report.definition_id,
title: report.name,
detail: report.description || report.definition_key,
meta: `Revision ${report.revision}`,
leading: <BarChart3 size={17} aria-hidden="true" />,
trailing: <StatusBadge status={report.status} label={report.status} />,
to: "/reports"
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/reports">Open reporting</Link>
</div>
</LoadingFrame>
);
}
function boundedNumber(value: unknown, fallback: number, minimum: number, maximum: number): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric) ? Math.max(minimum, Math.min(maximum, Math.round(numeric))) : fallback;
}
+2
View File
@@ -0,0 +1,2 @@
export { default, reportingModule } from "./module";
export * from "./api/reporting";
+99
View File
@@ -0,0 +1,99 @@
import { createElement, lazy } from "react";
import type { DashboardWidgetsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import ReportingReportsWidget from "./features/reporting/ReportingReportsWidget";
import "./styles/reporting.css";
const ReportingPage = lazy(() => import("./features/reporting/ReportingPage"));
const reportingDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "reporting.reports",
surfaceId: "reporting.widget.reports",
title: "Reports",
description: "Active governed reports available in the current scope.",
moduleId: "reporting",
category: "Analysis",
order: 75,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: ["reporting:definition:read"],
refreshIntervalMs: 60_000,
defaultConfiguration: { maxItems: 5 },
configurationFields: [
{
id: "maxItems",
label: "Maximum reports",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
}
],
render: ({ settings, refreshKey, configuration }) => createElement(
ReportingReportsWidget,
{ settings, refreshKey, configuration }
)
}
]
};
export const reportingModule: PlatformWebModule = {
id: "reporting",
label: "Reporting",
version: "0.1.14",
optionalDependencies: [
"dataflow",
"datasources",
"connectors",
"dashboard",
"files",
"mail",
"templates",
"workflow_engine",
"policy",
"search",
"notifications"
],
routes: [
{
path: "/reports",
anyOf: ["reporting:definition:read"],
order: 74,
surfaceId: "reporting.workspace",
render: (context) => createElement(ReportingPage, context)
},
{
path: "/reporting",
anyOf: ["reporting:definition:read"],
order: 175,
surfaceId: "reporting.compatibility",
render: (context) => createElement(ReportingPage, context)
}
],
navItems: [
{
to: "/reports",
label: "Reporting",
iconName: "clipboard-pen-line",
anyOf: ["reporting:definition:read"],
order: 74,
surfaceId: "reporting.navigation"
}
],
viewSurfaces: [
{ id: "reporting.navigation", moduleId: "reporting", kind: "navigation", label: "Reporting navigation", order: 10 },
{ id: "reporting.workspace", moduleId: "reporting", kind: "route", label: "Reporting workspace", order: 20 },
{ id: "reporting.parameters", moduleId: "reporting", kind: "section", label: "Report parameters and filters", parentId: "reporting.workspace", order: 30 },
{ id: "reporting.results", moduleId: "reporting", kind: "section", label: "Authorized report results", parentId: "reporting.workspace", order: 40 },
{ id: "reporting.widget.reports", moduleId: "reporting", kind: "section", label: "Reports dashboard widget", order: 75 }
],
uiCapabilities: {
"dashboard.widgets": reportingDashboardWidgets
}
};
export default reportingModule;
+653
View File
@@ -0,0 +1,653 @@
.reporting-page,
.reporting-shell {
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 {
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--border);
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;
}
.reporting-count {
margin-left: auto;
color: var(--text-soft);
font-size: 0.8rem;
}
.reporting-workspace {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: minmax(250px, 20%) minmax(460px, 1fr) minmax(260px, 20%);
}
.reporting-catalogue,
.reporting-inspector {
min-height: 0;
padding: 10px;
background: var(--surface-subtle, var(--surface));
}
.reporting-catalogue {
border-right: 1px solid var(--border);
}
.reporting-inspector {
border-left: 1px solid var(--border);
}
.reporting-report-list,
.reporting-inspector-content section {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.reporting-list-heading {
padding: 7px 10px;
border-bottom: 1px solid var(--border);
background: var(--surface-subtle, var(--surface));
color: var(--text-soft);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
}
.reporting-report-row,
.reporting-inspector-content section > button {
display: grid;
align-items: center;
width: 100%;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
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);
font-size: 0.76rem;
}
.reporting-result-region {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.reporting-result-header {
min-height: 62px;
padding: 8px 14px;
}
.reporting-title {
min-width: 0;
}
.reporting-title span {
color: var(--text-soft);
font-size: 0.75rem;
}
.reporting-title h1 {
overflow: hidden;
margin: 2px 0 0;
font-size: 1.05rem;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.reporting-title p {
max-width: 70ch;
margin: 3px 0 0;
overflow: hidden;
color: var(--text-soft);
font-size: 0.76rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.reporting-run-actions {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.reporting-query-controls {
display: flex;
align-items: center;
gap: 14px;
min-height: 66px;
padding: 8px 14px;
overflow-x: auto;
border-bottom: 1px solid var(--border);
background: var(--surface-subtle, var(--surface));
}
.reporting-query-heading {
display: flex;
align-items: center;
gap: 7px;
}
.reporting-query-controls fieldset {
min-width: 170px;
margin: 0;
padding: 4px 8px 7px;
border: 1px solid var(--border);
border-radius: 6px;
}
.reporting-query-controls legend,
.reporting-parameter > span,
.reporting-dialog-field > span {
color: var(--text-soft);
font-size: 0.72rem;
}
.reporting-option-list {
display: flex;
gap: 9px;
max-width: 340px;
overflow-x: auto;
}
.reporting-option-list label {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.reporting-parameter,
.reporting-dialog-field {
display: flex;
min-width: 150px;
flex-direction: column;
gap: 4px;
}
.reporting-provider-controls {
align-items: end;
}
.reporting-provider-purpose {
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);
font-size: 0.78rem;
}
.reporting-output {
min-height: 0;
flex: 1;
overflow: auto;
padding: 10px;
}
.reporting-provider-output {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 10px;
overflow: auto;
padding: 10px;
}
.reporting-provider-metrics {
margin: 0;
padding: 0;
}
.reporting-output .data-grid-shell {
min-width: 100%;
}
.reporting-empty {
padding: 38px 12px;
color: var(--text-soft);
text-align: center;
}
.reporting-inspector-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.reporting-inspector-content section h2 {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
padding: 9px 10px;
border-bottom: 1px solid var(--border);
font-size: 0.82rem;
letter-spacing: 0;
}
.reporting-inspector-content section > p {
margin: 0;
padding: 12px 10px;
}
.reporting-inspector-content section > button {
grid-template-columns: minmax(0, 1fr) auto;
gap: 5px 8px;
padding: 8px 10px;
}
.reporting-inspector-content section > button small {
grid-column: 1 / -1;
}
.reporting-provenance {
padding-bottom: 8px;
}
.reporting-provenance dl {
display: grid;
grid-template-columns: minmax(90px, auto) minmax(0, 1fr);
gap: 6px 8px;
margin: 0;
padding: 10px;
font-size: 0.76rem;
}
.reporting-provenance dt {
color: var(--text-soft);
}
.reporting-provenance dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.reporting-provenance .alert {
margin: 8px;
}
.reporting-bar-chart {
display: flex;
min-width: 420px;
flex-direction: column;
gap: 8px;
padding: 10px;
}
.reporting-chart-stack,
.reporting-line-chart,
.reporting-metric-grid,
.reporting-pie-layout {
display: grid;
gap: 14px;
min-width: 0;
}
.reporting-column-chart {
display: flex;
align-items: end;
gap: 8px;
min-height: 280px;
padding: 18px 12px 0;
overflow-x: auto;
border-bottom: 1px solid var(--border);
}
.reporting-column {
display: grid;
grid-template-rows: 24px minmax(180px, 1fr) 32px;
align-items: end;
min-width: 54px;
flex: 1 0 54px;
gap: 4px;
text-align: center;
}
.reporting-column strong,
.reporting-column span {
overflow: hidden;
font-size: 0.72rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.reporting-column i {
display: block;
width: 72%;
margin: 0 auto;
border-radius: 3px 3px 0 0;
background: var(--accent);
}
.reporting-line-chart svg {
width: 100%;
height: 280px;
overflow: visible;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-raised);
}
.reporting-chart-line {
fill: none;
stroke: var(--accent);
stroke-width: 3;
vector-effect: non-scaling-stroke;
}
.reporting-chart-area {
fill: color-mix(in srgb, var(--accent) 28%, transparent);
}
.reporting-chart-labels {
display: flex;
justify-content: space-between;
gap: 8px;
overflow-x: auto;
color: var(--text-soft);
font-size: 0.7rem;
}
.reporting-metric-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.reporting-metric {
display: flex;
min-height: 92px;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.reporting-metric span {
color: var(--text-soft);
font-size: 0.76rem;
}
.reporting-metric strong {
font-size: 1.45rem;
}
.reporting-pie-layout {
grid-template-columns: minmax(180px, 300px) minmax(240px, 1fr);
align-items: center;
}
.reporting-pie {
width: min(100%, 280px);
aspect-ratio: 1;
margin: 0 auto;
border-radius: 50%;
box-shadow: inset 0 0 0 1px var(--border);
}
.reporting-pie.is-donut {
border: 58px solid var(--surface-raised);
}
.reporting-pie-layout ol {
display: grid;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
}
.reporting-pie-layout li {
display: grid;
grid-template-columns: 12px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.reporting-swatch {
width: 10px;
height: 10px;
border-radius: 2px;
background: #2f7d6e;
}
.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-bar-row {
display: grid;
grid-template-columns: minmax(100px, 22%) minmax(180px, 1fr) minmax(80px, auto);
align-items: center;
gap: 9px;
font-size: 0.8rem;
}
.reporting-bar-row > div {
height: 22px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-subtle, var(--surface));
}
.reporting-bar-row i {
display: block;
height: 100%;
background: var(--accent);
}
.reporting-chart-table {
grid-column: 1 / -1;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.reporting-inspector-toggle,
.reporting-inspector-record {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px 10px;
padding: 9px 10px;
border-bottom: 1px solid var(--border);
}
.reporting-inspector-toggle > span,
.reporting-inspector-record > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.reporting-inspector-toggle small,
.reporting-inspector-record small {
grid-column: 1 / -1;
}
.reporting-inspector-toggle .toggle-switch-copy {
display: none;
}
.reporting-access-explanation {
display: flex;
flex-direction: column;
gap: 4px;
margin: 8px;
padding: 9px 10px;
border-left: 3px solid var(--accent);
background: var(--hover-bg);
font-size: 0.76rem;
}
.reporting-dialog-span {
grid-column: 1 / -1;
}
.reporting-drill-dialog {
width: min(1100px, calc(100vw - 32px));
height: min(760px, calc(100vh - 32px));
}
.reporting-drill-dialog .dialog-body {
display: flex;
min-height: 0;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
.reporting-drill-path {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.reporting-drill-path span {
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-subtle, var(--surface));
font-size: 0.76rem;
}
.reporting-drill-grid {
min-height: 0;
flex: 1;
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);
}
.reporting-inspector {
display: none;
}
}
@media (max-width: 760px) {
.reporting-workspace {
grid-template-columns: 1fr;
}
.reporting-catalogue {
max-height: 30vh;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.reporting-result-header,
.reporting-toolbar {
flex-wrap: wrap;
}
.reporting-search {
width: 100%;
}
.reporting-dialog-grid {
grid-template-columns: 1fr;
}
}