Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8253bd0dd6 | ||
|
|
89755e1924 | ||
|
|
f2e42cecfe | ||
|
|
d7e7890291 | ||
|
|
f85e0fe9ad | ||
|
|
eb6742a393 | ||
|
|
e932a3d0f7 | ||
|
|
0a606184b1 | ||
|
|
63ee29e222 | ||
|
|
e13d95c843 | ||
|
|
79fa45d14a | ||
|
|
c2c400a43a | ||
|
|
f930a6cff0 | ||
|
|
323963c49d | ||
|
|
a6bde385ca | ||
|
|
101eafbcf6 | ||
|
|
fc8cf4b914 | ||
|
|
4c192c1a2f | ||
|
|
4e0238f701 | ||
|
|
aebe94ecc2 | ||
|
|
72e86fa87d | ||
|
|
8337aec19a | ||
|
|
a81151391b |
@@ -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
|
||||||
@@ -16,15 +16,18 @@ The module now provides an executable governed semantic-reporting vertical:
|
|||||||
grants, row-policy handoff, freshness checks, and reconstructable run
|
grants, row-policy handoff, freshness checks, and reconstructable run
|
||||||
provenance;
|
provenance;
|
||||||
- safe dimensions, hierarchies, measures, typed calculations, filters,
|
- safe dimensions, hierarchies, measures, typed calculations, filters,
|
||||||
detail/summary/pivot queries, and accessible chart models without executing
|
detail/summary/pivot queries, parameterized PostgreSQL semantic plans, and
|
||||||
arbitrary report SQL;
|
accessible chart models without executing arbitrary report SQL;
|
||||||
- quality gates, saved views, interval/scheduled runs, CSV/JSON export,
|
- quality gates, saved views, interval/scheduled runs, CSV/JSON export,
|
||||||
provider-neutral publication targets, and import activation assessments;
|
provider-neutral publication targets, and import activation assessments;
|
||||||
- a versioned, provider-neutral cross-module report contract with source-owned
|
- a versioned, provider-neutral cross-module report contract with source-owned
|
||||||
authorization, declared result schemas, privacy transforms, effective scope,
|
authorization, declared result schemas, privacy transforms, effective scope,
|
||||||
source revisions, purpose, retention, export history, and audit provenance;
|
source revisions, purpose, retention, export history, and audit provenance;
|
||||||
- a full-height Reporting workspace for running, inspecting, saving,
|
- a full-height Reporting workspace for running, inspecting, saving,
|
||||||
scheduling, visualizing, and exporting authorized reports.
|
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
|
Reporting consumes Dataflow outputs or provider-owned read models. It does not
|
||||||
read another module's ORM tables or take ownership of ingestion and
|
read another module's ORM tables or take ownership of ingestion and
|
||||||
@@ -34,6 +37,23 @@ The canonical global route is `/reports`. `/reporting` remains a
|
|||||||
Reporting-owned compatibility route for saved links. Domain modules may keep
|
Reporting-owned compatibility route for saved links. Domain modules may keep
|
||||||
their own operational report routes, but do not register `/reports`.
|
their own operational report routes, but do not register `/reports`.
|
||||||
|
|
||||||
|
## Data-subject requests
|
||||||
|
|
||||||
|
Reporting publishes `privacy.dsar.reporting` for private saved views,
|
||||||
|
short-lived drill contexts, subject access grants, minimized staff
|
||||||
|
attribution, and explicitly identified retained executions, exports, and
|
||||||
|
publications. DSAR output never copies report rows, parameters, filters,
|
||||||
|
delivery targets, source payloads, diagnostics, provenance bodies, or hashes.
|
||||||
|
The source module remains responsible for locating and correcting subject
|
||||||
|
facts; arbitrary aggregate report output is not searched as if Reporting were
|
||||||
|
the authoritative owner.
|
||||||
|
|
||||||
|
Private views and drill contexts can be removed, grants revoked, and exact
|
||||||
|
retained result or publication detail minimized idempotently. Shared views,
|
||||||
|
definitions, schedules, quality/import evidence, and institutional attribution
|
||||||
|
require authorized review or retention. Source facts must be corrected before
|
||||||
|
rerunning or republishing a report.
|
||||||
|
|
||||||
See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary
|
See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary
|
||||||
decision. The behavior-level comparison with the supplied SuperX module set is
|
decision. The behavior-level comparison with the supplied SuperX module set is
|
||||||
recorded in
|
recorded in
|
||||||
@@ -43,3 +63,25 @@ Operational and recovery behavior is documented in
|
|||||||
[docs/OPERATIONS.md](docs/OPERATIONS.md), while user and administrator tasks
|
[docs/OPERATIONS.md](docs/OPERATIONS.md), while user and administrator tasks
|
||||||
are covered by [docs/USER_GUIDE.md](docs/USER_GUIDE.md) and
|
are covered by [docs/USER_GUIDE.md](docs/USER_GUIDE.md) and
|
||||||
[docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
|
[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).
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/reporting-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/reporting-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
@@ -14,11 +14,31 @@ existing parent revision. Editing creates a new immutable revision and
|
|||||||
requires the currently observed revision number. Existing runs continue to
|
requires the currently observed revision number. Existing runs continue to
|
||||||
reference the historical revisions they used.
|
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
|
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
|
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,
|
unbounded SQL connection as a report source. Configure an explicit schema,
|
||||||
freshness policy, source fingerprint expectations, purpose, privacy,
|
freshness policy, source fingerprint expectations, purpose, privacy,
|
||||||
retention, and a row-policy provider where source access alone is not enough.
|
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
|
## Access and publication
|
||||||
|
|
||||||
@@ -32,6 +52,20 @@ the Reporting publication-target contract. The target receives one immutable
|
|||||||
execution payload and an idempotency key. It must return bounded evidence and
|
execution payload and an idempotency key. It must return bounded evidence and
|
||||||
must not expose credentials in that evidence.
|
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
|
## Cross-module provider governance
|
||||||
|
|
||||||
Source modules register `reporting.report_provider.<provider-id>` capabilities;
|
Source modules register `reporting.report_provider.<provider-id>` capabilities;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -83,6 +83,10 @@ Reporting does not own:
|
|||||||
outcomes.
|
outcomes.
|
||||||
- `reporting.chart_renderer` renders provider-neutral visual models with an
|
- `reporting.chart_renderer` renders provider-neutral visual models with an
|
||||||
accessible table fallback.
|
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.read_model:*` capabilities can expose bounded source-owned rows.
|
||||||
- `reporting.publication_target:*` capabilities can accept immutable result
|
- `reporting.publication_target:*` capabilities can accept immutable result
|
||||||
payloads without Reporting importing the target module.
|
payloads without Reporting importing the target module.
|
||||||
@@ -125,14 +129,22 @@ row-policy provenance, blocking quality plans, definition hashes, executor
|
|||||||
version, output hash, diagnostics, and authorized rows are retained with the
|
version, output hash, diagnostics, and authorized rows are retained with the
|
||||||
execution. Failed runs also retain evidence.
|
execution. Failed runs also retain evidence.
|
||||||
|
|
||||||
The query engine deliberately implements a typed expression and semantic
|
The query layer deliberately implements a typed expression and semantic
|
||||||
query language rather than `eval`, arbitrary SQL, stored procedures, or
|
query language rather than `eval`, arbitrary SQL, stored procedures, or
|
||||||
runtime scripts. It supports detail, grouped summary, pivot, dimensions,
|
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,
|
hierarchies, common aggregates, calculated measures, filters, sorting,
|
||||||
pagination, totals, and a provider-neutral visualization model. A saved chart
|
pagination, totals, and a provider-neutral visualization model. A saved chart
|
||||||
that is incompatible with an ad-hoc query degrades to its mandatory table
|
that is incompatible with an ad-hoc query degrades to its mandatory table
|
||||||
fallback instead of failing a valid report run.
|
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
|
Direct export supports UTF-8 CSV and JSON. CSV cells that spreadsheet software
|
||||||
could interpret as formulas are escaped. Additional formats and delivery
|
could interpret as formulas are escaped. Additional formats and delivery
|
||||||
destinations use an optional publication capability and preserve idempotent
|
destinations use an optional publication capability and preserve idempotent
|
||||||
@@ -142,7 +154,10 @@ unsupported executable behavior remains.
|
|||||||
|
|
||||||
The WebUI uses the platform module loader and common controls. It exposes a
|
The WebUI uses the platform module loader and common controls. It exposes a
|
||||||
report catalogue, parameter and semantic-query controls, result visualization
|
report catalogue, parameter and semantic-query controls, result visualization
|
||||||
and table views, history/provenance, saved views, schedules, and downloads.
|
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
|
The global `/reports` route is owned only by Reporting. `/reporting` is a
|
||||||
documented compatibility path. Campaign's module-local aggregate view remains
|
documented compatibility path. Campaign's module-local aggregate view remains
|
||||||
at `/campaigns/reports`; when both modules are enabled, the same safe aggregate
|
at `/campaigns/reports`; when both modules are enabled, the same safe aggregate
|
||||||
@@ -151,7 +166,8 @@ contract.
|
|||||||
|
|
||||||
## Remaining Product Depth
|
## Remaining Product Depth
|
||||||
|
|
||||||
The architecture boundary is implemented. Further work is additive product
|
The architecture boundary and first operational vertical are implemented.
|
||||||
depth: richer visualization providers, drill-through navigation, packaged
|
Further work is additive product depth: packaged domain report catalogues,
|
||||||
domain report catalogues, XLSX/PDF formatting through optional providers, and
|
XLSX/PDF formatting through optional renderer providers, selector-backed Mail
|
||||||
target-environment evidence for a maturity claim above `vertical_slice`.
|
profile configuration, external publication connectors, and target-environment
|
||||||
|
evidence for a maturity claim above `vertical_slice`.
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ 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
|
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.
|
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
|
## Inspect evidence
|
||||||
|
|
||||||
The right panel lists previous runs and definition/source pins. Select an
|
The right panel lists previous runs and definition/source pins. Select an
|
||||||
@@ -21,6 +28,18 @@ the exact definition and output. Warnings explain freshness, inferred schema,
|
|||||||
or provider diagnostics. A failed quality gate records a failed execution and
|
or provider diagnostics. A failed quality gate records a failed execution and
|
||||||
does not publish a result.
|
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. CSV export therefore provides an
|
||||||
|
Excel-readable publication of the exact authorized Dataflow result, with
|
||||||
|
spreadsheet formula markers escaped and the run lineage retained on the
|
||||||
|
Reporting execution.
|
||||||
|
|
||||||
|
The **Effective access** explanation states when dimensions, measures, source
|
||||||
|
rows, or actions were removed by Policy. A result with no hidden elements says
|
||||||
|
so explicitly; catalogue visibility never grants access to protected detail.
|
||||||
|
|
||||||
## Save and export
|
## Save and export
|
||||||
|
|
||||||
Use **Save current view** to keep the current query under your account. Saved
|
Use **Save current view** to keep the current query under your account. Saved
|
||||||
@@ -32,6 +51,17 @@ Users with scheduling permission can create an hourly, daily, weekly, or
|
|||||||
30-day interval from the current revision, parameters, and query. Scheduled
|
30-day interval from the current revision, parameters, and query. Scheduled
|
||||||
runs continue to use those exact pins until the schedule is edited.
|
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
|
## Run a module report
|
||||||
|
|
||||||
Module reports retain their source module's access rules. Select the source
|
Module reports retain their source module's access rules. Select the source
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/reporting-webui",
|
||||||
|
"version": "0.1.22",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/reporting.css": "./webui/src/styles/reporting.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
+3
-3
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-reporting"
|
name = "govoplan-reporting"
|
||||||
version = "0.1.14"
|
version = "0.1.22"
|
||||||
description = "GovOPlaN governed reporting and semantic BI module."
|
description = "GovOPlaN governed reporting and semantic BI module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { text = "AGPL-3.0-or-later" }
|
license = { text = "AGPL-3.0-or-later" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.14",
|
"govoplan-core>=0.1.46",
|
||||||
"govoplan-access>=0.1.14",
|
"govoplan-access>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN Reporting module."""
|
"""GovOPlaN Reporting module."""
|
||||||
|
|
||||||
__version__ = "0.1.14"
|
__version__ = "0.1.22"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ CAPABILITY_REPORTING_REGISTRY = "reporting.registry"
|
|||||||
CAPABILITY_REPORTING_RUNNER = "reporting.runner"
|
CAPABILITY_REPORTING_RUNNER = "reporting.runner"
|
||||||
CAPABILITY_REPORTING_SCHEDULER = "reporting.scheduler"
|
CAPABILITY_REPORTING_SCHEDULER = "reporting.scheduler"
|
||||||
CAPABILITY_REPORTING_CHART_RENDERER = "reporting.chart_renderer"
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -119,6 +121,8 @@ def capability(registry: object | None, name: str) -> object | None:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CAPABILITY_REPORTING_CHART_RENDERER",
|
"CAPABILITY_REPORTING_CHART_RENDERER",
|
||||||
|
"CAPABILITY_REPORTING_PUBLICATION_FILES",
|
||||||
|
"CAPABILITY_REPORTING_PUBLICATION_MAIL",
|
||||||
"CAPABILITY_REPORTING_REGISTRY",
|
"CAPABILITY_REPORTING_REGISTRY",
|
||||||
"CAPABILITY_REPORTING_RUNNER",
|
"CAPABILITY_REPORTING_RUNNER",
|
||||||
"CAPABILITY_REPORTING_SCHEDULER",
|
"CAPABILITY_REPORTING_SCHEDULER",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from govoplan_reporting.backend.db.models import (
|
|||||||
ReportingDefinitionGrant,
|
ReportingDefinitionGrant,
|
||||||
ReportingDefinitionIdentity,
|
ReportingDefinitionIdentity,
|
||||||
ReportingDefinitionRevision,
|
ReportingDefinitionRevision,
|
||||||
|
ReportingDrillContext,
|
||||||
ReportingExecution,
|
ReportingExecution,
|
||||||
ReportingImportAssessment,
|
ReportingImportAssessment,
|
||||||
ReportingPublication,
|
ReportingPublication,
|
||||||
@@ -16,6 +17,7 @@ __all__ = [
|
|||||||
"ReportingDefinitionGrant",
|
"ReportingDefinitionGrant",
|
||||||
"ReportingDefinitionIdentity",
|
"ReportingDefinitionIdentity",
|
||||||
"ReportingDefinitionRevision",
|
"ReportingDefinitionRevision",
|
||||||
|
"ReportingDrillContext",
|
||||||
"ReportingExecution",
|
"ReportingExecution",
|
||||||
"ReportingImportAssessment",
|
"ReportingImportAssessment",
|
||||||
"ReportingPublication",
|
"ReportingPublication",
|
||||||
|
|||||||
@@ -484,6 +484,50 @@ class ReportingPublication(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class ReportingQualityResult(Base, TimestampMixin):
|
||||||
__tablename__ = "reporting_quality_results"
|
__tablename__ = "reporting_quality_results"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -551,6 +595,7 @@ __all__ = [
|
|||||||
"ReportingDefinitionGrant",
|
"ReportingDefinitionGrant",
|
||||||
"ReportingDefinitionIdentity",
|
"ReportingDefinitionIdentity",
|
||||||
"ReportingDefinitionRevision",
|
"ReportingDefinitionRevision",
|
||||||
|
"ReportingDrillContext",
|
||||||
"ReportingExecution",
|
"ReportingExecution",
|
||||||
"ReportingImportAssessment",
|
"ReportingImportAssessment",
|
||||||
"ReportingPublication",
|
"ReportingPublication",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import uuid
|
|||||||
from sqlalchemy import and_, exists, func, or_
|
from sqlalchemy import and_, exists, func, or_
|
||||||
from sqlalchemy.orm import Query, Session
|
from sqlalchemy.orm import Query, Session
|
||||||
|
|
||||||
|
from govoplan_core.core.principal_helpers import principal_actor_ids as _actor_ids
|
||||||
from govoplan_core.core.events import (
|
from govoplan_core.core.events import (
|
||||||
EventActorRef,
|
EventActorRef,
|
||||||
EventObjectRef,
|
EventObjectRef,
|
||||||
@@ -27,6 +28,11 @@ from govoplan_reporting.backend.domain import (
|
|||||||
ReportingDefinitionRecord,
|
ReportingDefinitionRecord,
|
||||||
definition_from_row,
|
definition_from_row,
|
||||||
)
|
)
|
||||||
|
from govoplan_reporting.backend.governance import (
|
||||||
|
apply_parent_governance,
|
||||||
|
normalize_definition_governance,
|
||||||
|
scope_visible,
|
||||||
|
)
|
||||||
from govoplan_reporting.backend.schemas import validate_definition_payload
|
from govoplan_reporting.backend.schemas import validate_definition_payload
|
||||||
|
|
||||||
|
|
||||||
@@ -86,10 +92,20 @@ def create_definition(
|
|||||||
clean_reason = _required(change_reason, "Reporting change reason", 1_000)
|
clean_reason = _required(change_reason, "Reporting change reason", 1_000)
|
||||||
_aware(recorded_at, "Reporting recorded_at")
|
_aware(recorded_at, "Reporting recorded_at")
|
||||||
validated_payload = validate_definition_payload(kind, dict(payload))
|
validated_payload = validate_definition_payload(kind, dict(payload))
|
||||||
|
validated_payload = validate_definition_payload(
|
||||||
|
kind,
|
||||||
|
normalize_definition_governance(
|
||||||
|
validated_payload,
|
||||||
|
principal,
|
||||||
|
administrative=_has_scope(principal, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
)
|
||||||
parent_kind, parent_id, parent_revision = _parent_reference(
|
parent_kind, parent_id, parent_revision = _parent_reference(
|
||||||
kind,
|
kind,
|
||||||
validated_payload,
|
validated_payload,
|
||||||
)
|
)
|
||||||
|
validated_payload = validate_definition_payload(
|
||||||
|
kind,
|
||||||
_validate_parent(
|
_validate_parent(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -97,6 +113,8 @@ def create_definition(
|
|||||||
parent_kind=parent_kind,
|
parent_kind=parent_kind,
|
||||||
parent_id=parent_id,
|
parent_id=parent_id,
|
||||||
parent_revision=parent_revision,
|
parent_revision=parent_revision,
|
||||||
|
child_payload=validated_payload,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
request = {
|
request = {
|
||||||
"definition_kind": kind,
|
"definition_kind": kind,
|
||||||
@@ -241,7 +259,17 @@ def update_definition(
|
|||||||
kind,
|
kind,
|
||||||
dict(changes.get("payload", current.payload)),
|
dict(changes.get("payload", current.payload)),
|
||||||
)
|
)
|
||||||
|
next_payload = validate_definition_payload(
|
||||||
|
kind,
|
||||||
|
normalize_definition_governance(
|
||||||
|
next_payload,
|
||||||
|
principal,
|
||||||
|
administrative=_has_scope(principal, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
)
|
||||||
parent_kind, parent_id, parent_revision = _parent_reference(kind, next_payload)
|
parent_kind, parent_id, parent_revision = _parent_reference(kind, next_payload)
|
||||||
|
next_payload = validate_definition_payload(
|
||||||
|
kind,
|
||||||
_validate_parent(
|
_validate_parent(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -249,6 +277,8 @@ def update_definition(
|
|||||||
parent_kind=parent_kind,
|
parent_kind=parent_kind,
|
||||||
parent_id=parent_id,
|
parent_id=parent_id,
|
||||||
parent_revision=parent_revision,
|
parent_revision=parent_revision,
|
||||||
|
child_payload=next_payload,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
identity = _identity(session, tenant_id, kind, definition_id)
|
identity = _identity(session, tenant_id, kind, definition_id)
|
||||||
if identity is None:
|
if identity is None:
|
||||||
@@ -584,9 +614,10 @@ def _validate_parent(
|
|||||||
parent_kind: str | None,
|
parent_kind: str | None,
|
||||||
parent_id: str | None,
|
parent_id: str | None,
|
||||||
parent_revision: int | None,
|
parent_revision: int | None,
|
||||||
) -> None:
|
child_payload: Mapping[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
if parent_kind is None:
|
if parent_kind is None:
|
||||||
return
|
return dict(child_payload)
|
||||||
row = (
|
row = (
|
||||||
session.query(ReportingDefinitionRevision)
|
session.query(ReportingDefinitionRevision)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -605,6 +636,7 @@ def _validate_parent(
|
|||||||
raise ReportingDefinitionError(
|
raise ReportingDefinitionError(
|
||||||
f"An active Reporting definition requires an active {parent_kind} revision."
|
f"An active Reporting definition requires an active {parent_kind} revision."
|
||||||
)
|
)
|
||||||
|
return apply_parent_governance(child_payload, row.payload)
|
||||||
|
|
||||||
|
|
||||||
def _parent_reference(
|
def _parent_reference(
|
||||||
@@ -667,6 +699,8 @@ def _can_access(
|
|||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
return False
|
return False
|
||||||
|
if not scope_visible(row.payload, principal):
|
||||||
|
return False
|
||||||
if _has_scope(principal, ADMIN_SCOPE):
|
if _has_scope(principal, ADMIN_SCOPE):
|
||||||
return True
|
return True
|
||||||
identity = _identity(session, tenant_id, definition_kind, definition_id)
|
identity = _identity(session, tenant_id, definition_kind, definition_id)
|
||||||
@@ -712,6 +746,24 @@ def _require_scope(principal: object, scope: str) -> None:
|
|||||||
def _filter_accessible(query: Query, principal: object) -> Query:
|
def _filter_accessible(query: Query, principal: object) -> Query:
|
||||||
if _has_scope(principal, ADMIN_SCOPE):
|
if _has_scope(principal, ADMIN_SCOPE):
|
||||||
return query
|
return query
|
||||||
|
governance = ReportingDefinitionRevision.payload["governance"]
|
||||||
|
scope_type = governance["scope_type"].as_string()
|
||||||
|
scope_id = governance["scope_id"].as_string()
|
||||||
|
inherited = governance["inherit_to_lower_scopes"].as_boolean()
|
||||||
|
scope_conditions = [
|
||||||
|
scope_type.is_(None),
|
||||||
|
and_(
|
||||||
|
scope_type == "tenant",
|
||||||
|
or_(scope_id.is_(None), scope_id == _principal_tenant(principal)),
|
||||||
|
),
|
||||||
|
and_(scope_type == "system", inherited.is_(True)),
|
||||||
|
]
|
||||||
|
group_ids = tuple(_string_subject_ids(principal, "group_ids"))
|
||||||
|
if group_ids:
|
||||||
|
scope_conditions.append(and_(scope_type == "group", scope_id.in_(group_ids)))
|
||||||
|
user_ids = _actor_ids(principal)
|
||||||
|
if user_ids:
|
||||||
|
scope_conditions.append(and_(scope_type == "user", scope_id.in_(user_ids)))
|
||||||
conditions = [ReportingDefinitionRevision.visibility == "tenant"]
|
conditions = [ReportingDefinitionRevision.visibility == "tenant"]
|
||||||
actor_ids = _actor_ids(principal)
|
actor_ids = _actor_ids(principal)
|
||||||
if actor_ids:
|
if actor_ids:
|
||||||
@@ -752,7 +804,14 @@ def _filter_accessible(query: Query, principal: object) -> Query:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return query.filter(or_(*conditions))
|
return query.filter(and_(or_(*scope_conditions), or_(*conditions)))
|
||||||
|
|
||||||
|
|
||||||
|
def _string_subject_ids(principal: object, attribute: str) -> tuple[str, ...]:
|
||||||
|
raw = getattr(principal, attribute, ()) or ()
|
||||||
|
if isinstance(raw, (str, bytes)):
|
||||||
|
return (str(raw),) if raw else ()
|
||||||
|
return tuple(dict.fromkeys(str(value) for value in raw if str(value or "").strip()))
|
||||||
|
|
||||||
|
|
||||||
def _current_row(
|
def _current_row(
|
||||||
@@ -826,22 +885,6 @@ def _actor(principal: object) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _actor_ids(principal: object) -> tuple[str, ...]:
|
|
||||||
user = getattr(principal, "user", None)
|
|
||||||
return tuple(
|
|
||||||
dict.fromkeys(
|
|
||||||
str(value)
|
|
||||||
for value in (
|
|
||||||
getattr(principal, "account_id", None),
|
|
||||||
getattr(principal, "identity_id", None),
|
|
||||||
getattr(principal, "membership_id", None),
|
|
||||||
getattr(user, "id", None),
|
|
||||||
)
|
|
||||||
if str(value or "").strip()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _principal_tenant(principal: object) -> str:
|
def _principal_tenant(principal: object) -> str:
|
||||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||||
if not tenant_id:
|
if not tenant_id:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,951 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_reporting.backend.db.models import (
|
||||||
|
ReportingDefinitionGrant,
|
||||||
|
ReportingDefinitionIdentity,
|
||||||
|
ReportingDefinitionRevision,
|
||||||
|
ReportingDrillContext,
|
||||||
|
ReportingExecution,
|
||||||
|
ReportingImportAssessment,
|
||||||
|
ReportingProviderExecution,
|
||||||
|
ReportingProviderExport,
|
||||||
|
ReportingPublication,
|
||||||
|
ReportingQualityResult,
|
||||||
|
ReportingSavedView,
|
||||||
|
ReportingSchedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
REPORTING_DSAR_CAPABILITY = dsar_capability_name("reporting")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
_CONFLICT = object()
|
||||||
|
_DIRECT_ALIASES = {
|
||||||
|
"definition_id": ("reporting.definition",),
|
||||||
|
"revision_id": (
|
||||||
|
"reporting.definition_revision",
|
||||||
|
"reporting.revision",
|
||||||
|
),
|
||||||
|
"execution_id": ("reporting.execution",),
|
||||||
|
"provider_execution_id": ("reporting.provider_execution",),
|
||||||
|
"provider_export_id": ("reporting.provider_export",),
|
||||||
|
"grant_id": ("reporting.definition_grant", "reporting.grant"),
|
||||||
|
"saved_view_id": ("reporting.saved_view",),
|
||||||
|
"schedule_id": ("reporting.schedule",),
|
||||||
|
"publication_id": ("reporting.publication",),
|
||||||
|
"drill_context_id": ("reporting.drill_context",),
|
||||||
|
"quality_result_id": ("reporting.quality_result",),
|
||||||
|
"import_assessment_id": ("reporting.import_assessment",),
|
||||||
|
}
|
||||||
|
_RESOURCE_MODELS = {
|
||||||
|
"reporting_definition": ReportingDefinitionIdentity,
|
||||||
|
"reporting_definition_revision": ReportingDefinitionRevision,
|
||||||
|
"reporting_execution": ReportingExecution,
|
||||||
|
"reporting_provider_execution": ReportingProviderExecution,
|
||||||
|
"reporting_provider_export": ReportingProviderExport,
|
||||||
|
"reporting_definition_grant": ReportingDefinitionGrant,
|
||||||
|
"reporting_saved_view": ReportingSavedView,
|
||||||
|
"reporting_schedule": ReportingSchedule,
|
||||||
|
"reporting_publication": ReportingPublication,
|
||||||
|
"reporting_drill_context": ReportingDrillContext,
|
||||||
|
"reporting_quality_result": ReportingQualityResult,
|
||||||
|
"reporting_import_assessment": ReportingImportAssessment,
|
||||||
|
}
|
||||||
|
_EXECUTABLE_KINDS = {
|
||||||
|
"reporting_execution": "anonymize",
|
||||||
|
"reporting_provider_execution": "anonymize",
|
||||||
|
"reporting_provider_export": "anonymize",
|
||||||
|
"reporting_publication": "anonymize",
|
||||||
|
"reporting_definition_grant": "revoke",
|
||||||
|
"reporting_saved_view": "delete",
|
||||||
|
"reporting_drill_context": "delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Selectors:
|
||||||
|
account_id: str | None
|
||||||
|
identity_id: str | None
|
||||||
|
membership_id: str | None
|
||||||
|
direct: dict[str, str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actor_ids(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
value
|
||||||
|
for value in (self.account_id, self.identity_id, self.membership_id)
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Match:
|
||||||
|
resource_type: str
|
||||||
|
row: Any
|
||||||
|
category: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingDsarProvider:
|
||||||
|
provider_id = "reporting"
|
||||||
|
module_id = "reporting"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _selectors(subject)
|
||||||
|
if selectors is None or not (selectors.actor_ids or selectors.direct):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
direct = _direct_matches(db, tenant_id=tenant_id, selectors=selectors)
|
||||||
|
if direct is None:
|
||||||
|
return ()
|
||||||
|
if direct:
|
||||||
|
if selectors.actor_ids and not all(
|
||||||
|
_correlates(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
match=match,
|
||||||
|
actor_ids=selectors.actor_ids,
|
||||||
|
)
|
||||||
|
for match in direct
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
matches = direct
|
||||||
|
else:
|
||||||
|
matches = _canonical_matches(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
actor_ids=selectors.actor_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for match in matches:
|
||||||
|
key = (match.resource_type, str(match.row.id))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
if len(records) >= _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Reporting DSAR result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
seen.add(key)
|
||||||
|
records.append(_record(match))
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _selectors(subject) is None:
|
||||||
|
raise ValueError("Reporting DSAR subject selectors conflict.")
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
kind = _planned_kind(record)
|
||||||
|
executable = kind in {"delete", "anonymize", "revoke"}
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=(
|
||||||
|
f"reporting:{kind}:{record.resource_type}:{record.resource_id}"
|
||||||
|
),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind=kind,
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=(
|
||||||
|
f"Minimize {record.title}"
|
||||||
|
if kind == "anonymize"
|
||||||
|
else f"{kind.replace('_', ' ').title()} {record.title}"
|
||||||
|
),
|
||||||
|
rationale=_rationale(record, kind=kind),
|
||||||
|
executable=executable,
|
||||||
|
irreversible=kind in {"delete", "anonymize"},
|
||||||
|
metadata={"record_category": record.category},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
raise ValueError("Reporting DSAR subject selectors conflict.")
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if not action.executable:
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Review institutional reporting evidence, shared "
|
||||||
|
"configuration, retention, and third-party impact."
|
||||||
|
),
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
model = _RESOURCE_MODELS[action.resource_type]
|
||||||
|
row = (
|
||||||
|
db.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, model.id == action.resource_id)
|
||||||
|
.with_for_update()
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
status = "unchanged"
|
||||||
|
summary = "Reporting row was already absent or minimized."
|
||||||
|
else:
|
||||||
|
match = _Match(action.resource_type, row, "execution")
|
||||||
|
if not (
|
||||||
|
_directly_targets(selectors, match)
|
||||||
|
or _correlates(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
match=match,
|
||||||
|
actor_ids=selectors.actor_ids,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Reporting DSAR action is not corroborated by the subject."
|
||||||
|
)
|
||||||
|
status, summary = _execute_action(
|
||||||
|
db,
|
||||||
|
row=row,
|
||||||
|
resource_type=action.resource_type,
|
||||||
|
kind=action.kind,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status=status,
|
||||||
|
summary=summary,
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_matches(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _Selectors,
|
||||||
|
) -> list[_Match] | None:
|
||||||
|
matches: list[_Match] = []
|
||||||
|
for selector, value in selectors.direct.items():
|
||||||
|
current: list[_Match]
|
||||||
|
if selector == "definition_id":
|
||||||
|
identities = _rows(
|
||||||
|
session,
|
||||||
|
ReportingDefinitionIdentity,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
field="definition_id",
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
revisions = _rows(
|
||||||
|
session,
|
||||||
|
ReportingDefinitionRevision,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
field="definition_id",
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
current = [
|
||||||
|
*(
|
||||||
|
_Match("reporting_definition", row, "reporting_configuration")
|
||||||
|
for row in identities
|
||||||
|
),
|
||||||
|
*(
|
||||||
|
_Match(
|
||||||
|
"reporting_definition_revision",
|
||||||
|
row,
|
||||||
|
"reporting_configuration",
|
||||||
|
)
|
||||||
|
for row in revisions
|
||||||
|
),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
model, field, resource_type = {
|
||||||
|
"revision_id": (
|
||||||
|
ReportingDefinitionRevision,
|
||||||
|
"id",
|
||||||
|
"reporting_definition_revision",
|
||||||
|
),
|
||||||
|
"execution_id": (
|
||||||
|
ReportingExecution,
|
||||||
|
"execution_id",
|
||||||
|
"reporting_execution",
|
||||||
|
),
|
||||||
|
"provider_execution_id": (
|
||||||
|
ReportingProviderExecution,
|
||||||
|
"execution_id",
|
||||||
|
"reporting_provider_execution",
|
||||||
|
),
|
||||||
|
"provider_export_id": (
|
||||||
|
ReportingProviderExport,
|
||||||
|
"export_id",
|
||||||
|
"reporting_provider_export",
|
||||||
|
),
|
||||||
|
"grant_id": (
|
||||||
|
ReportingDefinitionGrant,
|
||||||
|
"id",
|
||||||
|
"reporting_definition_grant",
|
||||||
|
),
|
||||||
|
"saved_view_id": (
|
||||||
|
ReportingSavedView,
|
||||||
|
"view_id",
|
||||||
|
"reporting_saved_view",
|
||||||
|
),
|
||||||
|
"schedule_id": (
|
||||||
|
ReportingSchedule,
|
||||||
|
"schedule_id",
|
||||||
|
"reporting_schedule",
|
||||||
|
),
|
||||||
|
"publication_id": (
|
||||||
|
ReportingPublication,
|
||||||
|
"publication_id",
|
||||||
|
"reporting_publication",
|
||||||
|
),
|
||||||
|
"drill_context_id": (
|
||||||
|
ReportingDrillContext,
|
||||||
|
"drill_context_id",
|
||||||
|
"reporting_drill_context",
|
||||||
|
),
|
||||||
|
"quality_result_id": (
|
||||||
|
ReportingQualityResult,
|
||||||
|
"result_id",
|
||||||
|
"reporting_quality_result",
|
||||||
|
),
|
||||||
|
"import_assessment_id": (
|
||||||
|
ReportingImportAssessment,
|
||||||
|
"assessment_id",
|
||||||
|
"reporting_import_assessment",
|
||||||
|
),
|
||||||
|
}[selector]
|
||||||
|
current = [
|
||||||
|
_Match(resource_type, row, _direct_category(resource_type, row))
|
||||||
|
for row in _rows(
|
||||||
|
session,
|
||||||
|
model,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
field=field,
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not current:
|
||||||
|
return None
|
||||||
|
matches.extend(current)
|
||||||
|
if len(matches) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Reporting DSAR result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_matches(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
actor_ids: tuple[str, ...],
|
||||||
|
) -> list[_Match]:
|
||||||
|
if not actor_ids:
|
||||||
|
return []
|
||||||
|
specs = (
|
||||||
|
(
|
||||||
|
ReportingDefinitionIdentity,
|
||||||
|
"created_by",
|
||||||
|
"reporting_definition",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingDefinitionRevision,
|
||||||
|
"changed_by",
|
||||||
|
"reporting_definition_revision",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingExecution,
|
||||||
|
"actor_id",
|
||||||
|
"reporting_execution",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingProviderExecution,
|
||||||
|
"actor_id",
|
||||||
|
"reporting_provider_execution",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingProviderExport,
|
||||||
|
"actor_id",
|
||||||
|
"reporting_provider_export",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingSavedView,
|
||||||
|
"owner_id",
|
||||||
|
"reporting_saved_view",
|
||||||
|
"subject_owned_reporting_view",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingSchedule,
|
||||||
|
"created_by",
|
||||||
|
"reporting_schedule",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingDrillContext,
|
||||||
|
"actor_id",
|
||||||
|
"reporting_drill_context",
|
||||||
|
"subject_owned_drill_context",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingQualityResult,
|
||||||
|
"actor_id",
|
||||||
|
"reporting_quality_result",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingImportAssessment,
|
||||||
|
"assessed_by",
|
||||||
|
"reporting_import_assessment",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingDefinitionGrant,
|
||||||
|
"subject_id",
|
||||||
|
"reporting_definition_grant",
|
||||||
|
"subject_access_grant",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
matches: list[_Match] = []
|
||||||
|
for model, field, resource_type, category in specs:
|
||||||
|
rows = (
|
||||||
|
session.query(model)
|
||||||
|
.filter(
|
||||||
|
model.tenant_id == tenant_id,
|
||||||
|
getattr(model, field).in_(actor_ids),
|
||||||
|
)
|
||||||
|
.order_by(model.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
matches.extend(
|
||||||
|
_Match(
|
||||||
|
resource_type,
|
||||||
|
row,
|
||||||
|
_direct_category(resource_type, row)
|
||||||
|
if resource_type == "reporting_saved_view"
|
||||||
|
else category,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
if len(matches) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Reporting DSAR result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(
|
||||||
|
session: Session,
|
||||||
|
model: Any,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
field: str,
|
||||||
|
value: str,
|
||||||
|
) -> list[Any]:
|
||||||
|
return (
|
||||||
|
session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, getattr(model, field) == value)
|
||||||
|
.order_by(model.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _correlates(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
match: _Match,
|
||||||
|
actor_ids: tuple[str, ...],
|
||||||
|
) -> bool:
|
||||||
|
if not actor_ids:
|
||||||
|
return False
|
||||||
|
row = match.row
|
||||||
|
field = {
|
||||||
|
"reporting_definition": "created_by",
|
||||||
|
"reporting_definition_revision": "changed_by",
|
||||||
|
"reporting_execution": "actor_id",
|
||||||
|
"reporting_provider_execution": "actor_id",
|
||||||
|
"reporting_provider_export": "actor_id",
|
||||||
|
"reporting_definition_grant": "subject_id",
|
||||||
|
"reporting_saved_view": "owner_id",
|
||||||
|
"reporting_schedule": "created_by",
|
||||||
|
"reporting_drill_context": "actor_id",
|
||||||
|
"reporting_quality_result": "actor_id",
|
||||||
|
"reporting_import_assessment": "assessed_by",
|
||||||
|
}.get(match.resource_type)
|
||||||
|
if field and str(getattr(row, field, "") or "") in actor_ids:
|
||||||
|
return True
|
||||||
|
if match.resource_type == "reporting_definition_revision":
|
||||||
|
identity = session.get(ReportingDefinitionIdentity, row.identity_id)
|
||||||
|
return bool(
|
||||||
|
identity
|
||||||
|
and identity.tenant_id == tenant_id
|
||||||
|
and identity.created_by in actor_ids
|
||||||
|
)
|
||||||
|
if match.resource_type == "reporting_provider_export":
|
||||||
|
execution = session.get(
|
||||||
|
ReportingProviderExecution,
|
||||||
|
row.provider_execution_id,
|
||||||
|
)
|
||||||
|
return bool(
|
||||||
|
execution
|
||||||
|
and execution.tenant_id == tenant_id
|
||||||
|
and execution.actor_id in actor_ids
|
||||||
|
)
|
||||||
|
if match.resource_type == "reporting_publication":
|
||||||
|
execution = (
|
||||||
|
session.query(ReportingExecution)
|
||||||
|
.filter(
|
||||||
|
ReportingExecution.tenant_id == tenant_id,
|
||||||
|
ReportingExecution.execution_id == row.execution_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
return bool(execution and execution.actor_id in actor_ids)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _directly_targets(selectors: _Selectors, match: _Match) -> bool:
|
||||||
|
row = match.row
|
||||||
|
selector, field = {
|
||||||
|
"reporting_definition": ("definition_id", "definition_id"),
|
||||||
|
"reporting_definition_revision": ("revision_id", "id"),
|
||||||
|
"reporting_execution": ("execution_id", "execution_id"),
|
||||||
|
"reporting_provider_execution": (
|
||||||
|
"provider_execution_id",
|
||||||
|
"execution_id",
|
||||||
|
),
|
||||||
|
"reporting_provider_export": ("provider_export_id", "export_id"),
|
||||||
|
"reporting_definition_grant": ("grant_id", "id"),
|
||||||
|
"reporting_saved_view": ("saved_view_id", "view_id"),
|
||||||
|
"reporting_schedule": ("schedule_id", "schedule_id"),
|
||||||
|
"reporting_publication": ("publication_id", "publication_id"),
|
||||||
|
"reporting_drill_context": ("drill_context_id", "drill_context_id"),
|
||||||
|
"reporting_quality_result": ("quality_result_id", "result_id"),
|
||||||
|
"reporting_import_assessment": (
|
||||||
|
"import_assessment_id",
|
||||||
|
"assessment_id",
|
||||||
|
),
|
||||||
|
}[match.resource_type]
|
||||||
|
value = getattr(row, field)
|
||||||
|
if selectors.direct.get(selector) == str(value):
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
match.resource_type == "reporting_definition_revision"
|
||||||
|
and selectors.direct.get("definition_id") == row.definition_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_category(resource_type: str, row: Any) -> str:
|
||||||
|
if resource_type == "reporting_saved_view":
|
||||||
|
return "shared_reporting_view" if row.shared else "subject_owned_reporting_view"
|
||||||
|
return {
|
||||||
|
"reporting_execution": "derived_report_result",
|
||||||
|
"reporting_provider_execution": "derived_provider_report_result",
|
||||||
|
"reporting_provider_export": "derived_provider_report_export",
|
||||||
|
"reporting_publication": "derived_report_publication",
|
||||||
|
"reporting_definition_grant": "subject_access_grant",
|
||||||
|
"reporting_drill_context": "subject_owned_drill_context",
|
||||||
|
}.get(resource_type, "reporting_configuration")
|
||||||
|
|
||||||
|
|
||||||
|
def _record(match: _Match) -> DsarRecordRef:
|
||||||
|
row = match.row
|
||||||
|
data = _record_data(match.resource_type, row)
|
||||||
|
immutable = match.category == "reporting_operator_attribution"
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="reporting",
|
||||||
|
module_id="reporting",
|
||||||
|
resource_type=match.resource_type,
|
||||||
|
resource_id=str(row.id),
|
||||||
|
category=match.category,
|
||||||
|
title=_title(match.resource_type),
|
||||||
|
data={key: value for key, value in data.items() if value is not None},
|
||||||
|
observed_at=_observed_at(row),
|
||||||
|
immutable_evidence=immutable,
|
||||||
|
retention_reason=(
|
||||||
|
"Institutional reporting activity remains attributable for "
|
||||||
|
"governance and audit review."
|
||||||
|
if immutable
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
source_path="/reports",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_data(resource_type: str, row: Any) -> dict[str, object]:
|
||||||
|
if resource_type == "reporting_definition":
|
||||||
|
return {
|
||||||
|
"definition_kind": row.definition_kind,
|
||||||
|
"definition_id": row.definition_id,
|
||||||
|
"definition_key": row.definition_key,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_definition_revision":
|
||||||
|
return {
|
||||||
|
"definition_kind": row.definition_kind,
|
||||||
|
"definition_id": row.definition_id,
|
||||||
|
"revision": row.revision,
|
||||||
|
"status": row.status,
|
||||||
|
"visibility": row.visibility,
|
||||||
|
"recorded_at": _iso(row.recorded_at),
|
||||||
|
"superseded_at": _iso(row.superseded_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_execution":
|
||||||
|
return {
|
||||||
|
"execution_id": row.execution_id,
|
||||||
|
"report_id": row.report_id,
|
||||||
|
"report_revision": row.report_revision,
|
||||||
|
"semantic_model_id": row.semantic_model_id,
|
||||||
|
"semantic_model_revision": row.semantic_model_revision,
|
||||||
|
"dataset_id": row.dataset_id,
|
||||||
|
"dataset_revision": row.dataset_revision,
|
||||||
|
"status": row.status,
|
||||||
|
"total_rows": row.total_rows,
|
||||||
|
"truncated": row.truncated,
|
||||||
|
"has_retained_result": bool(row.result_rows),
|
||||||
|
"started_at": _iso(row.started_at),
|
||||||
|
"finished_at": _iso(row.finished_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_provider_execution":
|
||||||
|
return {
|
||||||
|
"execution_id": row.execution_id,
|
||||||
|
"provider_id": row.provider_id,
|
||||||
|
"report_id": row.report_id,
|
||||||
|
"report_revision": row.report_revision,
|
||||||
|
"contract_version": row.contract_version,
|
||||||
|
"privacy_transforms": list(row.privacy_transforms or []),
|
||||||
|
"retention_class": row.retention_class,
|
||||||
|
"retention_days": row.retention_days,
|
||||||
|
"expires_at": _iso(row.expires_at),
|
||||||
|
"retention_redacted_at": _iso(row.retention_redacted_at),
|
||||||
|
"has_retained_result": bool(row.result_payload),
|
||||||
|
"generated_at": _iso(row.generated_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_provider_export":
|
||||||
|
return {
|
||||||
|
"export_id": row.export_id,
|
||||||
|
"execution_id": row.execution_id,
|
||||||
|
"format": row.format,
|
||||||
|
"exported_at": _iso(row.exported_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_definition_grant":
|
||||||
|
return {
|
||||||
|
"definition_kind": row.definition_kind,
|
||||||
|
"definition_id": row.definition_id,
|
||||||
|
"subject_kind": row.subject_kind,
|
||||||
|
"permissions": list(row.permissions or []),
|
||||||
|
"active": row.active,
|
||||||
|
"source_revision": row.source_revision,
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_saved_view":
|
||||||
|
return {
|
||||||
|
"view_id": row.view_id,
|
||||||
|
"report_id": row.report_id,
|
||||||
|
"report_revision": row.report_revision,
|
||||||
|
"owner_kind": row.owner_kind,
|
||||||
|
"revision": row.revision,
|
||||||
|
"shared": row.shared,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
"updated_at": _iso(row.updated_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_schedule":
|
||||||
|
return {
|
||||||
|
"schedule_id": row.schedule_id,
|
||||||
|
"report_id": row.report_id,
|
||||||
|
"report_revision": row.report_revision,
|
||||||
|
"revision": row.revision,
|
||||||
|
"trigger_kind": row.trigger_kind,
|
||||||
|
"enabled": row.enabled,
|
||||||
|
"next_run_at": _iso(row.next_run_at),
|
||||||
|
"last_run_at": _iso(row.last_run_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_publication":
|
||||||
|
return {
|
||||||
|
"publication_id": row.publication_id,
|
||||||
|
"execution_id": row.execution_id,
|
||||||
|
"target_capability": row.target_capability,
|
||||||
|
"format": row.format,
|
||||||
|
"status": row.status,
|
||||||
|
"completed_at": _iso(row.completed_at),
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_drill_context":
|
||||||
|
return {
|
||||||
|
"drill_context_id": row.drill_context_id,
|
||||||
|
"execution_id": row.execution_id,
|
||||||
|
"expires_at": _iso(row.expires_at),
|
||||||
|
"last_accessed_at": _iso(row.last_accessed_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_quality_result":
|
||||||
|
return {
|
||||||
|
"result_id": row.result_id,
|
||||||
|
"quality_plan_id": row.quality_plan_id,
|
||||||
|
"quality_plan_revision": row.quality_plan_revision,
|
||||||
|
"dataset_id": row.dataset_id,
|
||||||
|
"dataset_revision": row.dataset_revision,
|
||||||
|
"status": row.status,
|
||||||
|
"evaluated_at": _iso(row.evaluated_at),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"assessment_id": row.assessment_id,
|
||||||
|
"source_system": row.source_system,
|
||||||
|
"status": row.status,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_kind(record: DsarRecordRef) -> str:
|
||||||
|
if record.category == "reporting_operator_attribution":
|
||||||
|
return "retain"
|
||||||
|
if record.category in {"reporting_configuration", "shared_reporting_view"}:
|
||||||
|
return "manual_review"
|
||||||
|
return _EXECUTABLE_KINDS.get(record.resource_type, "manual_review")
|
||||||
|
|
||||||
|
|
||||||
|
def _rationale(record: DsarRecordRef, *, kind: str) -> str:
|
||||||
|
if kind == "delete":
|
||||||
|
return "Remove subject-owned, non-authoritative Reporting workspace state."
|
||||||
|
if kind == "anonymize":
|
||||||
|
return (
|
||||||
|
"Clear retained result or delivery detail while preserving hashes and "
|
||||||
|
"minimal institutional execution evidence."
|
||||||
|
)
|
||||||
|
if kind == "revoke":
|
||||||
|
return "Disable the subject-specific Reporting access relationship."
|
||||||
|
if kind == "retain":
|
||||||
|
return record.retention_reason or "Retain institutional attribution evidence."
|
||||||
|
return (
|
||||||
|
"An authorized Reporting owner must review shared configuration, "
|
||||||
|
"dependencies, legal retention, and third-party impact."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_action(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
row: Any,
|
||||||
|
resource_type: str,
|
||||||
|
kind: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
expected = _EXECUTABLE_KINDS.get(resource_type)
|
||||||
|
if expected != kind:
|
||||||
|
raise ValueError("Reporting DSAR executable action is not supported.")
|
||||||
|
if resource_type == "reporting_saved_view":
|
||||||
|
if row.shared:
|
||||||
|
raise ValueError("Shared Reporting views require manual review.")
|
||||||
|
session.delete(row)
|
||||||
|
session.flush()
|
||||||
|
return "executed", "Subject-owned Reporting view removed."
|
||||||
|
if resource_type == "reporting_drill_context":
|
||||||
|
session.delete(row)
|
||||||
|
session.flush()
|
||||||
|
return "executed", "Ephemeral Reporting drill context removed."
|
||||||
|
if resource_type == "reporting_definition_grant":
|
||||||
|
if not row.active:
|
||||||
|
return "unchanged", "Reporting access grant was already inactive."
|
||||||
|
row.active = False
|
||||||
|
session.flush()
|
||||||
|
return "executed", "Subject-specific Reporting access grant revoked."
|
||||||
|
if resource_type == "reporting_execution":
|
||||||
|
fields = {
|
||||||
|
"parameters": {},
|
||||||
|
"query": {},
|
||||||
|
"source_fingerprints": [],
|
||||||
|
"result_rows": [],
|
||||||
|
"diagnostics": [],
|
||||||
|
"provenance": {},
|
||||||
|
}
|
||||||
|
changed = _replace_fields(row, fields)
|
||||||
|
elif resource_type == "reporting_provider_execution":
|
||||||
|
fields = {
|
||||||
|
"purpose": "Redacted by data-subject request.",
|
||||||
|
"audience_scope": {},
|
||||||
|
"parameters": {},
|
||||||
|
"result_payload": {},
|
||||||
|
"source_revisions": [],
|
||||||
|
"effective_scope": {},
|
||||||
|
"provenance": {},
|
||||||
|
"governance_provenance": {},
|
||||||
|
}
|
||||||
|
changed = _replace_fields(row, fields)
|
||||||
|
if row.retention_redacted_at is None:
|
||||||
|
row.retention_redacted_at = datetime.now(timezone.utc)
|
||||||
|
changed = True
|
||||||
|
elif resource_type == "reporting_provider_export":
|
||||||
|
changed = _replace_fields(
|
||||||
|
row,
|
||||||
|
{
|
||||||
|
"purpose": "Redacted by data-subject request.",
|
||||||
|
"audience_scope": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
changed = _replace_fields(
|
||||||
|
row,
|
||||||
|
{"target_ref": None, "evidence": {}, "error": None},
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
session.flush()
|
||||||
|
return "executed", "Retained Reporting detail minimized; hashes remain."
|
||||||
|
return "unchanged", "Retained Reporting detail was already minimized."
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_fields(row: Any, values: dict[str, object]) -> bool:
|
||||||
|
changed = False
|
||||||
|
for field, value in values.items():
|
||||||
|
if getattr(row, field) != value:
|
||||||
|
setattr(row, field, value)
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
|
||||||
|
references = subject.external_references
|
||||||
|
account_id = _coalesce(
|
||||||
|
subject.account_id,
|
||||||
|
references.get("reporting.account"),
|
||||||
|
references.get("access.account"),
|
||||||
|
)
|
||||||
|
identity_id = _coalesce(
|
||||||
|
subject.identity_id,
|
||||||
|
references.get("reporting.identity"),
|
||||||
|
references.get("identity.id"),
|
||||||
|
)
|
||||||
|
membership_id = _coalesce(
|
||||||
|
subject.membership_id,
|
||||||
|
references.get("reporting.membership"),
|
||||||
|
references.get("tenancy.membership"),
|
||||||
|
)
|
||||||
|
direct: dict[str, str] = {}
|
||||||
|
for selector, aliases in _DIRECT_ALIASES.items():
|
||||||
|
value = _coalesce(*(references.get(alias) for alias in aliases))
|
||||||
|
if value is _CONFLICT:
|
||||||
|
return None
|
||||||
|
if value:
|
||||||
|
direct[selector] = str(value)
|
||||||
|
if _CONFLICT in {account_id, identity_id, membership_id}:
|
||||||
|
return None
|
||||||
|
return _Selectors(
|
||||||
|
account_id=_optional(account_id),
|
||||||
|
identity_id=_optional(identity_id),
|
||||||
|
membership_id=_optional(membership_id),
|
||||||
|
direct=direct,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce(*values: str | None) -> str | None | object:
|
||||||
|
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||||
|
if len(normalized) > 1:
|
||||||
|
return _CONFLICT
|
||||||
|
return next(iter(normalized), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _title(resource_type: str) -> str:
|
||||||
|
return resource_type.removeprefix("reporting_").replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
def _observed_at(row: Any) -> datetime | None:
|
||||||
|
for field in (
|
||||||
|
"generated_at",
|
||||||
|
"exported_at",
|
||||||
|
"evaluated_at",
|
||||||
|
"recorded_at",
|
||||||
|
"started_at",
|
||||||
|
"completed_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_at",
|
||||||
|
):
|
||||||
|
value = getattr(row, field, None)
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return _aware(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
aware = _aware(value)
|
||||||
|
return aware.isoformat() if aware else None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Reporting DSAR requires a SQLAlchemy Session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "reporting" or record.module_id != "reporting":
|
||||||
|
raise ValueError("Reporting DSAR cannot plan a foreign provider record.")
|
||||||
|
if record.resource_type not in _RESOURCE_MODELS or not record.resource_id:
|
||||||
|
raise ValueError("Reporting DSAR record identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "reporting" or action.module_id != "reporting":
|
||||||
|
raise ValueError("Reporting DSAR cannot execute a foreign provider action.")
|
||||||
|
if action.resource_type not in _RESOURCE_MODELS or not action.action_id.startswith(
|
||||||
|
"reporting:"
|
||||||
|
):
|
||||||
|
raise ValueError("Reporting DSAR action identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["REPORTING_DSAR_CAPABILITY", "ReportingDsarProvider"]
|
||||||
@@ -37,6 +37,12 @@ from govoplan_reporting.backend.db.models import (
|
|||||||
ReportingQualityResult,
|
ReportingQualityResult,
|
||||||
)
|
)
|
||||||
from govoplan_reporting.backend.definitions import get_definition, list_definitions
|
from govoplan_reporting.backend.definitions import get_definition, list_definitions
|
||||||
|
from govoplan_reporting.backend.domain import ReportingDefinitionRecord
|
||||||
|
from govoplan_reporting.backend.governance import require_definition_action
|
||||||
|
from govoplan_reporting.backend.postgres_planner import (
|
||||||
|
POSTGRES_PLANNER_VERSION,
|
||||||
|
execute_postgres_query,
|
||||||
|
)
|
||||||
from govoplan_reporting.backend.query_engine import (
|
from govoplan_reporting.backend.query_engine import (
|
||||||
QUERY_ENGINE_VERSION,
|
QUERY_ENGINE_VERSION,
|
||||||
DefaultChartRenderer,
|
DefaultChartRenderer,
|
||||||
@@ -99,7 +105,12 @@ class SqlReportingRunner:
|
|||||||
*,
|
*,
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
) -> Mapping[str, object] | None:
|
) -> Mapping[str, object] | None:
|
||||||
return get_execution(_session(session), principal, execution_id=execution_id)
|
return get_execution(
|
||||||
|
_session(session),
|
||||||
|
principal,
|
||||||
|
execution_id=execution_id,
|
||||||
|
registry=self.registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def execute_report(
|
def execute_report(
|
||||||
@@ -126,6 +137,13 @@ def execute_report(
|
|||||||
if report_record.status != "active":
|
if report_record.status != "active":
|
||||||
raise ReportingExecutionError("Only active report definitions can run.")
|
raise ReportingExecutionError("Only active report definitions can run.")
|
||||||
report = ReportDefinition.model_validate(report_record.payload)
|
report = ReportDefinition.model_validate(report_record.payload)
|
||||||
|
report_decision = require_definition_action(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
record=report_record,
|
||||||
|
action="run",
|
||||||
|
)
|
||||||
semantic_record = get_definition(
|
semantic_record = get_definition(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
@@ -138,6 +156,13 @@ def execute_report(
|
|||||||
"The report's pinned semantic model is unavailable or inactive."
|
"The report's pinned semantic model is unavailable or inactive."
|
||||||
)
|
)
|
||||||
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
|
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
|
||||||
|
semantic_decision = require_definition_action(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
record=semantic_record,
|
||||||
|
action="view",
|
||||||
|
)
|
||||||
dataset_record = get_definition(
|
dataset_record = get_definition(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
@@ -150,8 +175,15 @@ def execute_report(
|
|||||||
"The report's pinned analytical dataset is unavailable or inactive."
|
"The report's pinned analytical dataset is unavailable or inactive."
|
||||||
)
|
)
|
||||||
dataset = DatasetDefinition.model_validate(dataset_record.payload)
|
dataset = DatasetDefinition.model_validate(dataset_record.payload)
|
||||||
|
dataset_decision = require_definition_action(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
record=dataset_record,
|
||||||
|
action="view",
|
||||||
|
)
|
||||||
bound_parameters = _bind_parameters(report, parameters)
|
bound_parameters = _bind_parameters(report, parameters)
|
||||||
effective_query = query or report.default_query
|
effective_query = _enforce_query_access(report, query or report.default_query)
|
||||||
clean_idempotency_key = _required(
|
clean_idempotency_key = _required(
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
"Reporting execution idempotency key",
|
"Reporting execution idempotency key",
|
||||||
@@ -176,7 +208,19 @@ def execute_report(
|
|||||||
request_sha256=request_sha256,
|
request_sha256=request_sha256,
|
||||||
)
|
)
|
||||||
if replay is not None:
|
if replay is not None:
|
||||||
return _execution_payload(replay, report=report, registry=registry)
|
delivery = _authorize_execution_delivery(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
row=replay,
|
||||||
|
report_record=report_record,
|
||||||
|
)
|
||||||
|
return _execution_payload(
|
||||||
|
replay,
|
||||||
|
report=report,
|
||||||
|
registry=registry,
|
||||||
|
delivery_authorization=delivery,
|
||||||
|
)
|
||||||
started_at = utc_now()
|
started_at = utc_now()
|
||||||
execution = ReportingExecution(
|
execution = ReportingExecution(
|
||||||
tenant_id=_tenant(principal),
|
tenant_id=_tenant(principal),
|
||||||
@@ -232,7 +276,14 @@ def execute_report(
|
|||||||
output_hash=source.output_hash,
|
output_hash=source.output_hash,
|
||||||
source_fingerprints=source.source_fingerprints,
|
source_fingerprints=source.source_fingerprints,
|
||||||
)
|
)
|
||||||
result = execute_semantic_query(authorized_rows, semantic, effective_query)
|
result = execute_postgres_query(
|
||||||
|
session,
|
||||||
|
rows=authorized_rows,
|
||||||
|
dataset=dataset,
|
||||||
|
semantic_model=semantic,
|
||||||
|
query=effective_query,
|
||||||
|
) or execute_semantic_query(authorized_rows, semantic, effective_query)
|
||||||
|
diagnostics.extend(result.diagnostics)
|
||||||
output_hash = _sha256(
|
output_hash = _sha256(
|
||||||
{
|
{
|
||||||
"rows": result.rows,
|
"rows": result.rows,
|
||||||
@@ -244,7 +295,12 @@ def execute_report(
|
|||||||
execution.status = "succeeded"
|
execution.status = "succeeded"
|
||||||
execution.source_fingerprints = _json_value(source.source_fingerprints)
|
execution.source_fingerprints = _json_value(source.source_fingerprints)
|
||||||
execution.output_hash = output_hash
|
execution.output_hash = output_hash
|
||||||
execution.executor_version = f"{QUERY_ENGINE_VERSION}+{source.executor_version}"
|
planner_version = (
|
||||||
|
POSTGRES_PLANNER_VERSION
|
||||||
|
if any(item.get("code") == "postgresql_semantic_plan" for item in result.diagnostics)
|
||||||
|
else QUERY_ENGINE_VERSION
|
||||||
|
)
|
||||||
|
execution.executor_version = f"{planner_version}+{source.executor_version}"
|
||||||
execution.result_schema = list(result.schema)
|
execution.result_schema = list(result.schema)
|
||||||
execution.result_rows = list(result.rows)
|
execution.result_rows = list(result.rows)
|
||||||
execution.total_rows = result.total_rows
|
execution.total_rows = result.total_rows
|
||||||
@@ -259,11 +315,35 @@ def execute_report(
|
|||||||
"report_content_hash": report_record.content_hash,
|
"report_content_hash": report_record.content_hash,
|
||||||
"semantic_model_content_hash": semantic_record.content_hash,
|
"semantic_model_content_hash": semantic_record.content_hash,
|
||||||
"dataset_content_hash": dataset_record.content_hash,
|
"dataset_content_hash": dataset_record.content_hash,
|
||||||
|
"definition_governance": {
|
||||||
|
"report": report_decision.to_dict(),
|
||||||
|
"semantic_model": semantic_decision.to_dict(),
|
||||||
|
"dataset": dataset_decision.to_dict(),
|
||||||
|
},
|
||||||
|
"access_explanation": _access_explanation(
|
||||||
|
report,
|
||||||
|
effective_query,
|
||||||
|
source_rows=len(normalized_rows),
|
||||||
|
authorized_rows=len(authorized_rows),
|
||||||
|
row_policy=policy_provenance,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
execution.finished_at = utc_now()
|
execution.finished_at = utc_now()
|
||||||
session.flush()
|
session.flush()
|
||||||
_emit_execution_event(session, execution, report_record.name)
|
_emit_execution_event(session, execution, report_record.name)
|
||||||
return _execution_payload(execution, report=report, registry=registry)
|
delivery = _authorize_execution_delivery(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
row=execution,
|
||||||
|
report_record=report_record,
|
||||||
|
)
|
||||||
|
return _execution_payload(
|
||||||
|
execution,
|
||||||
|
report=report,
|
||||||
|
registry=registry,
|
||||||
|
delivery_authorization=delivery,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
execution.status = "failed"
|
execution.status = "failed"
|
||||||
execution.finished_at = utc_now()
|
execution.finished_at = utc_now()
|
||||||
@@ -284,6 +364,7 @@ def get_execution(
|
|||||||
principal: object,
|
principal: object,
|
||||||
*,
|
*,
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
|
registry: object | None = None,
|
||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
row = (
|
row = (
|
||||||
session.query(ReportingExecution)
|
session.query(ReportingExecution)
|
||||||
@@ -304,10 +385,18 @@ def get_execution(
|
|||||||
)
|
)
|
||||||
if report_record is None:
|
if report_record is None:
|
||||||
return None
|
return None
|
||||||
|
delivery = _authorize_execution_delivery(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
row=row,
|
||||||
|
report_record=report_record,
|
||||||
|
)
|
||||||
return _execution_payload(
|
return _execution_payload(
|
||||||
row,
|
row,
|
||||||
report=ReportDefinition.model_validate(report_record.payload),
|
report=ReportDefinition.model_validate(report_record.payload),
|
||||||
registry=None,
|
registry=registry,
|
||||||
|
delivery_authorization=delivery,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -317,16 +406,15 @@ def list_executions(
|
|||||||
*,
|
*,
|
||||||
report_id: str,
|
report_id: str,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
registry: object | None = None,
|
||||||
) -> tuple[dict[str, object], ...]:
|
) -> tuple[dict[str, object], ...]:
|
||||||
if (
|
current_report = get_definition(
|
||||||
get_definition(
|
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
definition_kind="report",
|
definition_kind="report",
|
||||||
definition_id=report_id,
|
definition_id=report_id,
|
||||||
)
|
)
|
||||||
is None
|
if current_report is None:
|
||||||
):
|
|
||||||
return ()
|
return ()
|
||||||
rows = (
|
rows = (
|
||||||
session.query(ReportingExecution)
|
session.query(ReportingExecution)
|
||||||
@@ -338,7 +426,46 @@ def list_executions(
|
|||||||
.limit(max(1, min(limit, 200)))
|
.limit(max(1, min(limit, 200)))
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return tuple(_execution_payload(row, report=None, registry=None) for row in rows)
|
authorization_cache: dict[tuple[int, int, int], dict[str, object]] = {}
|
||||||
|
payloads: list[dict[str, object]] = []
|
||||||
|
for row in rows:
|
||||||
|
key = (
|
||||||
|
row.report_revision,
|
||||||
|
row.semantic_model_revision,
|
||||||
|
row.dataset_revision,
|
||||||
|
)
|
||||||
|
report_record = (
|
||||||
|
current_report
|
||||||
|
if current_report.revision == row.report_revision
|
||||||
|
else get_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition_kind="report",
|
||||||
|
definition_id=row.report_id,
|
||||||
|
revision=row.report_revision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if report_record is None:
|
||||||
|
continue
|
||||||
|
delivery = authorization_cache.get(key)
|
||||||
|
if delivery is None:
|
||||||
|
delivery = _authorize_execution_delivery(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
row=row,
|
||||||
|
report_record=report_record,
|
||||||
|
)
|
||||||
|
authorization_cache[key] = delivery
|
||||||
|
payloads.append(
|
||||||
|
_execution_payload(
|
||||||
|
row,
|
||||||
|
report=ReportDefinition.model_validate(report_record.payload),
|
||||||
|
registry=registry,
|
||||||
|
delivery_authorization=delivery,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(payloads)
|
||||||
|
|
||||||
|
|
||||||
def _read_dataset(
|
def _read_dataset(
|
||||||
@@ -374,6 +501,7 @@ def _read_dataset(
|
|||||||
request=DataflowDatasetRequest(
|
request=DataflowDatasetRequest(
|
||||||
pipeline_ref=dataset.source_ref,
|
pipeline_ref=dataset.source_ref,
|
||||||
revision=dataset.source_revision or 0,
|
revision=dataset.source_revision or 0,
|
||||||
|
run_ref=dataset.source_run_ref,
|
||||||
parameters=source_parameters,
|
parameters=source_parameters,
|
||||||
row_limit=2_000,
|
row_limit=2_000,
|
||||||
expected_definition_hash=dataset.definition_hash,
|
expected_definition_hash=dataset.definition_hash,
|
||||||
@@ -717,11 +845,144 @@ def _evaluate_assertion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_query_access(
|
||||||
|
report: ReportDefinition,
|
||||||
|
query: ReportQuery,
|
||||||
|
) -> ReportQuery:
|
||||||
|
policy = report.access_policy
|
||||||
|
hidden_dimensions = _policy_strings(policy, "hidden_dimensions")
|
||||||
|
hidden_measures = _policy_strings(policy, "hidden_measures")
|
||||||
|
requested_dimensions = set(query.dimensions)
|
||||||
|
requested_dimensions.update(item.dimension for item in query.filters)
|
||||||
|
if query.pivot is not None:
|
||||||
|
requested_dimensions.update(query.pivot.rows)
|
||||||
|
requested_dimensions.update(query.pivot.columns)
|
||||||
|
requested_measures = set(query.measures)
|
||||||
|
if query.pivot is not None:
|
||||||
|
requested_measures.update(query.pivot.measures)
|
||||||
|
blocked = (requested_dimensions & hidden_dimensions) | (
|
||||||
|
requested_measures & hidden_measures
|
||||||
|
)
|
||||||
|
if blocked:
|
||||||
|
raise PermissionError(
|
||||||
|
"Policy hides requested Reporting fields: "
|
||||||
|
+ ", ".join(sorted(blocked))
|
||||||
|
)
|
||||||
|
if "run" in _policy_strings(policy, "disabled_actions"):
|
||||||
|
raise PermissionError(_policy_reason(policy, "run"))
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
def _access_explanation(
|
||||||
|
report: ReportDefinition,
|
||||||
|
query: ReportQuery,
|
||||||
|
*,
|
||||||
|
source_rows: int,
|
||||||
|
authorized_rows: int,
|
||||||
|
row_policy: Mapping[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
policy = report.access_policy
|
||||||
|
hidden_dimensions = sorted(_policy_strings(policy, "hidden_dimensions"))
|
||||||
|
hidden_measures = sorted(_policy_strings(policy, "hidden_measures"))
|
||||||
|
disabled_actions = sorted(_policy_strings(policy, "disabled_actions"))
|
||||||
|
reasons = policy.get("reasons")
|
||||||
|
return {
|
||||||
|
"hidden_dimensions": hidden_dimensions,
|
||||||
|
"hidden_measures": hidden_measures,
|
||||||
|
"hidden_rows": max(0, source_rows - authorized_rows),
|
||||||
|
"disabled_actions": disabled_actions,
|
||||||
|
"reasons": dict(reasons) if isinstance(reasons, Mapping) else {},
|
||||||
|
"row_policy": dict(row_policy),
|
||||||
|
"effective_query": query.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _authorize_execution_delivery(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
registry: object | None,
|
||||||
|
row: ReportingExecution,
|
||||||
|
report_record: ReportingDefinitionRecord,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
report_decision = require_definition_action(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
record=report_record,
|
||||||
|
action="view",
|
||||||
|
)
|
||||||
|
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 semantic_record is None or dataset_record is None:
|
||||||
|
raise PermissionError(
|
||||||
|
"The source definitions for this report result are no longer accessible."
|
||||||
|
)
|
||||||
|
semantic_decision = require_definition_action(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
record=semantic_record,
|
||||||
|
action="view",
|
||||||
|
)
|
||||||
|
dataset_decision = require_definition_action(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
record=dataset_record,
|
||||||
|
action="view",
|
||||||
|
)
|
||||||
|
dataset = DatasetDefinition.model_validate(dataset_record.payload)
|
||||||
|
_empty, row_policy = _apply_row_policy(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
dataset_id=dataset_record.definition_id,
|
||||||
|
dataset_revision=dataset_record.revision,
|
||||||
|
dataset=dataset,
|
||||||
|
rows=(),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"checked": True,
|
||||||
|
"report": report_decision.to_dict(),
|
||||||
|
"semantic_model": semantic_decision.to_dict(),
|
||||||
|
"dataset": dataset_decision.to_dict(),
|
||||||
|
"row_policy": dict(row_policy),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_strings(policy: Mapping[str, object], key: str) -> set[str]:
|
||||||
|
raw = policy.get(key, ())
|
||||||
|
if not isinstance(raw, (list, tuple, set, frozenset)):
|
||||||
|
return set()
|
||||||
|
return {str(item) for item in raw if str(item).strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_reason(policy: Mapping[str, object], action: str) -> str:
|
||||||
|
reasons = policy.get("reasons")
|
||||||
|
if isinstance(reasons, Mapping) and str(reasons.get(action) or "").strip():
|
||||||
|
return str(reasons[action])
|
||||||
|
return f"Policy disables the Reporting {action} action."
|
||||||
|
|
||||||
|
|
||||||
def _execution_payload(
|
def _execution_payload(
|
||||||
row: ReportingExecution,
|
row: ReportingExecution,
|
||||||
*,
|
*,
|
||||||
report: ReportDefinition | None,
|
report: ReportDefinition | None,
|
||||||
registry: object | None,
|
registry: object | None,
|
||||||
|
delivery_authorization: Mapping[str, object] | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
payload: dict[str, object] = {
|
payload: dict[str, object] = {
|
||||||
"execution_id": row.execution_id,
|
"execution_id": row.execution_id,
|
||||||
@@ -747,6 +1008,7 @@ def _execution_payload(
|
|||||||
"started_at": _datetime_text(row.started_at),
|
"started_at": _datetime_text(row.started_at),
|
||||||
"finished_at": _datetime_text(row.finished_at),
|
"finished_at": _datetime_text(row.finished_at),
|
||||||
"actor_id": row.actor_id,
|
"actor_id": row.actor_id,
|
||||||
|
"delivery_authorization": dict(delivery_authorization or {}),
|
||||||
}
|
}
|
||||||
if row.status == "succeeded" and report is not None:
|
if row.status == "succeeded" and report is not None:
|
||||||
result = QueryResult(
|
result = QueryResult(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -7,12 +7,15 @@ from govoplan_core.core.access import (
|
|||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_DATASET_OUTPUT
|
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 (
|
from govoplan_core.core.module_guards import (
|
||||||
drop_table_retirement_provider,
|
drop_table_retirement_provider,
|
||||||
persistent_table_uninstall_guard,
|
persistent_table_uninstall_guard,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -24,6 +27,7 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import (
|
from govoplan_core.core.provider_governance import (
|
||||||
@@ -41,11 +45,17 @@ from govoplan_core.db.base import Base
|
|||||||
from govoplan_reporting.backend.acl import ReportingScopeAclProvider
|
from govoplan_reporting.backend.acl import ReportingScopeAclProvider
|
||||||
from govoplan_reporting.backend.contracts import (
|
from govoplan_reporting.backend.contracts import (
|
||||||
CAPABILITY_REPORTING_CHART_RENDERER,
|
CAPABILITY_REPORTING_CHART_RENDERER,
|
||||||
|
CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||||
|
CAPABILITY_REPORTING_PUBLICATION_MAIL,
|
||||||
CAPABILITY_REPORTING_REGISTRY,
|
CAPABILITY_REPORTING_REGISTRY,
|
||||||
CAPABILITY_REPORTING_RUNNER,
|
CAPABILITY_REPORTING_RUNNER,
|
||||||
CAPABILITY_REPORTING_SCHEDULER,
|
CAPABILITY_REPORTING_SCHEDULER,
|
||||||
)
|
)
|
||||||
from govoplan_reporting.backend.db import models as reporting_models
|
from govoplan_reporting.backend.db import models as reporting_models
|
||||||
|
from govoplan_reporting.backend.dsar_provider import (
|
||||||
|
REPORTING_DSAR_CAPABILITY,
|
||||||
|
ReportingDsarProvider,
|
||||||
|
)
|
||||||
from govoplan_reporting.backend.definitions import (
|
from govoplan_reporting.backend.definitions import (
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
@@ -63,13 +73,17 @@ from govoplan_reporting.backend.operations import (
|
|||||||
SqlReportingScheduler,
|
SqlReportingScheduler,
|
||||||
)
|
)
|
||||||
from govoplan_reporting.backend.query_engine import DefaultChartRenderer
|
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.registry import SqlReportingRegistry
|
||||||
from govoplan_reporting.backend.search_source import create_reporting_search_source
|
from govoplan_reporting.backend.search_source import create_reporting_search_source
|
||||||
|
|
||||||
|
|
||||||
MODULE_ID = "reporting"
|
MODULE_ID = "reporting"
|
||||||
MODULE_NAME = "Reporting"
|
MODULE_NAME = "Reporting"
|
||||||
MODULE_VERSION = "0.1.14"
|
MODULE_VERSION = "0.1.22"
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
@@ -175,6 +189,14 @@ def _chart_renderer(context: ModuleContext) -> DefaultChartRenderer:
|
|||||||
return DefaultChartRenderer()
|
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):
|
def _retention(context: ModuleContext):
|
||||||
del context
|
del context
|
||||||
from govoplan_reporting.backend.retention import ReportingRetentionService
|
from govoplan_reporting.backend.retention import ReportingRetentionService
|
||||||
@@ -182,6 +204,11 @@ def _retention(context: ModuleContext):
|
|||||||
return ReportingRetentionService()
|
return ReportingRetentionService()
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(context: ModuleContext) -> ReportingDsarProvider:
|
||||||
|
del context
|
||||||
|
return ReportingDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
definitions = (
|
definitions = (
|
||||||
session.query(reporting_models.ReportingDefinitionRevision)
|
session.query(reporting_models.ReportingDefinitionRevision)
|
||||||
@@ -246,6 +273,8 @@ manifest = ModuleManifest(
|
|||||||
optional_capabilities=(
|
optional_capabilities=(
|
||||||
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
|
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
|
||||||
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
|
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
|
||||||
|
CAPABILITY_FILES_ARTIFACT_STORE,
|
||||||
|
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
@@ -289,6 +318,21 @@ manifest = ModuleManifest(
|
|||||||
surface_id="reporting.navigation",
|
surface_id="reporting.navigation",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="data-assurance",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.data_assurance",
|
||||||
|
icon="database-zap",
|
||||||
|
description="i18n:govoplan-core.product_area.data_assurance_description",
|
||||||
|
surface_ids=(
|
||||||
|
"reporting.navigation",
|
||||||
|
"reporting.workspace",
|
||||||
|
"reporting.compatibility",
|
||||||
|
),
|
||||||
|
order=60,
|
||||||
|
),
|
||||||
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="reporting.parameters",
|
id="reporting.parameters",
|
||||||
@@ -306,6 +350,13 @@ manifest = ModuleManifest(
|
|||||||
parent_id="reporting.workspace",
|
parent_id="reporting.workspace",
|
||||||
order=40,
|
order=40,
|
||||||
),
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="reporting.widget.reports",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Reports dashboard widget",
|
||||||
|
order=75,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
@@ -313,7 +364,14 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceProvider(name="reporting.runner", version="0.1.0"),
|
ModuleInterfaceProvider(name="reporting.runner", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="reporting.scheduler", version="0.1.0"),
|
ModuleInterfaceProvider(name="reporting.scheduler", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="reporting.chart_renderer", 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"),
|
ModuleInterfaceProvider(name=CAPABILITY_REPORTING_RETENTION, version="1.0.0"),
|
||||||
|
ModuleInterfaceProvider(name=REPORTING_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -328,13 +386,28 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="2.0.0",
|
version_max_exclusive="2.0.0",
|
||||||
optional=True,
|
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_factories={
|
||||||
CAPABILITY_REPORTING_REGISTRY: _registry,
|
CAPABILITY_REPORTING_REGISTRY: _registry,
|
||||||
CAPABILITY_REPORTING_RUNNER: _runner,
|
CAPABILITY_REPORTING_RUNNER: _runner,
|
||||||
CAPABILITY_REPORTING_SCHEDULER: _scheduler,
|
CAPABILITY_REPORTING_SCHEDULER: _scheduler,
|
||||||
CAPABILITY_REPORTING_CHART_RENDERER: _chart_renderer,
|
CAPABILITY_REPORTING_CHART_RENDERER: _chart_renderer,
|
||||||
|
CAPABILITY_REPORTING_PUBLICATION_FILES: _files_publication,
|
||||||
|
CAPABILITY_REPORTING_PUBLICATION_MAIL: _mail_publication,
|
||||||
CAPABILITY_REPORTING_RETENTION: _retention,
|
CAPABILITY_REPORTING_RETENTION: _retention,
|
||||||
|
REPORTING_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
CAPABILITY_REPORTING_REGISTRY: CapabilityDocumentation(
|
CAPABILITY_REPORTING_REGISTRY: CapabilityDocumentation(
|
||||||
@@ -357,6 +430,16 @@ manifest = ModuleManifest(
|
|||||||
summary="Builds provider-neutral chart models with an accessible tabular fallback.",
|
summary="Builds provider-neutral chart models with an accessible tabular fallback.",
|
||||||
contract_version="0.1.0",
|
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(
|
CAPABILITY_REPORTING_RETENTION: CapabilityDocumentation(
|
||||||
label="Reporting result retention",
|
label="Reporting result retention",
|
||||||
summary="Minimizes expired provider-report detail while retaining audit hashes and provenance.",
|
summary="Minimizes expired provider-report detail while retaining audit hashes and provenance.",
|
||||||
@@ -364,6 +447,13 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("privacy_officer", "operator", "system_admin"),
|
audience=("privacy_officer", "operator", "system_admin"),
|
||||||
),
|
),
|
||||||
|
REPORTING_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Reporting data-subject request provider",
|
||||||
|
summary="Finds subject-owned Reporting state and minimizes derived report copies.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("privacy_officer", "operator", "user"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
search_sources=(
|
search_sources=(
|
||||||
SearchSourceProviderRegistration(
|
SearchSourceProviderRegistration(
|
||||||
@@ -384,6 +474,7 @@ manifest = ModuleManifest(
|
|||||||
reporting_models.ReportingSavedView,
|
reporting_models.ReportingSavedView,
|
||||||
reporting_models.ReportingDefinitionGrant,
|
reporting_models.ReportingDefinitionGrant,
|
||||||
reporting_models.ReportingExecution,
|
reporting_models.ReportingExecution,
|
||||||
|
reporting_models.ReportingDrillContext,
|
||||||
reporting_models.ReportingProviderExport,
|
reporting_models.ReportingProviderExport,
|
||||||
reporting_models.ReportingProviderExecution,
|
reporting_models.ReportingProviderExecution,
|
||||||
reporting_models.ReportingDefinitionRevision,
|
reporting_models.ReportingDefinitionRevision,
|
||||||
@@ -401,6 +492,7 @@ manifest = ModuleManifest(
|
|||||||
reporting_models.ReportingDefinitionRevision,
|
reporting_models.ReportingDefinitionRevision,
|
||||||
reporting_models.ReportingDefinitionGrant,
|
reporting_models.ReportingDefinitionGrant,
|
||||||
reporting_models.ReportingExecution,
|
reporting_models.ReportingExecution,
|
||||||
|
reporting_models.ReportingDrillContext,
|
||||||
reporting_models.ReportingProviderExecution,
|
reporting_models.ReportingProviderExecution,
|
||||||
reporting_models.ReportingProviderExport,
|
reporting_models.ReportingProviderExport,
|
||||||
reporting_models.ReportingSavedView,
|
reporting_models.ReportingSavedView,
|
||||||
@@ -419,6 +511,87 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="reporting.data-subject-requests",
|
||||||
|
title="Reporting data-subject requests",
|
||||||
|
summary="Review personal workspace state and derived report copies without confusing them with source-owned facts.",
|
||||||
|
body=(
|
||||||
|
"Reporting matches exact tenant-scoped artifact references and account, identity, or membership ownership and attribution. Access output is deliberately minimized: report rows, parameters, filters, delivery targets, source payloads, diagnostics, provenance bodies, and hashes are not copied into the DSAR result. Source modules remain responsible for finding and correcting subject facts; Reporting cannot safely infer a person by scanning arbitrary aggregate output. "
|
||||||
|
"Private saved views and short-lived drill contexts can be deleted, subject grants can be revoked, and explicitly identified retained execution or publication detail can be minimized idempotently while hashes remain. Shared views, definitions, schedules, quality/import evidence, and staff attribution require authorized review or retention. Correct the source before rerunning a report or republishing an output."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("core", "datasources", "dataflow", "policy"),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"reporting.data-subject-requests",
|
||||||
|
"reporting.workspace",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_minimized_attribution": (
|
||||||
|
"Returns ownership and lifecycle context without report rows, parameters, or payloads."
|
||||||
|
),
|
||||||
|
"retain_governed_evidence": (
|
||||||
|
"Shared definitions, schedules, quality evidence, and required staff attribution remain subject to authorized review and retention."
|
||||||
|
),
|
||||||
|
"correct_authoritative_source": (
|
||||||
|
"Source facts must be corrected in their owner module before reports are rerun or republished."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzanfragen im Reporting",
|
||||||
|
"summary": (
|
||||||
|
"Persönliche Arbeitsbereichsdaten und abgeleitete Berichtskopien prüfen, "
|
||||||
|
"ohne sie mit Fakten aus führenden Quellsystemen zu verwechseln."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Reporting gleicht innerhalb des exakten Mandanten nur ausdrückliche "
|
||||||
|
"Artefaktverweise sowie die Zuordnung oder Urheberschaft von Konten, "
|
||||||
|
"Identitäten und Mitgliedschaften ab. Die Auskunft ist bewusst minimiert: "
|
||||||
|
"Berichtszeilen, Parameter, Filter, Zustellziele, Quellinhalte, Diagnosen, "
|
||||||
|
"Provenienzinhalte und Prüfsummen werden nicht in das Ergebnis kopiert. "
|
||||||
|
"Die Quellmodule bleiben dafür verantwortlich, personenbezogene Fakten zu "
|
||||||
|
"finden und zu berichtigen; Reporting darf Personen nicht durch das Durchsuchen "
|
||||||
|
"beliebiger Aggregatergebnisse ableiten. Private gespeicherte Ansichten und "
|
||||||
|
"kurzlebige Drilldown-Kontexte können gelöscht, personenbezogene Freigaben "
|
||||||
|
"entzogen und ausdrücklich bestimmte aufbewahrte Ausführungs- oder "
|
||||||
|
"Veröffentlichungsdetails idempotent minimiert werden, während Prüfsummen "
|
||||||
|
"erhalten bleiben. Gemeinsame Ansichten, Definitionen, Zeitpläne, Qualitäts- "
|
||||||
|
"und Importnachweise sowie dienstliche Zuschreibungen erfordern eine befugte "
|
||||||
|
"Prüfung oder Aufbewahrung. Die Quelle ist zu berichtigen, bevor ein Bericht "
|
||||||
|
"erneut ausgeführt oder veröffentlicht wird."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_minimized_attribution": (
|
||||||
|
"Gibt Zuordnungs- und Lebenszykluskontext ohne Berichtszeilen, Parameter oder Inhalte zurück."
|
||||||
|
),
|
||||||
|
"retain_governed_evidence": (
|
||||||
|
"Gemeinsame Definitionen, Zeitpläne, Qualitätsnachweise und erforderliche dienstliche Zuschreibungen unterliegen weiterhin befugter Prüfung und Aufbewahrung."
|
||||||
|
),
|
||||||
|
"correct_authoritative_source": (
|
||||||
|
"Quellfakten müssen im führenden Modul berichtigt werden, bevor Berichte erneut ausgeführt oder veröffentlicht werden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Reporting governance and retention",
|
||||||
|
href="govoplan-reporting/README.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
order=9,
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="reporting.governed-bi",
|
id="reporting.governed-bi",
|
||||||
title="Governed reporting and semantic BI",
|
title="Governed reporting and semantic BI",
|
||||||
@@ -429,11 +602,112 @@ manifest = ModuleManifest(
|
|||||||
"authorized result rows, diagnostics, and output hashes. Safe dimensions, "
|
"authorized result rows, diagnostics, and output hashes. Safe dimensions, "
|
||||||
"aggregations, typed expressions, filters, pivots, saved views, chart models, "
|
"aggregations, typed expressions, filters, pivots, saved views, chart models, "
|
||||||
"schedules, exports, and publication providers replace unchecked SQL in the "
|
"schedules, exports, and publication providers replace unchecked SQL in the "
|
||||||
"presentation layer. Dataflow and module read models remain source owners."
|
"presentation layer. PostgreSQL executes bounded semantic plans when available. "
|
||||||
|
"Calculated measure keys may contain the documented dots and hyphens, including in nested references; generated bind names remain internal and values remain parameters, not SQL fragments. "
|
||||||
|
"Signed drill contexts reauthorize contributor rows, and Files/Mail publication "
|
||||||
|
"adapters retain idempotent evidence. A dataset may pin one successful published Dataflow run, which is read from its exact Datasource materialization after both source boundaries reauthorize the current principal. Dataflow and module read models remain source owners. "
|
||||||
|
"The contributor drill-down action stays in a shared action column at the right edge of horizontally scrolled results; opening it still reauthorizes every contributor."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "operator", "module_admin", "product_owner"),
|
audience=("user", "operator", "module_admin", "product_owner"),
|
||||||
|
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||||
|
related_modules=("datasources", "dataflow", "policy", "files", "mail"),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"reporting.workspace",
|
||||||
|
"reporting.definitions",
|
||||||
|
"reporting.executions",
|
||||||
|
"reporting.publications",
|
||||||
|
],
|
||||||
|
"purpose": (
|
||||||
|
"Create and run a reproducible report over an authorized, revision-pinned dataset."
|
||||||
|
),
|
||||||
|
"prerequisites": [
|
||||||
|
"The actor can read Reporting and has the additional scope required for each offered action.",
|
||||||
|
"The selected dataset and every contributing row remain authorized by their owner providers.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Select or create a dataset definition and pin an immutable source revision.",
|
||||||
|
"Define safe dimensions, measures, filters, pivots, and a report revision.",
|
||||||
|
"Run the pinned revision and review quality, policy, provenance, and diagnostics evidence.",
|
||||||
|
"Inspect authorized rows or use a signed drill context that reauthorizes every contributor.",
|
||||||
|
"Export, schedule, or publish only when the corresponding action and target are authorized.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Reporting never replaces source-module authorization or authoritative source correction.",
|
||||||
|
"XLSX and PDF output require an installed renderer provider; native browser export is CSV or JSON.",
|
||||||
|
],
|
||||||
|
"operational_consequences": {
|
||||||
|
"run": "Creates immutable execution, provenance, quality, diagnostic, and output-hash evidence.",
|
||||||
|
"publish": "Creates idempotent target-delivery evidence and may cause an external effect.",
|
||||||
|
"schedule": "Allows future executions under the then-current authorization and policy state.",
|
||||||
|
},
|
||||||
|
"verification": [
|
||||||
|
"The execution names the pinned definition and source fingerprints.",
|
||||||
|
"Quality and policy results are visible before publication evidence is accepted.",
|
||||||
|
"Drilldown and publication access are reauthorized for the current principal.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Gesteuertes Reporting und semantische BI",
|
||||||
|
"summary": (
|
||||||
|
"Reproduzierbare Berichte auf anbietergeführten Datensätzen erstellen, "
|
||||||
|
"ohne Modul- oder Zeilenberechtigungen zu umgehen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Reporting fixiert Revisionen von Datensätzen, semantischen Modellen und "
|
||||||
|
"Berichten. Ausführungen bewahren Definitionsprüfsummen, Quellfingerabdrücke, "
|
||||||
|
"Richtlinienherkunft, Qualitätsnachweise, berechtigte Ergebniszeilen, Diagnosen "
|
||||||
|
"und Ausgabeprüfsummen. Sichere Dimensionen, Aggregationen, typisierte Ausdrücke, "
|
||||||
|
"Filter, Pivotierungen, gespeicherte Ansichten, Diagrammmodelle, Zeitpläne, "
|
||||||
|
"Exporte und Veröffentlichungsanbieter ersetzen ungeprüftes SQL in der "
|
||||||
|
"Darstellungsschicht. PostgreSQL führt begrenzte semantische Pläne aus, sofern "
|
||||||
|
"verfügbar. Kennungen berechneter Kennzahlen dürfen auch in verschachtelten Verweisen die vorgesehenen Punkte und Bindestriche enthalten; "
|
||||||
|
"erzeugte Bindungsnamen bleiben intern, und Werte bleiben Parameter statt SQL-Fragmente. Signierte Drilldown-Kontexte autorisieren beitragende Zeilen erneut; "
|
||||||
|
"Adapter für Dateien und Mail bewahren idempotente Nachweise. Ein Datensatz kann "
|
||||||
|
"genau eine erfolgreiche veröffentlichte Dataflow-Ausführung fixieren, die nach "
|
||||||
|
"erneuter Autorisierung beider Quellgrenzen aus ihrer exakten Datasource-"
|
||||||
|
"Materialisierung gelesen wird. Dataflow und die Lesemodelle der Module bleiben "
|
||||||
|
"führende Quellen. Die Aktion zum Aufschlüsseln beitragender Zeilen bleibt in einer gemeinsamen Aktionsspalte am rechten Rand horizontal gescrollter Ergebnisse; beim Öffnen wird jeder Beitrag erneut autorisiert."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"purpose": (
|
||||||
|
"Einen reproduzierbaren Bericht über einen berechtigten, revisionsgenau fixierten Datensatz erstellen und ausführen."
|
||||||
|
),
|
||||||
|
"prerequisites": [
|
||||||
|
"Die handelnde Person darf Reporting lesen und besitzt für jede angebotene Aktion die zusätzlich erforderliche Berechtigung.",
|
||||||
|
"Der ausgewählte Datensatz und jede beitragende Zeile bleiben durch ihre führenden Anbieter autorisiert.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Eine Datensatzdefinition auswählen oder erstellen und eine unveränderliche Quellrevision fixieren.",
|
||||||
|
"Sichere Dimensionen, Kennzahlen, Filter, Pivotierungen und eine Berichtsrevision definieren.",
|
||||||
|
"Die fixierte Revision ausführen und Qualitäts-, Richtlinien-, Provenienz- und Diagnosenachweise prüfen.",
|
||||||
|
"Berechtigte Zeilen prüfen oder einen signierten Drilldown-Kontext verwenden, der jeden Beitrag erneut autorisiert.",
|
||||||
|
"Nur mit der jeweiligen Aktions- und Zielberechtigung exportieren, planen oder veröffentlichen.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Reporting ersetzt weder die Autorisierung der Quellmodule noch die Berichtigung in der führenden Quelle.",
|
||||||
|
"XLSX- und PDF-Ausgaben erfordern einen installierten Renderer-Anbieter; der native Browserexport unterstützt CSV und JSON.",
|
||||||
|
],
|
||||||
|
"operational_consequences": {
|
||||||
|
"run": "Erzeugt unveränderliche Nachweise zu Ausführung, Provenienz, Qualität, Diagnosen und Ausgabeprüfsumme.",
|
||||||
|
"publish": "Erzeugt idempotente Nachweise zur Zielzustellung und kann eine externe Wirkung auslösen.",
|
||||||
|
"schedule": "Erlaubt künftige Ausführungen unter dem dann gültigen Berechtigungs- und Richtlinienstand.",
|
||||||
|
},
|
||||||
|
"verification": [
|
||||||
|
"Die Ausführung nennt die fixierte Definition und die Quellfingerabdrücke.",
|
||||||
|
"Qualitäts- und Richtlinienergebnisse sind sichtbar, bevor ein Veröffentlichungsnachweis akzeptiert wird.",
|
||||||
|
"Drilldown- und Veröffentlichungszugriffe werden für die aktuelle Person erneut autorisiert.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Reporting module boundary",
|
label="Reporting module boundary",
|
||||||
@@ -455,6 +729,11 @@ manifest = ModuleManifest(
|
|||||||
href="govoplan-reporting/docs/ADMIN_GUIDE.md",
|
href="govoplan-reporting/docs/ADMIN_GUIDE.md",
|
||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Reporting interface pattern audit",
|
||||||
|
href="govoplan-reporting/docs/INTERFACE_PATTERN_MIGRATION.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -476,9 +755,9 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
known_limits=(
|
known_limits=(
|
||||||
"Dataflow is the first live dataset adapter; additional module read models use the provider-neutral contract.",
|
"Dataflow is the first live dataset adapter; additional module read models use the provider-neutral contract.",
|
||||||
"Direct browser export supports CSV and JSON; XLSX, PDF, Files, Mail, and DMS delivery require an optional publication provider.",
|
"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.",
|
"Import assessment produces blocking diagnostics but does not execute source SQL or automatically activate generated definitions.",
|
||||||
"The initial chart provider emits a renderer-neutral model and accessible table; richer visual renderers remain replaceable adapters.",
|
"The built-in chart catalogue covers bounded bar, column, line, area, pie, donut, and metric views; specialized visual renderers remain replaceable adapters.",
|
||||||
),
|
),
|
||||||
owned_concepts=(
|
owned_concepts=(
|
||||||
"analytical dataset binding",
|
"analytical dataset binding",
|
||||||
|
|||||||
+71
@@ -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")
|
||||||
@@ -396,7 +396,12 @@ def publish_execution(
|
|||||||
options: Mapping[str, object],
|
options: Mapping[str, object],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
_require_scope(principal, PUBLISH_SCOPE)
|
_require_scope(principal, PUBLISH_SCOPE)
|
||||||
execution_payload = get_execution(session, principal, execution_id=execution_id)
|
execution_payload = get_execution(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
execution_id=execution_id,
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
if execution_payload is None:
|
if execution_payload is None:
|
||||||
raise LookupError("Reporting execution not found.")
|
raise LookupError("Reporting execution not found.")
|
||||||
if execution_payload["status"] != "succeeded":
|
if execution_payload["status"] != "succeeded":
|
||||||
@@ -497,14 +502,54 @@ def publish_execution(
|
|||||||
return _publication_payload(publication)
|
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(
|
def export_execution(
|
||||||
session: Session,
|
session: Session,
|
||||||
principal: object,
|
principal: object,
|
||||||
*,
|
*,
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
format: str,
|
format: str,
|
||||||
|
registry: object | None = None,
|
||||||
) -> tuple[bytes, str, str]:
|
) -> tuple[bytes, str, str]:
|
||||||
payload = get_execution(session, principal, execution_id=execution_id)
|
payload = get_execution(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
execution_id=execution_id,
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
raise LookupError("Reporting execution not found.")
|
raise LookupError("Reporting execution not found.")
|
||||||
if payload["status"] != "succeeded":
|
if payload["status"] != "succeeded":
|
||||||
@@ -850,6 +895,7 @@ __all__ = [
|
|||||||
"dispatch_due_schedules",
|
"dispatch_due_schedules",
|
||||||
"export_execution",
|
"export_execution",
|
||||||
"list_import_assessments",
|
"list_import_assessments",
|
||||||
|
"list_publications",
|
||||||
"list_saved_views",
|
"list_saved_views",
|
||||||
"list_schedules",
|
"list_schedules",
|
||||||
"publish_execution",
|
"publish_execution",
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
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,
|
||||||
|
# The expression position already makes this prefix unique. Model
|
||||||
|
# keys may contain dots or hyphens, which are not SQL bind names.
|
||||||
|
prefix + "_ref",
|
||||||
|
measures=measures,
|
||||||
|
stack=(*stack, reference),
|
||||||
|
)
|
||||||
|
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,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",
|
||||||
|
]
|
||||||
@@ -86,7 +86,7 @@ def execute_semantic_query(
|
|||||||
return QueryResult(
|
return QueryResult(
|
||||||
rows=tuple(selected),
|
rows=tuple(selected),
|
||||||
total_rows=total,
|
total_rows=total,
|
||||||
schema=_infer_schema(selected or sorted_rows[:1]),
|
schema=infer_query_schema(selected or sorted_rows[:1]),
|
||||||
truncated=query.offset + len(selected) < total,
|
truncated=query.offset + len(selected) < total,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ def _sort_rows(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _infer_schema(rows: Sequence[Mapping[str, object]]) -> tuple[dict[str, Any], ...]:
|
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))
|
names = tuple(dict.fromkeys(str(key) for row in rows for key in row))
|
||||||
return tuple(
|
return tuple(
|
||||||
{
|
{
|
||||||
@@ -491,4 +491,5 @@ __all__ = [
|
|||||||
"QueryResult",
|
"QueryResult",
|
||||||
"ReportingQueryError",
|
"ReportingQueryError",
|
||||||
"execute_semantic_query",
|
"execute_semantic_query",
|
||||||
|
"infer_query_schema",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ from govoplan_reporting.backend.definitions import (
|
|||||||
list_definitions,
|
list_definitions,
|
||||||
update_definition,
|
update_definition,
|
||||||
)
|
)
|
||||||
|
from govoplan_reporting.backend.drilldown import (
|
||||||
|
ReportingDrillError,
|
||||||
|
create_drill_context,
|
||||||
|
resolve_drill_context,
|
||||||
|
)
|
||||||
from govoplan_reporting.backend.execution import (
|
from govoplan_reporting.backend.execution import (
|
||||||
QUALITY_SCOPE,
|
QUALITY_SCOPE,
|
||||||
RUN_SCOPE,
|
RUN_SCOPE,
|
||||||
@@ -38,6 +43,7 @@ from govoplan_reporting.backend.operations import (
|
|||||||
dispatch_due_schedules,
|
dispatch_due_schedules,
|
||||||
export_execution,
|
export_execution,
|
||||||
list_import_assessments,
|
list_import_assessments,
|
||||||
|
list_publications,
|
||||||
list_saved_views,
|
list_saved_views,
|
||||||
list_schedules,
|
list_schedules,
|
||||||
publish_execution,
|
publish_execution,
|
||||||
@@ -53,10 +59,12 @@ from govoplan_reporting.backend.provider_reports import (
|
|||||||
list_provider_reports,
|
list_provider_reports,
|
||||||
provider_parameter_options,
|
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.query_engine import ReportingQueryError
|
||||||
from govoplan_reporting.backend.schemas import (
|
from govoplan_reporting.backend.schemas import (
|
||||||
DefinitionUpdateRequest,
|
DefinitionUpdateRequest,
|
||||||
DefinitionWriteRequest,
|
DefinitionWriteRequest,
|
||||||
|
DrillContextCreateRequest,
|
||||||
ImportAssessmentRequest,
|
ImportAssessmentRequest,
|
||||||
PublicationRequest,
|
PublicationRequest,
|
||||||
ProviderReportExecutionRequest,
|
ProviderReportExecutionRequest,
|
||||||
@@ -432,7 +440,13 @@ def create_router(registry: object | None) -> APIRouter:
|
|||||||
_require(principal, RUN_SCOPE)
|
_require(principal, RUN_SCOPE)
|
||||||
return {
|
return {
|
||||||
"executions": list(
|
"executions": list(
|
||||||
list_executions(session, principal, report_id=report_id, limit=limit)
|
list_executions(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
report_id=report_id,
|
||||||
|
limit=limit,
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,11 +457,69 @@ def create_router(registry: object | None) -> APIRouter:
|
|||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
_require(principal, RUN_SCOPE)
|
_require(principal, RUN_SCOPE)
|
||||||
result = get_execution(session, principal, execution_id=execution_id)
|
result = get_execution(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
execution_id=execution_id,
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
if result is None:
|
if result is None:
|
||||||
raise HTTPException(status_code=404, detail="Reporting execution not found")
|
raise HTTPException(status_code=404, detail="Reporting execution not found")
|
||||||
return result
|
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")
|
@router.get("/executions/{execution_id}/export")
|
||||||
def api_export_execution(
|
def api_export_execution(
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
@@ -462,6 +534,7 @@ def create_router(registry: object | None) -> APIRouter:
|
|||||||
principal,
|
principal,
|
||||||
execution_id=execution_id,
|
execution_id=execution_id,
|
||||||
format=format,
|
format=format,
|
||||||
|
registry=registry,
|
||||||
)
|
)
|
||||||
except (ReportingOperationError, LookupError) as exc:
|
except (ReportingOperationError, LookupError) as exc:
|
||||||
raise _error(exc) from exc
|
raise _error(exc) from exc
|
||||||
@@ -493,6 +566,33 @@ def create_router(registry: object | None) -> APIRouter:
|
|||||||
raise _error(exc) from exc
|
raise _error(exc) from exc
|
||||||
return result
|
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")
|
@router.get("/reports/{report_id}/saved-views")
|
||||||
def api_list_saved_views(
|
def api_list_saved_views(
|
||||||
report_id: str,
|
report_id: str,
|
||||||
|
|||||||
@@ -58,12 +58,50 @@ class FreshnessPolicy(BaseModel):
|
|||||||
require_source_fingerprints: bool = True
|
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):
|
class DatasetDefinition(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
|
|
||||||
source_kind: Literal["dataflow", "read_model", "static"]
|
source_kind: Literal["dataflow", "read_model", "static"]
|
||||||
source_ref: str = Field(min_length=1, max_length=500)
|
source_ref: str = Field(min_length=1, max_length=500)
|
||||||
source_revision: int | None = Field(default=None, ge=1)
|
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)
|
definition_hash: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
source_parameters: dict[str, Any] = Field(default_factory=dict)
|
source_parameters: dict[str, Any] = Field(default_factory=dict)
|
||||||
static_rows: list[dict[str, Any]] = Field(default_factory=list, max_length=2_000)
|
static_rows: list[dict[str, Any]] = Field(default_factory=list, max_length=2_000)
|
||||||
@@ -87,11 +125,14 @@ class DatasetDefinition(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
max_length=200,
|
max_length=200,
|
||||||
)
|
)
|
||||||
|
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_source_pin(self) -> "DatasetDefinition":
|
def validate_source_pin(self) -> "DatasetDefinition":
|
||||||
if self.source_kind == "dataflow" and self.source_revision is None:
|
if self.source_kind == "dataflow" and self.source_revision is None:
|
||||||
raise ValueError("Dataflow datasets require a pinned source revision.")
|
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:
|
if self.source_kind == "static" and not self.static_rows:
|
||||||
raise ValueError("Static analytical datasets require static_rows.")
|
raise ValueError("Static analytical datasets require static_rows.")
|
||||||
names = [item.name for item in self.fields]
|
names = [item.name for item in self.fields]
|
||||||
@@ -203,6 +244,7 @@ class SemanticModelDefinition(BaseModel):
|
|||||||
default_dimensions: list[str] = Field(default_factory=list, max_length=50)
|
default_dimensions: list[str] = Field(default_factory=list, max_length=50)
|
||||||
default_measures: 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)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_semantics(self) -> "SemanticModelDefinition":
|
def validate_semantics(self) -> "SemanticModelDefinition":
|
||||||
@@ -307,6 +349,7 @@ class VisualizationDefinition(BaseModel):
|
|||||||
"area",
|
"area",
|
||||||
"column",
|
"column",
|
||||||
"pie",
|
"pie",
|
||||||
|
"donut",
|
||||||
"metric",
|
"metric",
|
||||||
] = "table"
|
] = "table"
|
||||||
category_dimension: str | None = Field(default=None, max_length=120)
|
category_dimension: str | None = Field(default=None, max_length=120)
|
||||||
@@ -333,6 +376,7 @@ class ReportDefinition(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
max_length=200,
|
max_length=200,
|
||||||
)
|
)
|
||||||
|
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||||
|
|
||||||
|
|
||||||
class QualityAssertion(BaseModel):
|
class QualityAssertion(BaseModel):
|
||||||
@@ -359,6 +403,7 @@ class QualityPlanDefinition(BaseModel):
|
|||||||
dataset_revision: int = Field(ge=1)
|
dataset_revision: int = Field(ge=1)
|
||||||
assertions: list[QualityAssertion] = Field(min_length=1, max_length=200)
|
assertions: list[QualityAssertion] = Field(min_length=1, max_length=200)
|
||||||
block_report_execution: bool = True
|
block_report_execution: bool = True
|
||||||
|
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||||
|
|
||||||
|
|
||||||
DEFINITION_PAYLOAD_TYPES = {
|
DEFINITION_PAYLOAD_TYPES = {
|
||||||
@@ -478,6 +523,13 @@ class PublicationRequest(BaseModel):
|
|||||||
options: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class QualityRunRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -499,9 +551,11 @@ TypedExpression.model_rebuild()
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DatasetDefinition",
|
"DatasetDefinition",
|
||||||
|
"DefinitionGovernance",
|
||||||
"DefinitionUpdateRequest",
|
"DefinitionUpdateRequest",
|
||||||
"DefinitionWriteRequest",
|
"DefinitionWriteRequest",
|
||||||
"DimensionDefinition",
|
"DimensionDefinition",
|
||||||
|
"DrillContextCreateRequest",
|
||||||
"FilterClause",
|
"FilterClause",
|
||||||
"ImportAssessmentRequest",
|
"ImportAssessmentRequest",
|
||||||
"MeasureDefinition",
|
"MeasureDefinition",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
documentation_structured_translation_issues,
|
||||||
|
user_workflow_scope_condition_issues,
|
||||||
|
)
|
||||||
|
from govoplan_reporting.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingDocumentationTests(unittest.TestCase):
|
||||||
|
def test_public_topics_have_complete_german_reference_content(self) -> None:
|
||||||
|
self.assertEqual(2, len(manifest.documentation))
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
translation = topic.translations.get("de", {})
|
||||||
|
self.assertTrue(
|
||||||
|
all(translation.get(key) for key in ("title", "summary", "body"))
|
||||||
|
)
|
||||||
|
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||||
|
|
||||||
|
def test_documentation_has_scope_conditioned_workflow_and_reference(self) -> None:
|
||||||
|
kinds = {topic.metadata.get("kind") for topic in manifest.documentation}
|
||||||
|
self.assertIn("workflow", kinds)
|
||||||
|
self.assertIn("reference", kinds)
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
self.assertEqual((), user_workflow_scope_condition_issues(topic))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
from govoplan_reporting.backend.db.models import (
|
||||||
|
ReportingDefinitionGrant,
|
||||||
|
ReportingDefinitionIdentity,
|
||||||
|
ReportingDefinitionRevision,
|
||||||
|
ReportingDrillContext,
|
||||||
|
ReportingExecution,
|
||||||
|
ReportingImportAssessment,
|
||||||
|
ReportingProviderExecution,
|
||||||
|
ReportingProviderExport,
|
||||||
|
ReportingPublication,
|
||||||
|
ReportingQualityResult,
|
||||||
|
ReportingSavedView,
|
||||||
|
ReportingSchedule,
|
||||||
|
)
|
||||||
|
from govoplan_reporting.backend.dsar_provider import (
|
||||||
|
REPORTING_DSAR_CAPABILITY,
|
||||||
|
ReportingDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_reporting.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 21, 18, 0, tzinfo=UTC)
|
||||||
|
SECRET = "personal-report-detail-do-not-export"
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: ReportingDsarProvider, *, active: bool = True) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (REPORTING_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "reporting"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
active = self.active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("reporting",) if active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "reporting"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != REPORTING_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = ReportingDsarProvider()
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
self._seed()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _seed(self) -> None:
|
||||||
|
identity = ReportingDefinitionIdentity(
|
||||||
|
id="definition-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_kind="report",
|
||||||
|
definition_id="report-1",
|
||||||
|
definition_key="resident-permits",
|
||||||
|
created_by="account-1",
|
||||||
|
)
|
||||||
|
self.session.add(identity)
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add(
|
||||||
|
ReportingDefinitionRevision(
|
||||||
|
id="revision-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id=identity.id,
|
||||||
|
definition_kind="report",
|
||||||
|
definition_id="report-1",
|
||||||
|
definition_key="resident-permits",
|
||||||
|
revision=1,
|
||||||
|
name=SECRET,
|
||||||
|
description=SECRET,
|
||||||
|
status="active",
|
||||||
|
visibility="restricted",
|
||||||
|
content_hash="a" * 64,
|
||||||
|
change_reason=SECRET,
|
||||||
|
idempotency_key=SECRET,
|
||||||
|
request_sha256="b" * 64,
|
||||||
|
event_id="event-1",
|
||||||
|
recorded_at=NOW,
|
||||||
|
payload={"secret": SECRET},
|
||||||
|
changed_by="account-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
self._execution(
|
||||||
|
row_id="execution-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
execution_id="execution-1",
|
||||||
|
actor_id="account-1",
|
||||||
|
),
|
||||||
|
self._execution(
|
||||||
|
row_id="execution-row-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
execution_id="execution-1",
|
||||||
|
actor_id="account-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider_execution = ReportingProviderExecution(
|
||||||
|
id="provider-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
execution_id="provider-execution-1",
|
||||||
|
provider_id="cases.reports",
|
||||||
|
report_id="resident-permits",
|
||||||
|
report_revision="1",
|
||||||
|
contract_version="1.0.0",
|
||||||
|
idempotency_key=SECRET,
|
||||||
|
request_sha256="c" * 64,
|
||||||
|
purpose=SECRET,
|
||||||
|
audience_scope={"secret": SECRET},
|
||||||
|
parameters={"secret": SECRET},
|
||||||
|
result_schema=[{"secret": SECRET}],
|
||||||
|
result_payload={"secret": SECRET},
|
||||||
|
source_revisions=[{"secret": SECRET}],
|
||||||
|
effective_scope={"secret": SECRET},
|
||||||
|
privacy_transforms=["small_cell_suppression"],
|
||||||
|
provenance={"secret": SECRET},
|
||||||
|
governance_provenance={"secret": SECRET},
|
||||||
|
retention_class="short",
|
||||||
|
retention_days=30,
|
||||||
|
expires_at=NOW + timedelta(days=30),
|
||||||
|
output_hash="d" * 64,
|
||||||
|
generated_at=NOW,
|
||||||
|
actor_id="account-1",
|
||||||
|
)
|
||||||
|
self.session.add(provider_execution)
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
ReportingProviderExport(
|
||||||
|
id="export-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
export_id="export-1",
|
||||||
|
provider_execution_id=provider_execution.id,
|
||||||
|
execution_id=provider_execution.execution_id,
|
||||||
|
format="json",
|
||||||
|
purpose=SECRET,
|
||||||
|
audience_scope={"secret": SECRET},
|
||||||
|
output_hash="e" * 64,
|
||||||
|
exported_at=NOW,
|
||||||
|
actor_id="account-1",
|
||||||
|
),
|
||||||
|
ReportingDefinitionGrant(
|
||||||
|
id="grant-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_kind="report",
|
||||||
|
definition_id="report-1",
|
||||||
|
subject_kind="account",
|
||||||
|
subject_id="account-1",
|
||||||
|
permissions=["view"],
|
||||||
|
active=True,
|
||||||
|
source_revision=1,
|
||||||
|
),
|
||||||
|
ReportingSavedView(
|
||||||
|
id="view-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
view_id="view-1",
|
||||||
|
report_id="report-1",
|
||||||
|
report_revision=1,
|
||||||
|
owner_kind="account",
|
||||||
|
owner_id="account-1",
|
||||||
|
name=SECRET,
|
||||||
|
state={"secret": SECRET},
|
||||||
|
shared=False,
|
||||||
|
access={"secret": SECRET},
|
||||||
|
),
|
||||||
|
ReportingSavedView(
|
||||||
|
id="view-row-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
view_id="view-shared",
|
||||||
|
report_id="report-1",
|
||||||
|
report_revision=1,
|
||||||
|
owner_kind="account",
|
||||||
|
owner_id="account-1",
|
||||||
|
name=SECRET,
|
||||||
|
state={"secret": SECRET},
|
||||||
|
shared=True,
|
||||||
|
access={"secret": SECRET},
|
||||||
|
),
|
||||||
|
ReportingSchedule(
|
||||||
|
id="schedule-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
schedule_id="schedule-1",
|
||||||
|
report_id="report-1",
|
||||||
|
report_revision=1,
|
||||||
|
name=SECRET,
|
||||||
|
trigger_kind="interval",
|
||||||
|
trigger_config={"secret": SECRET},
|
||||||
|
parameters={"secret": SECRET},
|
||||||
|
query={"secret": SECRET},
|
||||||
|
publication_target={"secret": SECRET},
|
||||||
|
enabled=True,
|
||||||
|
next_run_at=NOW + timedelta(days=1),
|
||||||
|
created_by="account-1",
|
||||||
|
),
|
||||||
|
ReportingPublication(
|
||||||
|
id="publication-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
publication_id="publication-1",
|
||||||
|
execution_id="execution-1",
|
||||||
|
target_capability="files.artifact_store",
|
||||||
|
target_ref=SECRET,
|
||||||
|
format="json",
|
||||||
|
status="succeeded",
|
||||||
|
idempotency_key=SECRET,
|
||||||
|
evidence={"secret": SECRET},
|
||||||
|
error=SECRET,
|
||||||
|
completed_at=NOW,
|
||||||
|
),
|
||||||
|
ReportingDrillContext(
|
||||||
|
id="drill-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
drill_context_id="drill-1",
|
||||||
|
execution_id="execution-1",
|
||||||
|
token_sha256="f" * 64,
|
||||||
|
context_sha256="0" * 64,
|
||||||
|
actor_id="account-1",
|
||||||
|
dimension_path=[{"secret": SECRET}],
|
||||||
|
source_fingerprints=[{"secret": SECRET}],
|
||||||
|
policy_provenance={"secret": SECRET},
|
||||||
|
expires_at=NOW + timedelta(minutes=10),
|
||||||
|
),
|
||||||
|
ReportingQualityResult(
|
||||||
|
id="quality-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
result_id="quality-1",
|
||||||
|
quality_plan_id="quality-plan-1",
|
||||||
|
quality_plan_revision=1,
|
||||||
|
dataset_id="dataset-1",
|
||||||
|
dataset_revision=1,
|
||||||
|
status="passed",
|
||||||
|
output_hash="1" * 64,
|
||||||
|
assertions=[{"secret": SECRET}],
|
||||||
|
source_fingerprints=[{"secret": SECRET}],
|
||||||
|
evaluated_at=NOW,
|
||||||
|
actor_id="account-1",
|
||||||
|
),
|
||||||
|
ReportingImportAssessment(
|
||||||
|
id="assessment-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
assessment_id="assessment-1",
|
||||||
|
source_system="legacy-bi",
|
||||||
|
source_id=SECRET,
|
||||||
|
source_fingerprint="2" * 64,
|
||||||
|
mapping_report={"secret": SECRET},
|
||||||
|
status="blocked",
|
||||||
|
accepted_approximations=[SECRET],
|
||||||
|
assessed_by="account-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _execution(
|
||||||
|
*,
|
||||||
|
row_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
execution_id: str,
|
||||||
|
actor_id: str,
|
||||||
|
) -> ReportingExecution:
|
||||||
|
return ReportingExecution(
|
||||||
|
id=row_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
execution_id=execution_id,
|
||||||
|
report_id="report-1",
|
||||||
|
report_revision=1,
|
||||||
|
semantic_model_id="semantic-1",
|
||||||
|
semantic_model_revision=1,
|
||||||
|
dataset_id="dataset-1",
|
||||||
|
dataset_revision=1,
|
||||||
|
status="succeeded",
|
||||||
|
idempotency_key=SECRET,
|
||||||
|
request_sha256="3" * 64,
|
||||||
|
parameters={"secret": SECRET},
|
||||||
|
query={"secret": SECRET},
|
||||||
|
source_fingerprints=[{"secret": SECRET}],
|
||||||
|
definition_hashes={"secret": SECRET},
|
||||||
|
output_hash="4" * 64,
|
||||||
|
executor_version="reporting-v1",
|
||||||
|
result_schema=[{"secret": SECRET}],
|
||||||
|
result_rows=[{"secret": SECRET}],
|
||||||
|
total_rows=1,
|
||||||
|
truncated=False,
|
||||||
|
diagnostics=[{"secret": SECRET}],
|
||||||
|
provenance={"secret": SECRET},
|
||||||
|
started_at=NOW,
|
||||||
|
finished_at=NOW,
|
||||||
|
actor_id=actor_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_canonical_selector_returns_minimized_owned_and_attribution_rows(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(12, len(records))
|
||||||
|
self.assertIn(
|
||||||
|
"subject_owned_reporting_view",
|
||||||
|
{record.category for record in records},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
{record.category for record in records},
|
||||||
|
)
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
self.assertNotIn(SECRET, exported)
|
||||||
|
self.assertNotIn("account-1", exported)
|
||||||
|
self.assertNotIn("execution-row-2", exported)
|
||||||
|
|
||||||
|
def test_direct_references_are_exact_tenant_scoped_and_corroborated(self) -> None:
|
||||||
|
direct = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={"reporting.execution": "execution-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mismatch = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-2",
|
||||||
|
external_references={"reporting.execution": "execution-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
wrong_tenant = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={"reporting.saved_view": "view-1"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"reporting.definition_revision": "revision-row-1",
|
||||||
|
"reporting.revision": "different-revision",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["execution-row-1"], [item.resource_id for item in direct])
|
||||||
|
self.assertEqual("derived_report_result", direct[0].category)
|
||||||
|
self.assertEqual((), mismatch)
|
||||||
|
self.assertEqual((), wrong_tenant)
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
|
||||||
|
def test_every_exact_artifact_reference_is_supported(self) -> None:
|
||||||
|
references = {
|
||||||
|
"reporting.definition": ("report-1", 2),
|
||||||
|
"reporting.definition_revision": ("revision-row-1", 1),
|
||||||
|
"reporting.provider_execution": ("provider-execution-1", 1),
|
||||||
|
"reporting.provider_export": ("export-1", 1),
|
||||||
|
"reporting.definition_grant": ("grant-row-1", 1),
|
||||||
|
"reporting.saved_view": ("view-1", 1),
|
||||||
|
"reporting.schedule": ("schedule-1", 1),
|
||||||
|
"reporting.publication": ("publication-1", 1),
|
||||||
|
"reporting.drill_context": ("drill-1", 1),
|
||||||
|
"reporting.quality_result": ("quality-1", 1),
|
||||||
|
"reporting.import_assessment": ("assessment-1", 1),
|
||||||
|
}
|
||||||
|
for key, (value, expected) in references.items():
|
||||||
|
with self.subTest(key=key):
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(external_references={key: value}),
|
||||||
|
)
|
||||||
|
self.assertEqual(expected, len(records))
|
||||||
|
|
||||||
|
def test_planning_and_execution_preserve_governed_boundaries(self) -> None:
|
||||||
|
subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
by_resource = {action.resource_id: action for action in actions}
|
||||||
|
self.assertEqual("delete", by_resource["view-row-1"].kind)
|
||||||
|
self.assertEqual("manual_review", by_resource["view-row-2"].kind)
|
||||||
|
self.assertEqual("delete", by_resource["drill-row-1"].kind)
|
||||||
|
self.assertEqual("revoke", by_resource["grant-row-1"].kind)
|
||||||
|
self.assertEqual("retain", by_resource["execution-row-1"].kind)
|
||||||
|
|
||||||
|
first = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
second = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1-retry",
|
||||||
|
)
|
||||||
|
self.assertIn("executed", {result.status for result in first})
|
||||||
|
self.assertIn("blocked", {result.status for result in first})
|
||||||
|
self.assertIn("unchanged", {result.status for result in second})
|
||||||
|
self.assertIsNone(self.session.get(ReportingSavedView, "view-row-1"))
|
||||||
|
self.assertIsNone(self.session.get(ReportingDrillContext, "drill-row-1"))
|
||||||
|
self.assertFalse(
|
||||||
|
self.session.get(ReportingDefinitionGrant, "grant-row-1").active
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[{"secret": SECRET}],
|
||||||
|
self.session.get(ReportingExecution, "execution-row-1").result_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exact_derived_detail_is_minimized_idempotently(self) -> None:
|
||||||
|
subject = DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"reporting.execution": "execution-1",
|
||||||
|
"reporting.provider_execution": "provider-execution-1",
|
||||||
|
"reporting.provider_export": "export-1",
|
||||||
|
"reporting.publication": "publication-1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
self.assertEqual({"anonymize"}, {action.kind for action in actions})
|
||||||
|
first = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-2",
|
||||||
|
)
|
||||||
|
second = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-2-retry",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(all(result.status == "executed" for result in first))
|
||||||
|
self.assertTrue(all(result.status == "unchanged" for result in second))
|
||||||
|
execution = self.session.get(ReportingExecution, "execution-row-1")
|
||||||
|
provider_execution = self.session.get(
|
||||||
|
ReportingProviderExecution,
|
||||||
|
"provider-row-1",
|
||||||
|
)
|
||||||
|
publication = self.session.get(
|
||||||
|
ReportingPublication,
|
||||||
|
"publication-row-1",
|
||||||
|
)
|
||||||
|
provider_export = self.session.get(ReportingProviderExport, "export-row-1")
|
||||||
|
self.assertEqual([], execution.result_rows)
|
||||||
|
self.assertEqual({}, execution.parameters)
|
||||||
|
self.assertEqual({}, provider_execution.result_payload)
|
||||||
|
self.assertIsNotNone(provider_execution.retention_redacted_at)
|
||||||
|
self.assertEqual({}, provider_export.audience_scope)
|
||||||
|
self.assertEqual(
|
||||||
|
"Redacted by data-subject request.",
|
||||||
|
provider_export.purpose,
|
||||||
|
)
|
||||||
|
self.assertIsNone(publication.target_ref)
|
||||||
|
self.assertEqual({}, publication.evidence)
|
||||||
|
self.assertIsNone(publication.error)
|
||||||
|
|
||||||
|
def test_foreign_records_and_actions_are_rejected(self) -> None:
|
||||||
|
subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||||
|
self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=(
|
||||||
|
DsarRecordRef(
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
category="case",
|
||||||
|
title="Case",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id="cases:delete:case:case-1",
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
title="Delete case",
|
||||||
|
rationale="Foreign",
|
||||||
|
executable=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
request_id="dsar-3",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
|
||||||
|
row = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-REPORTING-1",
|
||||||
|
request_kind="access_and_erasure",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 and 17 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=row,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[REPORTING_DSAR_CAPABILITY],
|
||||||
|
row.coverage["provider_capabilities"],
|
||||||
|
)
|
||||||
|
self.assertEqual(12, row.search_result["record_count"])
|
||||||
|
|
||||||
|
inactive = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-REPORTING-2",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, active=False),
|
||||||
|
row=inactive,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||||
|
self.assertEqual(
|
||||||
|
[REPORTING_DSAR_CAPABILITY],
|
||||||
|
inactive.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
self.assertEqual(0, inactive.search_result["record_count"])
|
||||||
|
|
||||||
|
def test_manifest_registers_and_documents_capability(self) -> None:
|
||||||
|
self.assertIn(REPORTING_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(REPORTING_DSAR_CAPABILITY, manifest.capability_documentation)
|
||||||
|
self.assertIn(
|
||||||
|
REPORTING_DSAR_CAPABILITY,
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
topic.id == "reporting.data-subject-requests"
|
||||||
|
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||||
|
for topic in manifest.documentation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -14,7 +14,7 @@ class ReportingManifestTests(unittest.TestCase):
|
|||||||
self.assertEqual("@govoplan/reporting-webui", manifest.frontend.package_name)
|
self.assertEqual("@govoplan/reporting-webui", manifest.frontend.package_name)
|
||||||
self.assertIsNotNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNotNone(manifest.migration_spec)
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
self.assertEqual(5, len(manifest.provides_interfaces))
|
self.assertEqual(8, len(manifest.provides_interfaces))
|
||||||
self.assertEqual(1, len(manifest.search_sources))
|
self.assertEqual(1, len(manifest.search_sources))
|
||||||
self.assertIn("dataflow", manifest.optional_dependencies)
|
self.assertIn("dataflow", manifest.optional_dependencies)
|
||||||
self.assertIn("policy", manifest.optional_dependencies)
|
self.assertIn("policy", manifest.optional_dependencies)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ def test_fresh_migration_creates_provider_evidence_tables_and_current_head() ->
|
|||||||
tables = set(inspect(connection).get_table_names())
|
tables = set(inspect(connection).get_table_names())
|
||||||
assert {
|
assert {
|
||||||
"reporting_provider_executions",
|
"reporting_provider_executions",
|
||||||
|
"reporting_drill_contexts",
|
||||||
"reporting_provider_exports",
|
"reporting_provider_exports",
|
||||||
}.issubset(tables)
|
}.issubset(tables)
|
||||||
assert "b7c4e1a9d2f6" in set(
|
assert "b7c4e1a9d2f6" in set(
|
||||||
|
|||||||
@@ -70,6 +70,34 @@ assert provider.contract_version == '1.0'
|
|||||||
_run_probe(script)
|
_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:
|
def _run_probe(source: str) -> None:
|
||||||
environment = dict(os.environ)
|
environment = dict(os.environ)
|
||||||
environment["PYTHONPATH"] = os.pathsep.join(
|
environment["PYTHONPATH"] = os.pathsep.join(
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from govoplan_reporting.backend.postgres_planner import compile_postgres_query
|
||||||
|
from govoplan_reporting.backend.schemas import (
|
||||||
|
DatasetDefinition,
|
||||||
|
ReportQuery,
|
||||||
|
SemanticModelDefinition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresBindNameTests(unittest.TestCase):
|
||||||
|
def test_allowed_measure_key_punctuation_never_becomes_bind_parameter_syntax(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
dataset = DatasetDefinition(
|
||||||
|
source_kind="static",
|
||||||
|
source_ref="fixture",
|
||||||
|
static_rows=[{"value": 10}],
|
||||||
|
purpose="Bound parameter fixture",
|
||||||
|
)
|
||||||
|
semantic = SemanticModelDefinition.model_validate(
|
||||||
|
{
|
||||||
|
"dataset_id": "fixture",
|
||||||
|
"dataset_revision": 1,
|
||||||
|
"measures": [
|
||||||
|
{
|
||||||
|
"key": "base",
|
||||||
|
"label": "Base",
|
||||||
|
"aggregation": "sum",
|
||||||
|
"field": "value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "extra-cost",
|
||||||
|
"label": "Extra",
|
||||||
|
"aggregation": "calculated",
|
||||||
|
"expression": {
|
||||||
|
"op": "add",
|
||||||
|
"args": [
|
||||||
|
{"op": "measure", "ref": "base"},
|
||||||
|
{"op": "literal", "value": 5},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "tax.factor",
|
||||||
|
"label": "Tax",
|
||||||
|
"aggregation": "calculated",
|
||||||
|
"expression": {
|
||||||
|
"op": "multiply",
|
||||||
|
"args": [
|
||||||
|
{"op": "measure", "ref": "base"},
|
||||||
|
{"op": "literal", "value": 9},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "grand-total",
|
||||||
|
"label": "Total",
|
||||||
|
"aggregation": "calculated",
|
||||||
|
"expression": {
|
||||||
|
"op": "add",
|
||||||
|
"args": [
|
||||||
|
{"op": "measure", "ref": "extra-cost"},
|
||||||
|
{"op": "measure", "ref": "tax.factor"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
plan = compile_postgres_query(
|
||||||
|
dataset,
|
||||||
|
semantic,
|
||||||
|
ReportQuery(measures=["extra-cost", "tax.factor", "grand-total"]),
|
||||||
|
)
|
||||||
|
binds = text(plan.sql).compile(dialect=postgresql.dialect()).params
|
||||||
|
self.assertEqual(
|
||||||
|
set(plan.parameters) | {"rows_json", "result_limit", "result_offset"},
|
||||||
|
set(binds),
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all("-" not in name and "." not in name for name in plan.parameters)
|
||||||
|
)
|
||||||
|
self.assertIn('AS "extra-cost"', plan.sql)
|
||||||
|
self.assertIn('AS "tax.factor"', plan.sql)
|
||||||
|
self.assertEqual(2, list(plan.parameters.values()).count(5))
|
||||||
|
self.assertEqual(2, list(plan.parameters.values()).count(9))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -92,7 +92,7 @@ class _Provider:
|
|||||||
del session
|
del session
|
||||||
return ReportProviderResult(
|
return ReportProviderResult(
|
||||||
report_id=request.report_id,
|
report_id=request.report_id,
|
||||||
generated_at=datetime(2026, 8, 2, 10, 0, tzinfo=UTC),
|
generated_at=datetime.now(UTC),
|
||||||
payload={"metric": 12},
|
payload={"metric": 12},
|
||||||
source_revisions=(
|
source_revisions=(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.db.base import Base
|
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_core.security.module_permissions import scopes_grant_compatible
|
||||||
from govoplan_reporting.backend.definitions import (
|
from govoplan_reporting.backend.definitions import (
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
@@ -27,6 +35,11 @@ from govoplan_reporting.backend.execution import (
|
|||||||
execute_report,
|
execute_report,
|
||||||
run_quality_plan,
|
run_quality_plan,
|
||||||
)
|
)
|
||||||
|
from govoplan_reporting.backend.drilldown import (
|
||||||
|
ReportingDrillError,
|
||||||
|
create_drill_context,
|
||||||
|
resolve_drill_context,
|
||||||
|
)
|
||||||
from govoplan_reporting.backend.operations import (
|
from govoplan_reporting.backend.operations import (
|
||||||
IMPORT_SCOPE,
|
IMPORT_SCOPE,
|
||||||
PUBLISH_SCOPE,
|
PUBLISH_SCOPE,
|
||||||
@@ -35,10 +48,24 @@ from govoplan_reporting.backend.operations import (
|
|||||||
assess_import,
|
assess_import,
|
||||||
dispatch_due_schedules,
|
dispatch_due_schedules,
|
||||||
export_execution,
|
export_execution,
|
||||||
|
list_publications,
|
||||||
|
publish_execution,
|
||||||
upsert_saved_view,
|
upsert_saved_view,
|
||||||
upsert_schedule,
|
upsert_schedule,
|
||||||
)
|
)
|
||||||
from govoplan_reporting.backend.schemas import ReportQuery
|
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)
|
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||||
@@ -79,6 +106,64 @@ class Principal:
|
|||||||
return scopes_grant_compatible(self.scopes, scope)
|
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):
|
class ReportingServiceTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
@@ -299,6 +384,53 @@ class ReportingServiceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(raised.exception.execution_id)
|
self.assertTrue(raised.exception.execution_id)
|
||||||
|
|
||||||
|
def test_dataflow_run_can_be_exported_as_formula_safe_csv(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=ReportQuery(mode="detail", dimensions=["note"]),
|
||||||
|
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"])
|
||||||
|
content, content_type, filename = export_execution(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
execution_id=str(result["execution_id"]),
|
||||||
|
format="csv",
|
||||||
|
)
|
||||||
|
self.assertEqual("text/csv; charset=utf-8", content_type)
|
||||||
|
self.assertTrue(filename.endswith(".csv"))
|
||||||
|
self.assertIn("'=cmd", content.decode("utf-8-sig"))
|
||||||
|
|
||||||
def test_restricted_access_and_service_scope_guards(self) -> None:
|
def test_restricted_access_and_service_scope_guards(self) -> None:
|
||||||
self._create_report_graph(
|
self._create_report_graph(
|
||||||
report_access={
|
report_access={
|
||||||
@@ -404,6 +536,216 @@ class ReportingServiceTests(unittest.TestCase):
|
|||||||
accepted_approximations=[],
|
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(
|
def _create_report_graph(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/reporting-webui",
|
"name": "@govoplan/reporting-webui",
|
||||||
"version": "0.1.14",
|
"version": "0.1.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,8 +13,11 @@
|
|||||||
},
|
},
|
||||||
"./styles/reporting.css": "./src/styles/reporting.css"
|
"./styles/reporting.css": "./src/styles/reporting.css"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.14",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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.match(page, /id: "drill",\s*header: "Detail",\s*columnType: "actions",\s*sticky: "end"/, "Result drill-down controls use the shared pinned action-column contract");
|
||||||
|
assert.match(page, /<TableActionGroup actions=\{\[\{\s*id: "drill"/, "Drill-down renders the shared measurable action surface");
|
||||||
|
assert.ok(page.includes("AccessExplanation"), "Policy-hidden fields, rows, and actions are explained");
|
||||||
|
assert.ok(page.includes("PublishDialog"), "Publication targets use the shared dialog surface");
|
||||||
|
assert.ok(provider.includes("disabledReason={runDisabledReason}"), "Governed report blockers remain keyboard-explainable");
|
||||||
|
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.");
|
||||||
@@ -103,9 +103,69 @@ export type ReportExecution = {
|
|||||||
kind: string;
|
kind: string;
|
||||||
requested_kind?: string;
|
requested_kind?: string;
|
||||||
category?: string | null;
|
category?: string | null;
|
||||||
|
series?: string | null;
|
||||||
measures?: string[];
|
measures?: string[];
|
||||||
|
options?: Record<string, unknown>;
|
||||||
fallback_reason?: string | null;
|
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 = {
|
export type ReportingSavedView = {
|
||||||
@@ -307,6 +367,26 @@ export function listExecutions(
|
|||||||
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(reportId)}/executions?limit=30`, { signal });
|
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(
|
export function listSavedViews(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
reportId: string,
|
reportId: string,
|
||||||
@@ -364,6 +444,72 @@ export function createIntervalSchedule(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
export async function downloadExecution(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
executionId: string,
|
executionId: string,
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { Download, FileJson, Play, ShieldCheck } from "lucide-react";
|
import { Download, FileJson, Play, ShieldCheck } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { ContentGrid,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
IconButton,
|
IconButton,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
hasScope,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo
|
type AuthInfo
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
@@ -32,6 +36,7 @@ export function ProviderReportWorkspace({ settings, auth, report }: {
|
|||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const tenant = auth.active_tenant ?? auth.tenant;
|
const tenant = auth.active_tenant ?? auth.tenant;
|
||||||
|
const canRun = hasScope(auth, "reporting:report:run");
|
||||||
const audienceScope = useMemo(() => ({
|
const audienceScope = useMemo(() => ({
|
||||||
scope_type: "tenant",
|
scope_type: "tenant",
|
||||||
scope_id: tenant.id,
|
scope_id: tenant.id,
|
||||||
@@ -101,6 +106,15 @@ export function ProviderReportWorkspace({ settings, auth, report }: {
|
|||||||
const missingRequired = report.parameters.some((item) =>
|
const missingRequired = report.parameters.some((item) =>
|
||||||
item.required && (parameters[item.key] === undefined || parameters[item.key] === "")
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="reporting-result-header">
|
<header className="reporting-result-header">
|
||||||
@@ -113,7 +127,8 @@ export function ProviderReportWorkspace({ settings, auth, report }: {
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => void run()}
|
onClick={() => void run()}
|
||||||
disabled={!report.available || running || missingRequired || !purpose.trim()}>
|
disabled={running || Boolean(runDisabledReason)}
|
||||||
|
disabledReason={runDisabledReason}>
|
||||||
<Play size={16} aria-hidden="true" /> {running ? "Running" : "Run"}
|
<Play size={16} aria-hidden="true" /> {running ? "Running" : "Run"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,20 +177,25 @@ export function ProviderReportWorkspace({ settings, auth, report }: {
|
|||||||
</div>
|
</div>
|
||||||
{execution ?
|
{execution ?
|
||||||
<>
|
<>
|
||||||
<div className="reporting-output-toolbar">
|
<WorkspaceActionBar
|
||||||
<span>Generated {formatDateTime(execution.generated_at)}</span>
|
scope="detail-pane"
|
||||||
|
variant="detail"
|
||||||
|
className="reporting-output-toolbar"
|
||||||
|
contextActions={<span>Generated {formatDateTime(execution.generated_at)}</span>}
|
||||||
|
primaryActions={<>
|
||||||
{report.export_formats.includes("csv") &&
|
{report.export_formats.includes("csv") &&
|
||||||
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void download("csv")} />
|
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void download("csv")} />
|
||||||
}
|
}
|
||||||
{report.export_formats.includes("json") &&
|
{report.export_formats.includes("json") &&
|
||||||
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void download("json")} />
|
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void download("json")} />
|
||||||
}
|
}
|
||||||
</div>
|
</>}
|
||||||
|
/>
|
||||||
<div className="reporting-provider-output">
|
<div className="reporting-provider-output">
|
||||||
<ProviderResult execution={execution} />
|
<ProviderResult execution={execution} />
|
||||||
</div>
|
</div>
|
||||||
</> :
|
</> :
|
||||||
<div className="reporting-empty">Select the parameters and run this governed report.</div>
|
<StatePanel size="fill" description="Select the parameters and run this governed report." />
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -215,33 +235,33 @@ function ProviderResult({ execution }: { execution: ProviderReportExecution }) {
|
|||||||
return (
|
return (
|
||||||
<Card title={group} key={group}>
|
<Card title={group} key={group}>
|
||||||
{metrics.length > 0 &&
|
{metrics.length > 0 &&
|
||||||
<div className="dashboard-grid reporting-provider-metrics">
|
<ContentGrid columns={2} collapseAt="workspace" className="reporting-provider-metrics">
|
||||||
{metrics.map((field) =>
|
{metrics.map((field) =>
|
||||||
<MetricCard key={field.path} label={field.label} value={displayValue(pathValue(execution.result, field.path))} />
|
<MetricCard key={field.path} label={field.label} value={displayValue(pathValue(execution.result, field.path))} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</ContentGrid>
|
||||||
}
|
}
|
||||||
{details.length > 0 &&
|
{details.length > 0 &&
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
{details.map((field) =>
|
{details.map((field) =>
|
||||||
<div key={field.path}>
|
<div key={field.path}>
|
||||||
<dt>{field.label}</dt>
|
<dt>{field.label}</dt>
|
||||||
<dd>{displayValue(pathValue(execution.result, field.path), field)}</dd>
|
<dd>{displayValue(pathValue(execution.result, field.path), field)}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
}
|
}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Card title="Provenance">
|
<Card title="Provenance">
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
<div><dt>Purpose</dt><dd>{execution.purpose}</dd></div>
|
<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>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>Source revisions</dt><dd>{execution.source_revisions.length}</dd></div>
|
||||||
<div><dt>Privacy transforms</dt><dd>{execution.privacy_transforms.join(", ")}</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>
|
<div><dt>Expires</dt><dd>{execution.expires_at ? formatDateTime(execution.expires_at) : "Policy managed"}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
FileJson,
|
FileJson,
|
||||||
|
FolderOutput,
|
||||||
History,
|
History,
|
||||||
Play,
|
Play,
|
||||||
RefreshCw,
|
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
@@ -17,37 +18,58 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type FormEvent
|
type FormEvent
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import { FormGrid,
|
||||||
Button,
|
Button,
|
||||||
DataGrid,
|
DataGrid,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
FilterBar,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
|
StatePanel,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
ToggleSwitch,
|
||||||
hasScope,
|
hasScope,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
type DataGridColumn,
|
type DataGridColumn,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
createIntervalSchedule,
|
createIntervalSchedule,
|
||||||
|
createDrillContext,
|
||||||
downloadExecution,
|
downloadExecution,
|
||||||
getDefinition,
|
getDefinition,
|
||||||
listDefinitions,
|
listDefinitions,
|
||||||
listExecutions,
|
listExecutions,
|
||||||
|
listPublicationTargets,
|
||||||
|
listPublications,
|
||||||
listProviderReports,
|
listProviderReports,
|
||||||
listSavedViews,
|
listSavedViews,
|
||||||
|
listSchedules,
|
||||||
|
publishExecution,
|
||||||
reportPayload,
|
reportPayload,
|
||||||
runReport,
|
runReport,
|
||||||
saveView,
|
saveView,
|
||||||
semanticPayload,
|
semanticPayload,
|
||||||
|
resolveDrillContext,
|
||||||
|
updateSchedule,
|
||||||
type ReportExecution,
|
type ReportExecution,
|
||||||
|
type ReportingDrillResult,
|
||||||
type ReportingDefinition,
|
type ReportingDefinition,
|
||||||
type ReportingQuery,
|
type ReportingQuery,
|
||||||
type ReportingQueryMode,
|
type ReportingQueryMode,
|
||||||
|
type ReportingPublication,
|
||||||
|
type ReportingPublicationTarget,
|
||||||
type ReportingSavedView,
|
type ReportingSavedView,
|
||||||
|
type ReportingSchedule,
|
||||||
type ProviderReportDescriptor,
|
type ProviderReportDescriptor,
|
||||||
type SemanticModelPayload
|
type SemanticModelPayload
|
||||||
} from "../../api/reporting";
|
} from "../../api/reporting";
|
||||||
@@ -72,14 +94,22 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
const [execution, setExecution] = useState<ReportExecution | null>(null);
|
const [execution, setExecution] = useState<ReportExecution | null>(null);
|
||||||
const [history, setHistory] = useState<ReportExecution[]>([]);
|
const [history, setHistory] = useState<ReportExecution[]>([]);
|
||||||
const [savedViews, setSavedViews] = useState<ReportingSavedView[]>([]);
|
const [savedViews, setSavedViews] = useState<ReportingSavedView[]>([]);
|
||||||
|
const [schedules, setSchedules] = useState<ReportingSchedule[]>([]);
|
||||||
|
const [publicationTargets, setPublicationTargets] = useState<ReportingPublicationTarget[]>([]);
|
||||||
|
const [publications, setPublications] = useState<ReportingPublication[]>([]);
|
||||||
const [outputMode, setOutputMode] = useState<OutputMode>("visual");
|
const [outputMode, setOutputMode] = useState<OutputMode>("visual");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
||||||
|
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
|
||||||
|
const [drillDialogOpen, setDrillDialogOpen] = useState(false);
|
||||||
|
const [drillResult, setDrillResult] = useState<ReportingDrillResult | null>(null);
|
||||||
|
const [drilling, setDrilling] = useState(false);
|
||||||
const canRun = hasScope(auth, "reporting:report:run");
|
const canRun = hasScope(auth, "reporting:report:run");
|
||||||
const canSchedule = hasScope(auth, "reporting:schedule:write");
|
const canSchedule = hasScope(auth, "reporting:schedule:write");
|
||||||
|
const canPublish = hasScope(auth, "reporting:report:publish");
|
||||||
|
|
||||||
const selected = useMemo(
|
const selected = useMemo(
|
||||||
() => reports.find((item) => item.definition_id === selectedId) ?? null,
|
() => reports.find((item) => item.definition_id === selectedId) ?? null,
|
||||||
@@ -101,15 +131,17 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
query: submittedSearch,
|
query: submittedSearch,
|
||||||
limit: 200
|
limit: 200
|
||||||
}, signal),
|
}, signal),
|
||||||
listProviderReports(settings, signal)
|
listProviderReports(settings, signal),
|
||||||
|
canPublish ? listPublicationTargets(settings, signal) : Promise.resolve({ targets: [] })
|
||||||
]).
|
]).
|
||||||
then(([result, providerResult]) => {
|
then(([result, providerResult, targetResult]) => {
|
||||||
const providerRows = providerResult.reports.filter((item) => {
|
const providerRows = providerResult.reports.filter((item) => {
|
||||||
const query = submittedSearch.toLocaleLowerCase();
|
const query = submittedSearch.toLocaleLowerCase();
|
||||||
return !query || `${item.title} ${item.summary} ${item.provider_id}`.toLocaleLowerCase().includes(query);
|
return !query || `${item.title} ${item.summary} ${item.provider_id}`.toLocaleLowerCase().includes(query);
|
||||||
});
|
});
|
||||||
setReports(result.definitions);
|
setReports(result.definitions);
|
||||||
setProviderReports(providerRows);
|
setProviderReports(providerRows);
|
||||||
|
setPublicationTargets(targetResult.targets);
|
||||||
const currentSemanticAvailable = result.definitions.some((item) => item.definition_id === selectedId);
|
const currentSemanticAvailable = result.definitions.some((item) => item.definition_id === selectedId);
|
||||||
const currentProviderAvailable = providerRows.some((item) => `${item.provider_id}:${item.report_id}` === selectedProviderKey);
|
const currentProviderAvailable = providerRows.some((item) => `${item.provider_id}:${item.report_id}` === selectedProviderKey);
|
||||||
if (!currentSemanticAvailable && !currentProviderAvailable) {
|
if (!currentSemanticAvailable && !currentProviderAvailable) {
|
||||||
@@ -140,6 +172,7 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
setExecution(null);
|
setExecution(null);
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
setSavedViews([]);
|
setSavedViews([]);
|
||||||
|
setSchedules([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -149,12 +182,14 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
getDefinition(settings, "semantic_model", report.semantic_model_id, report.semantic_model_revision, controller.signal),
|
getDefinition(settings, "semantic_model", report.semantic_model_id, report.semantic_model_revision, controller.signal),
|
||||||
canRun ? listExecutions(settings, selected.definition_id, controller.signal) : Promise.resolve({ executions: [] }),
|
canRun ? listExecutions(settings, selected.definition_id, controller.signal) : Promise.resolve({ executions: [] }),
|
||||||
listSavedViews(settings, selected.definition_id, controller.signal)
|
listSavedViews(settings, selected.definition_id, controller.signal),
|
||||||
|
canSchedule ? listSchedules(settings, selected.definition_id, controller.signal) : Promise.resolve({ schedules: [] })
|
||||||
]).
|
]).
|
||||||
then(([semanticDefinition, executions, views]) => {
|
then(([semanticDefinition, executions, views, scheduleResult]) => {
|
||||||
setSemantic(semanticPayload(semanticDefinition));
|
setSemantic(semanticPayload(semanticDefinition));
|
||||||
setHistory(executions.executions);
|
setHistory(executions.executions);
|
||||||
setSavedViews(views.views);
|
setSavedViews(views.views);
|
||||||
|
setSchedules(scheduleResult.schedules);
|
||||||
setExecution(executions.executions.find((item) => item.status === "succeeded") ?? null);
|
setExecution(executions.executions.find((item) => item.status === "succeeded") ?? null);
|
||||||
}).
|
}).
|
||||||
catch((reason) => {
|
catch((reason) => {
|
||||||
@@ -163,6 +198,20 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [settings, selectedId]);
|
}, [settings, selectedId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!execution || !canPublish) {
|
||||||
|
setPublications([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
void listPublications(settings, execution.execution_id, controller.signal).
|
||||||
|
then((result) => setPublications(result.publications)).
|
||||||
|
catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") setError(message(reason));
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [settings, execution?.execution_id, canPublish]);
|
||||||
|
|
||||||
function submitSearch(event: FormEvent) {
|
function submitSearch(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSubmittedSearch(search.trim());
|
setSubmittedSearch(search.trim());
|
||||||
@@ -188,11 +237,34 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
if (view.state.query) setQuery(normalizeQuery(view.state.query));
|
if (view.state.query) setQuery(normalizeQuery(view.state.query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function drill(row: Record<string, unknown>) {
|
||||||
|
if (!execution) return;
|
||||||
|
setDrillDialogOpen(true);
|
||||||
|
setDrillResult(null);
|
||||||
|
setDrilling(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const context = await createDrillContext(settings, execution.execution_id, row);
|
||||||
|
setDrillResult(await resolveDrillContext(settings, context.token));
|
||||||
|
} catch (reason) {
|
||||||
|
setError(message(reason));
|
||||||
|
setDrillDialogOpen(false);
|
||||||
|
} finally {
|
||||||
|
setDrilling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="reporting-page">
|
<main className="reporting-page">
|
||||||
<div className="reporting-shell">
|
<WorkspaceFrame className="reporting-shell" label="Reporting workspace" interfaceId="reporting.workspace" helpContextId="reporting.page.workspace" helpModuleId="reporting">
|
||||||
<div className="reporting-toolbar">
|
<WorkspaceActionBar
|
||||||
<form className="reporting-search" onSubmit={submitSearch}>
|
scope="workspace"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void reload(), loading, label: "Reload reports" }}
|
||||||
|
className="reporting-toolbar"
|
||||||
|
contextActions={<>
|
||||||
|
<FilterBar as="form" surface="control" wrap="never" width="compact" className="reporting-search" onSubmit={submitSearch}>
|
||||||
<Search size={17} aria-hidden="true" />
|
<Search size={17} aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
@@ -200,15 +272,14 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
aria-label="Search reports"
|
aria-label="Search reports"
|
||||||
placeholder="Search reports"
|
placeholder="Search reports"
|
||||||
/>
|
/>
|
||||||
</form>
|
</FilterBar>
|
||||||
<span className="reporting-count">{reports.length + providerReports.length} reports</span>
|
<span className="reporting-count">{reports.length + providerReports.length} reports</span>
|
||||||
<IconButton
|
</>}
|
||||||
label="Reload reports"
|
helpAction={<DocumentationHelpLink
|
||||||
icon={<RefreshCw size={17} />}
|
reference={{ topicId: "reporting.governed-bi", documentationType: "user" }}
|
||||||
variant="ghost"
|
label="Open reporting documentation"
|
||||||
onClick={() => void reload()}
|
/>}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{error &&
|
{error &&
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
{error}
|
{error}
|
||||||
@@ -217,38 +288,32 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
<div className="reporting-workspace">
|
<div className="reporting-workspace">
|
||||||
<PageScrollViewport className="reporting-catalogue">
|
<PageScrollViewport className="reporting-catalogue">
|
||||||
{loading && <LoadingIndicator label="Loading reports" />}
|
{loading && <LoadingIndicator label="Loading reports" />}
|
||||||
{!loading && reports.length + providerReports.length === 0 && <div className="reporting-empty">No active reports are available.</div>}
|
{!loading && reports.length + providerReports.length === 0 && <StatePanel size="compact" description="No active reports are available." />}
|
||||||
<div className="reporting-report-list" role="list">
|
<SelectionList variant="navigation" label="Reports">
|
||||||
{providerReports.length > 0 && <div className="reporting-list-heading">Module reports</div>}
|
{providerReports.length > 0 && <div className="reporting-list-heading" role="presentation">Module reports</div>}
|
||||||
{providerReports.map((item) => {
|
{providerReports.map((item) => {
|
||||||
const key = `${item.provider_id}:${item.report_id}`;
|
const key = `${item.provider_id}:${item.report_id}`;
|
||||||
return (
|
return (
|
||||||
<button
|
<SelectionListItem
|
||||||
type="button"
|
|
||||||
role="listitem"
|
|
||||||
key={key}
|
key={key}
|
||||||
className={`reporting-report-row${selectedProviderKey === key ? " is-selected" : ""}`}
|
selected={selectedProviderKey === key}
|
||||||
onClick={() => { setSelectedProviderKey(key); setSelectedId(""); }}>
|
onClick={() => { setSelectedProviderKey(key); setSelectedId(""); }}>
|
||||||
<BarChart3 size={17} aria-hidden="true" />
|
<SelectionListItemContent leading={<BarChart3 size={17} />} title={item.title} description={`${item.provider_id} · ${item.revision}`} />
|
||||||
<span><strong>{item.title}</strong><small>{item.provider_id} · {item.revision}</small></span>
|
|
||||||
<StatusBadge status={item.available ? "active" : "locked"} label={item.available ? "Available" : "Restricted"} />
|
<StatusBadge status={item.available ? "active" : "locked"} label={item.available ? "Available" : "Restricted"} />
|
||||||
</button>
|
</SelectionListItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{reports.length > 0 && <div className="reporting-list-heading">Semantic reports</div>}
|
{reports.length > 0 && <div className="reporting-list-heading" role="presentation">Semantic reports</div>}
|
||||||
{reports.map((item) =>
|
{reports.map((item) =>
|
||||||
<button
|
<SelectionListItem
|
||||||
type="button"
|
|
||||||
role="listitem"
|
|
||||||
key={item.definition_id}
|
key={item.definition_id}
|
||||||
className={`reporting-report-row${selectedId === item.definition_id ? " is-selected" : ""}`}
|
selected={selectedId === item.definition_id}
|
||||||
onClick={() => { setSelectedId(item.definition_id); setSelectedProviderKey(""); }}>
|
onClick={() => { setSelectedId(item.definition_id); setSelectedProviderKey(""); }}>
|
||||||
<BarChart3 size={17} aria-hidden="true" />
|
<SelectionListItemContent leading={<BarChart3 size={17} />} title={item.name} description={`${item.definition_key} · r${item.revision}`} />
|
||||||
<span><strong>{item.name}</strong><small>{item.definition_key} · r{item.revision}</small></span>
|
|
||||||
<StatusBadge status="active" label="Active" />
|
<StatusBadge status="active" label="Active" />
|
||||||
</button>
|
</SelectionListItem>
|
||||||
)}
|
)}
|
||||||
</div>
|
</SelectionList>
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
<section className="reporting-result-region">
|
<section className="reporting-result-region">
|
||||||
{selectedProvider ?
|
{selectedProvider ?
|
||||||
@@ -265,7 +330,11 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
<IconButton label="Schedule report" icon={<CalendarClock size={17} />} variant="ghost" onClick={() => setScheduleDialogOpen(true)} />
|
<IconButton label="Schedule report" icon={<CalendarClock size={17} />} variant="ghost" onClick={() => setScheduleDialogOpen(true)} />
|
||||||
}
|
}
|
||||||
<IconButton label="Save current view" icon={<Save size={17} />} variant="ghost" onClick={() => setSaveDialogOpen(true)} />
|
<IconButton label="Save current view" icon={<Save size={17} />} variant="ghost" onClick={() => setSaveDialogOpen(true)} />
|
||||||
<Button variant="primary" onClick={() => void execute()} disabled={!canRun || running}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void execute()}
|
||||||
|
disabled={!canRun || running}
|
||||||
|
disabledReason={!canRun ? "Report run permission is required." : undefined}>
|
||||||
<Play size={16} aria-hidden="true" /> {running ? "Running" : "Run"}
|
<Play size={16} aria-hidden="true" /> {running ? "Running" : "Run"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,8 +347,11 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
onQueryChange={setQuery}
|
onQueryChange={setQuery}
|
||||||
onParametersChange={setParameters}
|
onParametersChange={setParameters}
|
||||||
/>
|
/>
|
||||||
<div className="reporting-output-toolbar">
|
<WorkspaceActionBar
|
||||||
<SegmentedControl
|
scope="detail-pane"
|
||||||
|
variant="detail"
|
||||||
|
className="reporting-output-toolbar"
|
||||||
|
contextActions={<SegmentedControl
|
||||||
ariaLabel="Report output"
|
ariaLabel="Report output"
|
||||||
value={outputMode}
|
value={outputMode}
|
||||||
onChange={setOutputMode}
|
onChange={setOutputMode}
|
||||||
@@ -287,22 +359,25 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
{ id: "visual", label: <><BarChart3 size={15} /> Visual</> },
|
{ id: "visual", label: <><BarChart3 size={15} /> Visual</> },
|
||||||
{ id: "table", label: <><Table2 size={15} /> Table</> }
|
{ id: "table", label: <><Table2 size={15} /> Table</> }
|
||||||
]}
|
]}
|
||||||
/>
|
/>}
|
||||||
{execution &&
|
primaryActions={execution ?
|
||||||
<>
|
<>
|
||||||
<span>{execution.total_rows} rows{execution.truncated ? " (truncated)" : ""}</span>
|
<span>{execution.total_rows} rows{execution.truncated ? " (truncated)" : ""}</span>
|
||||||
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "csv").catch((reason) => setError(message(reason)))} />
|
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "csv").catch((reason) => setError(message(reason)))} />
|
||||||
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "json").catch((reason) => setError(message(reason)))} />
|
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "json").catch((reason) => setError(message(reason)))} />
|
||||||
</>
|
{canPublish &&
|
||||||
|
<IconButton label="Publish report" helpContextId="reporting.publications" helpModuleId="reporting" icon={<FolderOutput size={17} />} variant="ghost" onClick={() => setPublishDialogOpen(true)} />
|
||||||
}
|
}
|
||||||
</div>
|
</>
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
<div className="reporting-output">
|
<div className="reporting-output">
|
||||||
{!execution && <div className="reporting-empty">Run the report or select a previous execution.</div>}
|
{!execution && <StatePanel size="fill" description="Run the report or select a previous execution." />}
|
||||||
{execution && outputMode === "visual" && <ReportVisual execution={execution} />}
|
{execution && outputMode === "visual" && <ReportVisual execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
|
||||||
{execution && outputMode === "table" && <ReportTable execution={execution} />}
|
{execution && outputMode === "table" && <ReportTable execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
|
||||||
</div>
|
</div>
|
||||||
</> :
|
</> :
|
||||||
<div className="reporting-empty">Select a report.</div>
|
<StatePanel size="fill" title="Reports" description="Select a report." />
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
<PageScrollViewport className="reporting-inspector">
|
<PageScrollViewport className="reporting-inspector">
|
||||||
@@ -313,12 +388,22 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
execution={execution}
|
execution={execution}
|
||||||
history={history}
|
history={history}
|
||||||
savedViews={savedViews}
|
savedViews={savedViews}
|
||||||
|
schedules={schedules}
|
||||||
|
publications={publications}
|
||||||
onSelectExecution={setExecution}
|
onSelectExecution={setExecution}
|
||||||
onApplySavedView={applySavedView}
|
onApplySavedView={applySavedView}
|
||||||
|
onScheduleEnabledChange={async (schedule, enabled) => {
|
||||||
|
try {
|
||||||
|
const updated = await updateSchedule(settings, schedule, { enabled });
|
||||||
|
setSchedules((current) => current.map((item) => item.schedule_id === updated.schedule_id ? updated : item));
|
||||||
|
} catch (reason) {
|
||||||
|
setError(message(reason));
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>}
|
/>}
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</WorkspaceFrame>
|
||||||
<SaveViewDialog
|
<SaveViewDialog
|
||||||
open={saveDialogOpen}
|
open={saveDialogOpen}
|
||||||
onClose={() => setSaveDialogOpen(false)}
|
onClose={() => setSaveDialogOpen(false)}
|
||||||
@@ -334,10 +419,28 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
onClose={() => setScheduleDialogOpen(false)}
|
onClose={() => setScheduleDialogOpen(false)}
|
||||||
onSave={async (name, seconds) => {
|
onSave={async (name, seconds) => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
await createIntervalSchedule(settings, selected, name, seconds, query, parameters);
|
const created = await createIntervalSchedule(settings, selected, name, seconds, query, parameters) as ReportingSchedule;
|
||||||
|
setSchedules((current) => [...current, created].sort((left, right) => left.name.localeCompare(right.name)));
|
||||||
setScheduleDialogOpen(false);
|
setScheduleDialogOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<PublishDialog
|
||||||
|
open={publishDialogOpen}
|
||||||
|
targets={publicationTargets}
|
||||||
|
onClose={() => setPublishDialogOpen(false)}
|
||||||
|
onPublish={async (request) => {
|
||||||
|
if (!execution) return;
|
||||||
|
const publication = await publishExecution(settings, execution.execution_id, request);
|
||||||
|
setPublications((current) => [publication, ...current]);
|
||||||
|
setPublishDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DrillDialog
|
||||||
|
open={drillDialogOpen}
|
||||||
|
loading={drilling}
|
||||||
|
result={drillResult}
|
||||||
|
onClose={() => setDrillDialogOpen(false)}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -437,11 +540,11 @@ function QueryControls({ query, semantic, parameters, parameterValues, onQueryCh
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportTable({ execution }: { execution: ReportExecution }) {
|
function ReportTable({ execution, onDrill }: { execution: ReportExecution; onDrill?: (row: Record<string, unknown>) => void }) {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
useEffect(() => setPage(0), [execution.execution_id]);
|
useEffect(() => setPage(0), [execution.execution_id]);
|
||||||
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() =>
|
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => {
|
||||||
execution.schema.map((field) => ({
|
const result: DataGridColumn<Record<string, unknown>>[] = execution.schema.map((field) => ({
|
||||||
id: field.name,
|
id: field.name,
|
||||||
header: humanize(field.name),
|
header: humanize(field.name),
|
||||||
width: "1fr",
|
width: "1fr",
|
||||||
@@ -452,7 +555,30 @@ function ReportTable({ execution }: { execution: ReportExecution }) {
|
|||||||
filterType: field.type === "integer" || field.type === "number" ? field.type : "text",
|
filterType: field.type === "integer" || field.type === "number" ? field.type : "text",
|
||||||
value: (row) => row[field.name],
|
value: (row) => row[field.name],
|
||||||
render: (row) => formatValue(row[field.name])
|
render: (row) => formatValue(row[field.name])
|
||||||
})), [execution]);
|
} satisfies DataGridColumn<Record<string, unknown>>));
|
||||||
|
if (onDrill) {
|
||||||
|
result.push({
|
||||||
|
id: "drill",
|
||||||
|
header: "Detail",
|
||||||
|
columnType: "actions",
|
||||||
|
sticky: "end",
|
||||||
|
resizable: false,
|
||||||
|
align: "right",
|
||||||
|
width: 74,
|
||||||
|
minWidth: 74,
|
||||||
|
maxWidth: 74,
|
||||||
|
render: (row) => (
|
||||||
|
<TableActionGroup actions={[{
|
||||||
|
id: "drill",
|
||||||
|
label: "Show authorized contributing rows",
|
||||||
|
icon: <ChevronRight size={16} aria-hidden="true" />,
|
||||||
|
onClick: () => onDrill(row)
|
||||||
|
}]} />
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [execution, onDrill]);
|
||||||
return (
|
return (
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`reporting-execution-${execution.execution_id}`}
|
id={`reporting-execution-${execution.execution_id}`}
|
||||||
@@ -467,20 +593,80 @@ function ReportTable({ execution }: { execution: ReportExecution }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportVisual({ execution }: { execution: ReportExecution }) {
|
function ReportVisual({ execution, onDrill }: { execution: ReportExecution; onDrill?: (row: Record<string, unknown>) => void }) {
|
||||||
const visual = execution.visualization;
|
const visual = execution.visualization;
|
||||||
if (!visual || visual.kind === "table" || !visual.category || !visual.measures?.length) {
|
const needsCategory = visual?.kind !== "metric";
|
||||||
|
if (!visual || visual.kind === "table" || !visual.measures?.length || (needsCategory && !visual.category)) {
|
||||||
return (
|
return (
|
||||||
<div className="reporting-visual-fallback">
|
<div className="reporting-visual-fallback">
|
||||||
{visual?.fallback_reason && <DismissibleAlert tone="info" dismissible={false}>{visual.fallback_reason}</DismissibleAlert>}
|
{visual?.fallback_reason && <DismissibleAlert tone="info" dismissible={false}>{visual.fallback_reason}</DismissibleAlert>}
|
||||||
<ReportTable execution={execution} />
|
<ReportTable execution={execution} onDrill={onDrill} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const measure = visual.measures[0];
|
const measure = visual.measures[0];
|
||||||
const values = execution.rows.map((row) => Number(row[measure] ?? 0));
|
const values = execution.rows.map((row) => Number(row[measure] ?? 0));
|
||||||
const maximum = Math.max(...values.map((value) => Math.abs(value)), 1);
|
const maximum = Math.max(...values.map((value) => Math.abs(value)), 1);
|
||||||
|
if (visual.kind === "metric") {
|
||||||
return (
|
return (
|
||||||
|
<div className="reporting-metric-grid">
|
||||||
|
{visual.measures.map((key) =>
|
||||||
|
<div className="reporting-metric" key={key}>
|
||||||
|
<span>{humanize(key)}</span>
|
||||||
|
<strong>{formatValue(execution.rows[0]?.[key])}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (visual.kind === "column") {
|
||||||
|
return (
|
||||||
|
<div className="reporting-chart-stack">
|
||||||
|
<div className="reporting-column-chart" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`}>
|
||||||
|
{execution.rows.slice(0, 50).map((row, index) =>
|
||||||
|
<div className="reporting-column" key={`${String(row[visual.category ?? ""])}:${index}`}>
|
||||||
|
<strong>{formatValue(row[measure])}</strong>
|
||||||
|
<i style={{ height: `${Math.max(2, Math.abs(values[index]) / maximum * 100)}%` }} />
|
||||||
|
<span>{formatValue(row[visual.category ?? ""])}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (visual.kind === "line" || visual.kind === "area") {
|
||||||
|
const points = chartPoints(values.slice(0, 50), 700, 250);
|
||||||
|
return (
|
||||||
|
<div className="reporting-line-chart">
|
||||||
|
<svg viewBox="0 0 700 250" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`} preserveAspectRatio="none">
|
||||||
|
{visual.kind === "area" && <polygon points={`0,250 ${points} 700,250`} className="reporting-chart-area" />}
|
||||||
|
<polyline points={points} className="reporting-chart-line" />
|
||||||
|
</svg>
|
||||||
|
<div className="reporting-chart-labels">
|
||||||
|
{execution.rows.slice(0, 50).map((row, index) => <span key={index}>{formatValue(row[visual.category ?? ""])}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (visual.kind === "pie" || visual.kind === "donut") {
|
||||||
|
const positive = values.map((value) => Math.max(0, value));
|
||||||
|
const total = positive.reduce((sum, value) => sum + value, 0) || 1;
|
||||||
|
const stops = pieStops(positive, total);
|
||||||
|
return (
|
||||||
|
<div className="reporting-pie-layout">
|
||||||
|
<div className={`reporting-pie${visual.kind === "donut" ? " is-donut" : ""}`} style={{ background: `conic-gradient(${stops})` }} role="img" aria-label={`${humanize(measure)} distribution`} />
|
||||||
|
<ol>
|
||||||
|
{execution.rows.slice(0, 12).map((row, index) => <li key={index}><i className={`reporting-swatch reporting-swatch-${index % 8}`} /><span>{formatValue(row[visual.category ?? ""])}</span><strong>{formatValue(row[measure])}</strong></li>)}
|
||||||
|
</ol>
|
||||||
|
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="reporting-chart-stack">
|
||||||
<div className="reporting-bar-chart" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`}>
|
<div className="reporting-bar-chart" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`}>
|
||||||
{execution.rows.map((row, index) =>
|
{execution.rows.map((row, index) =>
|
||||||
<div className="reporting-bar-row" key={`${String(row[visual.category ?? ""])}:${index}`}>
|
<div className="reporting-bar-row" key={`${String(row[visual.category ?? ""])}:${index}`}>
|
||||||
@@ -489,18 +675,188 @@ function ReportVisual({ execution }: { execution: ReportExecution }) {
|
|||||||
<strong>{formatValue(row[measure])}</strong>
|
<strong>{formatValue(row[measure])}</strong>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="reporting-chart-table"><ReportTable execution={execution} /></div>
|
</div>
|
||||||
|
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Inspector({ selected, execution, history, savedViews, onSelectExecution, onApplySavedView }: {
|
function AccessExplanation({ execution }: { execution: ReportExecution }) {
|
||||||
|
const provenance = objectValue(execution.provenance);
|
||||||
|
const explanation = objectValue(provenance.access_explanation);
|
||||||
|
const hiddenDimensions = stringValues(explanation.hidden_dimensions);
|
||||||
|
const hiddenMeasures = stringValues(explanation.hidden_measures);
|
||||||
|
const disabledActions = stringValues(explanation.disabled_actions);
|
||||||
|
const hiddenRows = Number(explanation.hidden_rows ?? 0);
|
||||||
|
const reasons = objectValue(explanation.reasons);
|
||||||
|
if (!hiddenDimensions.length && !hiddenMeasures.length && !disabledActions.length && hiddenRows <= 0) {
|
||||||
|
return <DismissibleAlert tone="info" dismissible={false} compact>No report fields, rows, or actions were hidden by effective policy.</DismissibleAlert>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="reporting-access-explanation">
|
||||||
|
<strong>Effective access</strong>
|
||||||
|
{hiddenDimensions.length > 0 && <span>Hidden dimensions: {hiddenDimensions.join(", ")}</span>}
|
||||||
|
{hiddenMeasures.length > 0 && <span>Hidden measures: {hiddenMeasures.join(", ")}</span>}
|
||||||
|
{hiddenRows > 0 && <span>{hiddenRows} source rows were removed before planning.</span>}
|
||||||
|
{disabledActions.map((action) => <span key={action}>{String(reasons[action] ?? `The ${action} action is disabled by policy.`)}</span>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublishDialog({ open, targets, onClose, onPublish }: {
|
||||||
|
open: boolean;
|
||||||
|
targets: ReportingPublicationTarget[];
|
||||||
|
onClose: () => void;
|
||||||
|
onPublish: (request: { target_capability: string; target_ref?: string | null; format: string; options: Record<string, unknown> }) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const firstAvailable = targets.find((item) => item.available) ?? targets[0];
|
||||||
|
const [targetCapability, setTargetCapability] = useState(firstAvailable?.capability ?? "");
|
||||||
|
const [targetRef, setTargetRef] = useState("");
|
||||||
|
const [format, setFormat] = useState(firstAvailable?.formats[0] ?? "csv");
|
||||||
|
const [filename, setFilename] = useState("");
|
||||||
|
const [mailProfileId, setMailProfileId] = useState("");
|
||||||
|
const [fromAddress, setFromAddress] = useState("");
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dialogError, setDialogError] = useState("");
|
||||||
|
const target = targets.find((item) => item.capability === targetCapability) ?? firstAvailable;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const next = targets.find((item) => item.available) ?? targets[0];
|
||||||
|
setTargetCapability(next?.capability ?? "");
|
||||||
|
setFormat(next?.formats[0] ?? "csv");
|
||||||
|
setTargetRef("");
|
||||||
|
setFilename("");
|
||||||
|
setMailProfileId("");
|
||||||
|
setFromAddress("");
|
||||||
|
setSubject("");
|
||||||
|
setDialogError("");
|
||||||
|
}, [open, targets]);
|
||||||
|
|
||||||
|
const mailTarget = target?.capability.endsWith(".mail") === true;
|
||||||
|
const valid = Boolean(target?.available) && (!target?.target_required || targetRef.trim()) && (!mailTarget || (mailProfileId.trim() && fromAddress.trim()));
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title="Publish report"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={<>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
helpContextId="reporting.publications"
|
||||||
|
helpModuleId="reporting"
|
||||||
|
disabled={!valid || saving}
|
||||||
|
disabledReason={!target?.available ? target?.reason ?? "The selected target is unavailable." : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (!target) return;
|
||||||
|
setSaving(true);
|
||||||
|
setDialogError("");
|
||||||
|
void onPublish({
|
||||||
|
target_capability: target.capability,
|
||||||
|
target_ref: targetRef.trim() || null,
|
||||||
|
format,
|
||||||
|
options: mailTarget ? {
|
||||||
|
mail_profile_id: mailProfileId.trim(),
|
||||||
|
from_address: fromAddress.trim(),
|
||||||
|
subject: subject.trim() || undefined
|
||||||
|
} : { filename: filename.trim() || undefined }
|
||||||
|
}).catch((reason) => setDialogError(message(reason))).finally(() => setSaving(false));
|
||||||
|
}}>
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
|
</>}>
|
||||||
|
{dialogError && <DismissibleAlert tone="danger" resetKey={dialogError}>{dialogError}</DismissibleAlert>}
|
||||||
|
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||||
|
<label className="reporting-dialog-field">
|
||||||
|
<span>Target</span>
|
||||||
|
<select value={targetCapability} onChange={(event) => {
|
||||||
|
const next = targets.find((item) => item.capability === event.target.value);
|
||||||
|
setTargetCapability(event.target.value);
|
||||||
|
setFormat(next?.formats[0] ?? "csv");
|
||||||
|
}}>
|
||||||
|
{targets.map((item) => <option key={item.capability} value={item.capability}>{item.label}{item.available ? "" : " (unavailable)"}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="reporting-dialog-field">
|
||||||
|
<span>Format</span>
|
||||||
|
<select value={format} onChange={(event) => setFormat(event.target.value)} disabled={!target?.available}>
|
||||||
|
{(target?.formats ?? []).map((item) => <option value={item} key={item}>{item.toUpperCase()}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{target && <label className="reporting-dialog-field">
|
||||||
|
<span>{target.target_label}</span>
|
||||||
|
<input value={targetRef} onChange={(event) => setTargetRef(event.target.value)} placeholder={mailTarget ? "recipient@example.org" : "Generated/Reports"} />
|
||||||
|
</label>}
|
||||||
|
{!mailTarget && <label className="reporting-dialog-field"><span>Filename</span><input value={filename} onChange={(event) => setFilename(event.target.value)} placeholder="Generated from report name" /></label>}
|
||||||
|
{mailTarget && <>
|
||||||
|
<label className="reporting-dialog-field"><span>Mail profile ID</span><input value={mailProfileId} onChange={(event) => setMailProfileId(event.target.value)} /></label>
|
||||||
|
<label className="reporting-dialog-field"><span>Sender address</span><input type="email" value={fromAddress} onChange={(event) => setFromAddress(event.target.value)} /></label>
|
||||||
|
<label className="reporting-dialog-field reporting-dialog-span"><span>Subject</span><input value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Generated from report name" /></label>
|
||||||
|
</>}
|
||||||
|
</FormGrid>
|
||||||
|
{target?.reason && <DismissibleAlert tone="warning" dismissible={false}>{target.reason}</DismissibleAlert>}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrillDialog({ open, loading, result, onClose }: {
|
||||||
|
open: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
result: ReportingDrillResult | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
useEffect(() => setPage(0), [result?.drill_context_id]);
|
||||||
|
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() =>
|
||||||
|
(result?.schema ?? []).map((field) => ({
|
||||||
|
id: field.name,
|
||||||
|
header: humanize(field.name),
|
||||||
|
width: "1fr",
|
||||||
|
minWidth: 120,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
filterType: field.type === "number" || field.type === "integer" ? field.type : "text",
|
||||||
|
value: (row) => row[field.name],
|
||||||
|
render: (row) => formatValue(row[field.name])
|
||||||
|
})), [result]);
|
||||||
|
return (
|
||||||
|
<Dialog open={open} title="Authorized contributing rows" onClose={onClose} className="reporting-drill-dialog" footer={<Button onClick={onClose}>Close</Button>}>
|
||||||
|
{loading && <LoadingIndicator label="Rechecking access and loading detail rows" />}
|
||||||
|
{result && <>
|
||||||
|
<nav className="reporting-drill-path" aria-label="Drill-through filter path">
|
||||||
|
{result.dimension_path.map((item, index) => <span key={`${item.dimension}:${index}`}><strong>{item.label}</strong> = {formatValue(item.value)}</span>)}
|
||||||
|
</nav>
|
||||||
|
<div className="reporting-drill-grid">
|
||||||
|
<DataGrid
|
||||||
|
id={`reporting-drill-${result.drill_context_id}`}
|
||||||
|
rows={result.rows}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={(_row, index) => `${result.drill_context_id}:${index}`}
|
||||||
|
initialFit="container"
|
||||||
|
resizeBehavior="cover"
|
||||||
|
emptyText="No contributing rows are authorized."
|
||||||
|
pagination={{ page, pageSize: 50, onPageChange: setPage }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small>{result.total_rows} authorized rows{result.truncated ? " (bounded result)" : ""}. Access and source fingerprints were rechecked for this drill.</small>
|
||||||
|
</>}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inspector({ selected, execution, history, savedViews, schedules, publications, onSelectExecution, onApplySavedView, onScheduleEnabledChange }: {
|
||||||
selected: ReportingDefinition | null;
|
selected: ReportingDefinition | null;
|
||||||
execution: ReportExecution | null;
|
execution: ReportExecution | null;
|
||||||
history: ReportExecution[];
|
history: ReportExecution[];
|
||||||
savedViews: ReportingSavedView[];
|
savedViews: ReportingSavedView[];
|
||||||
|
schedules: ReportingSchedule[];
|
||||||
|
publications: ReportingPublication[];
|
||||||
onSelectExecution: (execution: ReportExecution) => void;
|
onSelectExecution: (execution: ReportExecution) => void;
|
||||||
onApplySavedView: (view: ReportingSavedView) => void;
|
onApplySavedView: (view: ReportingSavedView) => void;
|
||||||
|
onScheduleEnabledChange: (schedule: ReportingSchedule, enabled: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="reporting-inspector-content">
|
<div className="reporting-inspector-content">
|
||||||
@@ -515,6 +871,29 @@ function Inspector({ selected, execution, history, savedViews, onSelectExecution
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{schedules.length > 0 &&
|
||||||
|
<section>
|
||||||
|
<h2><CalendarClock size={16} /> Schedules</h2>
|
||||||
|
{schedules.map((schedule) =>
|
||||||
|
<div className="reporting-inspector-toggle" key={schedule.schedule_id}>
|
||||||
|
<span><strong>{schedule.name}</strong><small>{schedule.trigger_kind === "interval" ? `Every ${formatInterval(schedule.trigger_config.seconds)}` : "Scheduled"}</small></span>
|
||||||
|
<ToggleSwitch label="Enabled" checked={schedule.enabled} onChange={(enabled) => onScheduleEnabledChange(schedule, enabled)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
{publications.length > 0 &&
|
||||||
|
<section>
|
||||||
|
<h2><FolderOutput size={16} /> Publications</h2>
|
||||||
|
{publications.map((publication) =>
|
||||||
|
<div className="reporting-inspector-record" key={publication.publication_id}>
|
||||||
|
<span>{humanize(publication.target_capability.split(".").at(-1) ?? "target")}</span>
|
||||||
|
<StatusBadge status={publication.status} label={humanize(publication.status)} />
|
||||||
|
<small>{publication.completed_at ? formatDateTime(publication.completed_at) : "Pending"}</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
<section>
|
<section>
|
||||||
<h2><Save size={16} /> Saved views</h2>
|
<h2><Save size={16} /> Saved views</h2>
|
||||||
{savedViews.length === 0 && <p>No saved views.</p>}
|
{savedViews.length === 0 && <p>No saved views.</p>}
|
||||||
@@ -539,6 +918,7 @@ function Inspector({ selected, execution, history, savedViews, onSelectExecution
|
|||||||
{item.message ?? item.code ?? "Execution diagnostic"}
|
{item.message ?? item.code ?? "Execution diagnostic"}
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
)}
|
)}
|
||||||
|
{execution && <AccessExplanation execution={execution} />}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -567,10 +947,10 @@ function ScheduleDialog({ open, onClose, onSave }: { open: boolean; onClose: ()
|
|||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
<Button variant="primary" disabled={!name.trim() || saving} onClick={() => { setSaving(true); void onSave(name.trim(), Number(interval)).finally(() => setSaving(false)); }}>Schedule</Button>
|
<Button variant="primary" disabled={!name.trim() || saving} onClick={() => { setSaving(true); void onSave(name.trim(), Number(interval)).finally(() => setSaving(false)); }}>Schedule</Button>
|
||||||
</>}>
|
</>}>
|
||||||
<div className="reporting-dialog-grid">
|
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||||
<label className="reporting-dialog-field"><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
|
<label className="reporting-dialog-field"><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
|
||||||
<label className="reporting-dialog-field"><span>Interval</span><select value={interval} onChange={(event) => setIntervalValue(event.target.value)}><option value="3600">Hourly</option><option value="86400">Daily</option><option value="604800">Weekly</option><option value="2592000">Every 30 days</option></select></label>
|
<label className="reporting-dialog-field"><span>Interval</span><select value={interval} onChange={(event) => setIntervalValue(event.target.value)}><option value="3600">Hourly</option><option value="86400">Daily</option><option value="604800">Weekly</option><option value="2592000">Every 30 days</option></select></label>
|
||||||
</div>
|
</FormGrid>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -610,6 +990,48 @@ function formatDateTime(value: string): string {
|
|||||||
return Number.isNaN(parsed.valueOf()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
|
return Number.isNaN(parsed.valueOf()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatInterval(value: unknown): string {
|
||||||
|
const seconds = Number(value);
|
||||||
|
if (seconds === 3600) return "hour";
|
||||||
|
if (seconds === 86400) return "day";
|
||||||
|
if (seconds === 604800) return "week";
|
||||||
|
if (seconds === 2592000) return "30 days";
|
||||||
|
return `${Number.isFinite(seconds) ? seconds : 0} seconds`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartPoints(values: number[], width: number, height: number): string {
|
||||||
|
if (!values.length) return "";
|
||||||
|
const finite = values.map((value) => Number.isFinite(value) ? value : 0);
|
||||||
|
const minimum = Math.min(...finite);
|
||||||
|
const maximum = Math.max(...finite);
|
||||||
|
const range = maximum - minimum || 1;
|
||||||
|
const divisor = Math.max(1, finite.length - 1);
|
||||||
|
return finite.map((value, index) => {
|
||||||
|
const x = index / divisor * width;
|
||||||
|
const y = height - ((value - minimum) / range * (height - 20) + 10);
|
||||||
|
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||||
|
}).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const PIE_COLORS = ["#2f7d6e", "#3366a8", "#c28b2c", "#9a4f71", "#5f7f3a", "#b85c3b", "#586176", "#2e8b9a"];
|
||||||
|
|
||||||
|
function pieStops(values: number[], total: number): string {
|
||||||
|
let offset = 0;
|
||||||
|
return values.slice(0, 12).map((value, index) => {
|
||||||
|
const start = offset;
|
||||||
|
offset += value / total * 100;
|
||||||
|
return `${PIE_COLORS[index % PIE_COLORS.length]} ${start.toFixed(2)}% ${offset.toFixed(2)}%`;
|
||||||
|
}).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValues(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.map(String) : [];
|
||||||
|
}
|
||||||
|
|
||||||
function humanize(value: string): string {
|
function humanize(value: string): string {
|
||||||
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+43
-3
@@ -1,10 +1,46 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
import type { DashboardWidgetsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import ReportingReportsWidget from "./features/reporting/ReportingReportsWidget";
|
||||||
import "./styles/reporting.css";
|
import "./styles/reporting.css";
|
||||||
|
|
||||||
|
|
||||||
const ReportingPage = lazy(() => import("./features/reporting/ReportingPage"));
|
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 = {
|
export const reportingModule: PlatformWebModule = {
|
||||||
id: "reporting",
|
id: "reporting",
|
||||||
label: "Reporting",
|
label: "Reporting",
|
||||||
@@ -52,8 +88,12 @@ export const reportingModule: PlatformWebModule = {
|
|||||||
{ id: "reporting.navigation", moduleId: "reporting", kind: "navigation", label: "Reporting navigation", order: 10 },
|
{ 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.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.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.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;
|
export default reportingModule;
|
||||||
|
|||||||
+226
-83
@@ -1,19 +1,10 @@
|
|||||||
.reporting-page,
|
.reporting-page {
|
||||||
.reporting-shell {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-shell {
|
.reporting-result-header {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-toolbar,
|
|
||||||
.reporting-result-header,
|
|
||||||
.reporting-output-toolbar {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -21,21 +12,8 @@
|
|||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-toolbar {
|
|
||||||
min-height: 56px;
|
|
||||||
padding: 9px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-search {
|
.reporting-search {
|
||||||
display: flex;
|
flex: 1 1 440px;
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: min(440px, 46vw);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-search input {
|
|
||||||
min-width: 140px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-count {
|
.reporting-count {
|
||||||
@@ -66,11 +44,10 @@
|
|||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-report-list,
|
|
||||||
.reporting-inspector-content section {
|
.reporting-inspector-content section {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-compact);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +61,6 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-report-row,
|
|
||||||
.reporting-inspector-content section > button {
|
.reporting-inspector-content section > button {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -97,45 +73,15 @@
|
|||||||
cursor: pointer;
|
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 {
|
.reporting-inspector-content section > button:last-child {
|
||||||
border-bottom: 0;
|
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:hover,
|
||||||
.reporting-inspector-content section > button.is-selected {
|
.reporting-inspector-content section > button.is-selected {
|
||||||
background: var(--hover-bg);
|
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 small,
|
||||||
.reporting-inspector-content p {
|
.reporting-inspector-content p {
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
@@ -211,7 +157,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 4px 8px 7px;
|
padding: 4px 8px 7px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-query-controls legend,
|
.reporting-query-controls legend,
|
||||||
@@ -251,11 +197,6 @@
|
|||||||
min-width: min(320px, 35vw);
|
min-width: min(320px, 35vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-output-toolbar {
|
|
||||||
min-height: 48px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-output-toolbar > span {
|
.reporting-output-toolbar > span {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
@@ -288,12 +229,6 @@
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-empty {
|
|
||||||
padding: 38px 12px;
|
|
||||||
color: var(--text-soft);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-inspector-content {
|
.reporting-inspector-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -361,6 +296,152 @@
|
|||||||
padding: 10px;
|
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: var(--radius-tight) var(--radius-tight) 0 0;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reporting-line-chart svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 280px;
|
||||||
|
overflow: visible;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
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: var(--radius-compact);
|
||||||
|
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: var(--radius-round);
|
||||||
|
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: var(--radius-hairline);
|
||||||
|
background: var(--data-series-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reporting-swatch-1 { background: var(--data-series-2); }
|
||||||
|
.reporting-swatch-2 { background: var(--data-series-3); }
|
||||||
|
.reporting-swatch-3 { background: var(--data-series-4); }
|
||||||
|
.reporting-swatch-4 { background: var(--data-series-5); }
|
||||||
|
.reporting-swatch-5 { background: var(--data-series-6); }
|
||||||
|
.reporting-swatch-6 { background: var(--data-series-7); }
|
||||||
|
.reporting-swatch-7 { background: var(--data-series-8); }
|
||||||
|
|
||||||
.reporting-bar-row {
|
.reporting-bar-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(100px, 22%) minmax(180px, 1fr) minmax(80px, auto);
|
grid-template-columns: minmax(100px, 22%) minmax(180px, 1fr) minmax(80px, auto);
|
||||||
@@ -373,7 +454,7 @@
|
|||||||
height: 22px;
|
height: 22px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-subtle, var(--surface));
|
background: var(--surface-subtle, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,15 +465,85 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reporting-chart-table {
|
.reporting-chart-table {
|
||||||
|
grid-column: 1 / -1;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-dialog-grid {
|
.reporting-inspector-toggle,
|
||||||
|
.reporting-inspector-record {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 12px;
|
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: var(--radius-sm);
|
||||||
|
background: var(--surface-subtle, var(--surface));
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reporting-drill-grid {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
@@ -416,16 +567,8 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-result-header,
|
.reporting-result-header {
|
||||||
.reporting-toolbar {
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-search {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-dialog-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user