Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f930a6cff0 | ||
|
|
323963c49d | ||
|
|
a6bde385ca | ||
|
|
101eafbcf6 | ||
|
|
fc8cf4b914 | ||
|
|
4c192c1a2f | ||
|
|
4e0238f701 | ||
|
|
aebe94ecc2 | ||
|
|
72e86fa87d | ||
|
|
8337aec19a | ||
|
|
a81151391b | ||
|
|
0abcc2455e | ||
|
|
eba35edd3c | ||
|
|
720959536b | ||
|
|
858f9831ad |
@@ -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
|
||||
@@ -1,12 +1,52 @@
|
||||
# GovOPlaN Reporting
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-reporting` owns report definitions, BI-style views, dashboards,
|
||||
scheduled outputs, and report publication/export behavior. It is intentionally
|
||||
separate from reusable template rendering and from generic dataflow pipelines.
|
||||
|
||||
This repository is currently a tag-only scaffold. It should gain package
|
||||
metadata and module manifests only after the first backend or WebUI slice is
|
||||
designed.
|
||||
The module now provides an executable governed semantic-reporting vertical:
|
||||
|
||||
- immutable, optimistic-concurrency guarded dataset, semantic-model, report,
|
||||
and quality-plan revisions;
|
||||
- exact source and definition pins, tenant isolation, normalized access
|
||||
grants, row-policy handoff, freshness checks, and reconstructable run
|
||||
provenance;
|
||||
- safe dimensions, hierarchies, measures, typed calculations, filters,
|
||||
detail/summary/pivot queries, parameterized PostgreSQL semantic plans, and
|
||||
accessible chart models without executing arbitrary report SQL;
|
||||
- quality gates, saved views, interval/scheduled runs, CSV/JSON export,
|
||||
provider-neutral publication targets, and import activation assessments;
|
||||
- a versioned, provider-neutral cross-module report contract with source-owned
|
||||
authorization, declared result schemas, privacy transforms, effective scope,
|
||||
source revisions, purpose, retention, export history, and audit provenance;
|
||||
- a full-height Reporting workspace for running, inspecting, saving,
|
||||
scheduling, visualizing, drilling into reauthorized contributors, publishing
|
||||
through Files/Mail, and exporting authorized reports;
|
||||
- a configurable Dashboard widget and explicit policy explanations for hidden
|
||||
fields, rows, and actions.
|
||||
|
||||
Reporting consumes Dataflow outputs or provider-owned read models. It does not
|
||||
read another module's ORM tables or take ownership of ingestion and
|
||||
transformation.
|
||||
|
||||
The canonical global route is `/reports`. `/reporting` remains a
|
||||
Reporting-owned compatibility route for saved links. Domain modules may keep
|
||||
their own operational report routes, but do not register `/reports`.
|
||||
|
||||
See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary
|
||||
decision.
|
||||
decision. The behavior-level comparison with the supplied SuperX module set is
|
||||
recorded in
|
||||
[docs/SUPERX_CAPABILITY_ASSESSMENT.md](docs/SUPERX_CAPABILITY_ASSESSMENT.md).
|
||||
|
||||
Operational and recovery behavior is documented in
|
||||
[docs/OPERATIONS.md](docs/OPERATIONS.md), while user and administrator tasks
|
||||
are covered by [docs/USER_GUIDE.md](docs/USER_GUIDE.md) and
|
||||
[docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
|
||||
|
||||
The Reporting route, workspace, state, consequence, and accessibility mapping
|
||||
is recorded in
|
||||
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Reporting Administration Guide
|
||||
|
||||
## Definition graph
|
||||
|
||||
Reporting definitions form an exact graph:
|
||||
|
||||
```text
|
||||
Dataset revision -> Semantic-model revision -> Report revision
|
||||
-> Quality-plan revision
|
||||
```
|
||||
|
||||
Create parents before children. An active child may reference only an active,
|
||||
existing parent revision. Editing creates a new immutable revision and
|
||||
requires the currently observed revision number. Existing runs continue to
|
||||
reference the historical revisions they used.
|
||||
|
||||
Each definition also records system, tenant, group, or user governance scope,
|
||||
whether it is inherited, and whether lower scopes may run, reuse, or automate
|
||||
it. A child may tighten but never broaden any effective ancestor limit. System
|
||||
definitions require system governance permission; tenant definitions are bound
|
||||
to the active tenant; group and user definitions require the matching subject
|
||||
unless a Reporting administrator performs the operation. Policy is consulted
|
||||
for view, edit, run, reuse, and automation decisions.
|
||||
|
||||
Datasets may bind a static fixture, a pinned Dataflow output, or a capability
|
||||
published by a source-owning module. Do not expose another module's ORM or an
|
||||
unbounded SQL connection as a report source. Configure an explicit schema,
|
||||
freshness policy, source fingerprint expectations, purpose, privacy,
|
||||
retention, and a row-policy provider where source access alone is not enough.
|
||||
For a Dataflow source, `source_run_ref` optionally pins one successful run that
|
||||
published an immutable Datasource materialization. Reporting passes this pin
|
||||
through rather than re-executing the pipeline. The current principal must still
|
||||
be authorized for the Dataflow definition and the exact Datasource output.
|
||||
|
||||
On PostgreSQL installations, Reporting compiles bounded semantic filters,
|
||||
grouping, measures, calculated measures, ordering, offsets, and limits into a
|
||||
parameterized PostgreSQL plan over the already authorized provider rows. Field
|
||||
paths and values are bound parameters and result limits remain mandatory. Pivot
|
||||
plans retain the safe provider-neutral engine fallback. SQLite development and
|
||||
other database engines use the same typed semantics through the bounded runtime
|
||||
engine.
|
||||
|
||||
## Access and publication
|
||||
|
||||
Tenant-visible definitions are readable by principals with Reporting read
|
||||
permission. Restricted reports use normalized account, identity, group, role,
|
||||
function, assignment, organization-unit, or service-account grants. The
|
||||
creator and Reporting administrators retain management access.
|
||||
|
||||
Scheduled output publication requires an installed capability implementing
|
||||
the Reporting publication-target contract. The target receives one immutable
|
||||
execution payload and an idempotency key. It must return bounded evidence and
|
||||
must not expose credentials in that evidence.
|
||||
|
||||
Reporting ships two optional adapters. `reporting.publication.files` calls
|
||||
`files.artifact_store` and stores an idempotent managed artifact with execution,
|
||||
revision, output-hash, and file-version evidence. `reporting.publication.mail`
|
||||
calls `mail.notificationDelivery` and submits an idempotent report notice to the
|
||||
Mail outbox. The latter does not bypass Mail profile, credential, or transport
|
||||
policy. Adapter availability is evaluated at runtime, so Reporting remains
|
||||
usable with neither Files nor Mail installed.
|
||||
|
||||
Drill contexts expire after 20 minutes, are bound to the creating account, store
|
||||
only token and context hashes, and must match the original execution output and
|
||||
source fingerprints. Resolution re-runs definition and row-level authorization.
|
||||
Treat a fingerprint mismatch as a required report rerun, not as a recoverable
|
||||
client warning.
|
||||
|
||||
## Cross-module provider governance
|
||||
|
||||
Source modules register `reporting.report_provider.<provider-id>` capabilities;
|
||||
do not grant Reporting direct table access to those modules. Review every
|
||||
descriptor's result schema, required privacy transforms, retention class,
|
||||
export formats, and re-identification risk before enabling it in production.
|
||||
Provider authorization remains mandatory even when the Reporting role allows
|
||||
the user to run reports.
|
||||
|
||||
When Policy is enabled, configure `reporting_governance_policy` in system or
|
||||
tenant settings. Tenant settings may only tighten the system result. Supported
|
||||
fields are:
|
||||
|
||||
```json
|
||||
{
|
||||
"reporting_governance_policy": {
|
||||
"allow_exports": true,
|
||||
"allowed_export_formats": ["json"],
|
||||
"allow_high_reidentification_risk": false,
|
||||
"max_retention_days": 30,
|
||||
"required_privacy_transforms": ["small_cell_suppression"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Malformed explicit policy fails closed. The ordinary privacy-retention setting
|
||||
`stored_report_detail_retention_days` is an additional ceiling. A tenant cannot
|
||||
re-enable an export format, high-risk report, or longer retention period that
|
||||
the system policy denied.
|
||||
|
||||
## Import assessments
|
||||
|
||||
Import assessment accepts declarative metadata only. Native datasets,
|
||||
dimensions, hierarchies, measures, parameters, tables, pivots, charts,
|
||||
quality assertions, and saved views map exactly. Provider-specific formatting,
|
||||
dialect functions, and dashboard layouts require explicit approximation
|
||||
acceptance. Raw SQL, procedures, scripts, implicit authorization, unchecked
|
||||
functions, and unknown features block activation.
|
||||
@@ -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
|
||||
@@ -0,0 +1,53 @@
|
||||
# Reporting Operations, Security, and Recovery
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
Reporting API nodes are stateless. Definitions, executions, schedules,
|
||||
publication evidence, and import/quality evidence live in the shared platform
|
||||
database. Dataset bytes remain with their source owner; large durable output
|
||||
files belong in a publication provider backed by shared object storage.
|
||||
|
||||
Run and schedule idempotency keys make retries replay-safe. Schedule workers
|
||||
claim bounded batches, retain success or failure state, and advance interval
|
||||
time from the claimed occurrence. Providers must use the same tenant and
|
||||
principal context and must enforce their source-side authorization.
|
||||
|
||||
Provider-report executions and exports are database evidence. Each execution
|
||||
stores the exact provider/report revision, request hash, source revisions,
|
||||
effective scope, applied privacy transforms, governance decision provenance,
|
||||
generation actor/time, retention expiry, and output hash. Export rows retain
|
||||
their own purpose, audience, actor/time, format, and hash. Expired provider
|
||||
results are rejected by retrieval and export paths; run the platform privacy
|
||||
retention process on schedule to remove or minimize expired stored detail in
|
||||
accordance with the active Policy.
|
||||
|
||||
## Security controls
|
||||
|
||||
- The semantic engine never evaluates Python, browser code, raw SQL, stored
|
||||
procedures, or imported runtime scripts.
|
||||
- Definition and execution APIs require explicit module scopes and a
|
||||
tenant-bound principal.
|
||||
- Restricted access is normalized into grants and rechecked before definitions
|
||||
or execution history are returned.
|
||||
- Row-policy providers may only reduce a dataset; returning more rows than the
|
||||
source read fails closed.
|
||||
- Freshness, schema, fingerprints, quality gates, definition hashes, source
|
||||
provenance, actor, and output hash are retained with every run.
|
||||
- CSV formula injection is neutralized before direct download.
|
||||
- Provider exceptions are isolated in catalogue diagnostics; an unavailable
|
||||
optional provider cannot take down the global report catalogue.
|
||||
- High re-identification-risk provider reports fail closed unless an installed
|
||||
Policy capability explicitly permits them.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
Back up the shared database and every object-storage publication target as one
|
||||
recovery set. Before destructive module retirement, take and verify a database
|
||||
snapshot; retirement removes definitions, result evidence, schedules, and
|
||||
publication records. Restore the database first, then target object storage,
|
||||
then reconcile provider publications by their idempotency/evidence references.
|
||||
|
||||
After restore, verify migrations, definition parent pins, schedule enablement,
|
||||
execution counts and hashes, and publication target health before dispatching
|
||||
due schedules. Do not claim successful recovery until an operator has run and
|
||||
recorded the deployment-specific drill.
|
||||
+115
-18
@@ -15,6 +15,9 @@ Reporting owns:
|
||||
- curated query/view inputs from modules, connectors, datasources, or
|
||||
configuration packages
|
||||
- report execution history, generated output evidence, and scheduled runs
|
||||
- institutional question, goal/obligation, owner, calculation-version,
|
||||
freshness/quality, and decision/action-consumption references for each
|
||||
material report or indicator
|
||||
- report permissions and sharing rules
|
||||
- export targets such as file, DMS, mail, API, RSS/Atom publication, and
|
||||
downstream connector handoff
|
||||
@@ -28,13 +31,27 @@ Initial source categories:
|
||||
- curated SQL/query views exposed through a controlled capability
|
||||
- connector-staged datasets and external references
|
||||
- generated file/csv/xlsx inputs from files/connectors
|
||||
- future `govoplan-datasources` catalog entries, if that module is created
|
||||
- future `govoplan-dataflow` outputs, if that module is created
|
||||
- governed `govoplan-datasources` catalogue entries and immutable
|
||||
materializations
|
||||
- versioned `govoplan-dataflow` outputs and run lineage
|
||||
|
||||
Reporting should not reach into module ORM internals directly. Module-owned
|
||||
data must be exposed through capabilities, DTOs, events, read models, or
|
||||
controlled query views.
|
||||
|
||||
Every reproducible report definition should retain or resolve:
|
||||
|
||||
- the institutional question, obligation, goal, or service measure it serves;
|
||||
- owner and responsible organization/function;
|
||||
- datasource/materialization and Dataflow definition/run revisions;
|
||||
- source freshness, quality state, known limitations, and calculation version;
|
||||
- purpose, visibility, privacy, retention, and publication decisions;
|
||||
- reports, decisions, controls, projects, or actions that consumed the result.
|
||||
|
||||
Reporting owns execution and presentation of the measure. Mandates, Services,
|
||||
Projects, Risk Compliance, Decisions, and other domains retain ownership of
|
||||
the referenced institutional concepts.
|
||||
|
||||
## Output Targets
|
||||
|
||||
- dashboard/widget view
|
||||
@@ -53,24 +70,104 @@ Reporting does not own:
|
||||
- reusable template rendering; that belongs to `govoplan-templates`
|
||||
- DMS lifecycle, collaborative editing, legal hold, or records management
|
||||
- raw file/blob storage and provider connectors
|
||||
- general-purpose ingestion/transformation pipeline ownership unless a future
|
||||
`govoplan-dataflow` module is justified
|
||||
- ingestion, governed source identity, staging, or transformation pipeline
|
||||
ownership; those belong to Connectors, Datasources, and Dataflow
|
||||
- cross-module search indexing; that belongs to `govoplan-search`
|
||||
|
||||
## Candidate Capabilities
|
||||
## Capability Contracts
|
||||
|
||||
- `reporting.catalog`
|
||||
- `reporting.run`
|
||||
- `reporting.schedules`
|
||||
- `reporting.exports`
|
||||
- `reporting.readModels`
|
||||
- `reporting.dashboardContributions`
|
||||
- `reporting.registry` owns immutable definition registration and lookup.
|
||||
- `reporting.runner` executes exact report graphs and returns governed result
|
||||
evidence.
|
||||
- `reporting.scheduler` claims due schedules and preserves run/publication
|
||||
outcomes.
|
||||
- `reporting.chart_renderer` renders provider-neutral visual models with an
|
||||
accessible table fallback.
|
||||
- `reporting.publication.files` adapts immutable results to Core's
|
||||
`files.artifact_store` boundary without importing Files internals.
|
||||
- `reporting.publication.mail` adapts report notices to Core's
|
||||
`mail.notificationDelivery` boundary without importing Mail internals.
|
||||
- `reporting.read_model:*` capabilities can expose bounded source-owned rows.
|
||||
- `reporting.publication_target:*` capabilities can accept immutable result
|
||||
payloads without Reporting importing the target module.
|
||||
|
||||
## First Implementation Slice
|
||||
### Cross-module report providers
|
||||
|
||||
1. Define manifest metadata, permissions, and capability names.
|
||||
2. Add report definition and parameter DTOs.
|
||||
3. Add one read-only report source contract over module-provided summary DTOs.
|
||||
4. Add export target DTOs without implementing every target.
|
||||
5. Add tests that report consumers/producers use capability lookup rather than
|
||||
direct imports.
|
||||
Source modules contribute aggregate or otherwise minimized reports through
|
||||
`reporting.report_provider.<provider-id>`. Core owns contract version `1.0` and
|
||||
the provider-neutral DTOs. A provider must declare stable report and revision
|
||||
IDs, typed parameters, the complete result schema, privacy transforms,
|
||||
retention class, export formats, and re-identification risk. It must enforce
|
||||
source access before listing options or producing a result and must return
|
||||
source revisions, effective tenant scope, applied transforms, generation time,
|
||||
and bounded provenance.
|
||||
|
||||
Reporting discovers these capabilities by prefix. It validates descriptors,
|
||||
rejects undeclared output fields, requires every mandatory transform, records
|
||||
purpose and effective audience, and persists immutable execution and export
|
||||
evidence. The optional `policy.reporting_governance` capability can tighten
|
||||
retention, export formats, required transforms, and high-risk handling. If
|
||||
Policy is absent, Reporting applies its restrictive built-in baseline and
|
||||
denies high-risk provider reports.
|
||||
|
||||
This direction is intentionally one-way: source modules import only Core's
|
||||
contract and remain usable when Reporting is absent. Reporting never imports
|
||||
the source module or reads its ORM tables.
|
||||
|
||||
## Implemented Vertical
|
||||
|
||||
The first complete vertical persists datasets, semantic models, reports,
|
||||
quality plans, grants, executions, saved views, schedules, publications,
|
||||
quality results, and import assessments. Each definition update creates an
|
||||
immutable revision and requires optimistic concurrency. Active child
|
||||
definitions require the exact pinned parent revision to be active.
|
||||
|
||||
Execution resolves the report, semantic model, and dataset graph before
|
||||
reading data. Static fixtures, Dataflow output, and provider-owned read models
|
||||
share one bounded read contract. Source fingerprints, freshness, schema,
|
||||
row-policy provenance, blocking quality plans, definition hashes, executor
|
||||
version, output hash, diagnostics, and authorized rows are retained with the
|
||||
execution. Failed runs also retain evidence.
|
||||
|
||||
The query layer deliberately implements a typed expression and semantic
|
||||
query language rather than `eval`, arbitrary SQL, stored procedures, or
|
||||
runtime scripts. PostgreSQL installations receive parameterized semantic plans
|
||||
for filters, grouping, measures, calculated aggregates, sorting, and bounds;
|
||||
other engines and pivots use the equivalent bounded runtime evaluator. It
|
||||
supports detail, grouped summary, pivot, dimensions,
|
||||
hierarchies, common aggregates, calculated measures, filters, sorting,
|
||||
pagination, totals, and a provider-neutral visualization model. A saved chart
|
||||
that is incompatible with an ad-hoc query degrades to its mandatory table
|
||||
fallback instead of failing a valid report run.
|
||||
|
||||
Aggregate drill-through uses an expiring actor-bound context hash. Resolution
|
||||
rechecks all definition and row-policy decisions, verifies the source
|
||||
fingerprints against the original execution, preserves the complete dimension
|
||||
path, and returns only authorized contributors.
|
||||
|
||||
Direct export supports UTF-8 CSV and JSON. CSV cells that spreadsheet software
|
||||
could interpret as formulas are escaped. Additional formats and delivery
|
||||
destinations use an optional publication capability and preserve idempotent
|
||||
evidence. Import assessments classify exact, approximated, and unsupported
|
||||
semantics and block activation until every approximation is accepted and no
|
||||
unsupported executable behavior remains.
|
||||
|
||||
The WebUI uses the platform module loader and common controls. It exposes a
|
||||
report catalogue, parameter and semantic-query controls, result visualization
|
||||
and table views, accessible bar/column/line/area/pie/donut/metric charts,
|
||||
drill-through, access explanations, history/provenance, saved views, schedule
|
||||
management, Files/Mail publication management, downloads, and a Dashboard
|
||||
widget contribution.
|
||||
The global `/reports` route is owned only by Reporting. `/reporting` is a
|
||||
documented compatibility path. Campaign's module-local aggregate view remains
|
||||
at `/campaigns/reports`; when both modules are enabled, the same safe aggregate
|
||||
projection is also contributed to the global catalogue through the provider
|
||||
contract.
|
||||
|
||||
## Remaining Product Depth
|
||||
|
||||
The architecture boundary and first operational vertical are implemented.
|
||||
Further work is additive product depth: packaged domain report catalogues,
|
||||
XLSX/PDF formatting through optional renderer providers, selector-backed Mail
|
||||
profile configuration, external publication connectors, and target-environment
|
||||
evidence for a maturity claim above `vertical_slice`.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# SuperX Capability Assessment
|
||||
|
||||
## Scope
|
||||
|
||||
The supplied SuperX directory contains 22 PostgreSQL module archives:
|
||||
|
||||
`bau`, `cob`, `costage`, `erfolg`, `etl`, `fin`, `gang`, `gxstage`, `ivs`,
|
||||
`kenn`, `kern`, `lm`, `man`, `msg`, `qa`, `rpta`, `sos`, `sva`, `sxc`, `viz`,
|
||||
`xcube`, and `zul`.
|
||||
|
||||
This is a behavior and architecture assessment. It uses archive metadata and
|
||||
representative definitions to identify product capabilities; no SuperX source
|
||||
code or visual assets are copied.
|
||||
|
||||
## Functional Model
|
||||
|
||||
The supplied modules collectively provide five relevant layers:
|
||||
|
||||
1. **ETL orchestration:** parameterized jobs, ordered steps, dependencies,
|
||||
load/select/native actions, continuation policy, and execution metadata.
|
||||
2. **Domain data marts:** higher-education, finance, personnel, buildings,
|
||||
applications, courses, success, and benchmark datasets with curated
|
||||
reports.
|
||||
3. **Semantic BI model:** cubes, dimensions, measures, hierarchies, totals,
|
||||
labels, virtual calculated columns, and row/column arrangements.
|
||||
4. **Presentation:** parameter masks, tables, pivots, charts, saved
|
||||
visualizations, dashboard tabs, and formatted report products.
|
||||
5. **Quality and operations:** assertions, comparison profiles, grouped
|
||||
controls, execution logs, projects/issues, installation, and updates.
|
||||
|
||||
## GovOPlaN Coverage
|
||||
|
||||
| Capability | GovOPlaN state | Primary owner |
|
||||
| --- | --- | --- |
|
||||
| Governed source identity and frozen snapshots | Strong foundation | Datasources |
|
||||
| External acquisition and credentials | Strong foundation | Connectors |
|
||||
| Graph and SQL transformations | Strong foundation | Dataflow |
|
||||
| Calculations, joins, ranking, aggregates | Available | Dataflow |
|
||||
| Quality rules and keyed reconciliation | Available foundation | Dataflow |
|
||||
| Durable scheduled/event/user runs | Available foundation | Dataflow/Core |
|
||||
| Resumable approval and correction steps | Available foundation | Workflow |
|
||||
| Scope inheritance and provenance | Available foundation | Policy/Core |
|
||||
| Configurable dashboard widgets | Available foundation | Dashboard |
|
||||
| Reusable parameterized subflows | Available foundation | Dataflow |
|
||||
| BI dimensions, measures, and hierarchies | Available | Reporting |
|
||||
| Pivot analytical view | Available; drill-through is product depth | Reporting |
|
||||
| Parameterized report masks and saved views | Available | Reporting |
|
||||
| Chart renderer/provider contract | Available with tabular fallback | Reporting |
|
||||
| Scheduled report publication/export | Available through provider contract | Reporting |
|
||||
| Report-specific access enforcement | Available; richer explanations are product depth | Reporting/Policy |
|
||||
| Data-quality test plans and evidence | Available foundation | Dataflow/Reporting |
|
||||
| Packaged domain report catalogues | Missing | Reporting plus domain modules |
|
||||
|
||||
GovOPlaN already has most platform primitives below the BI layer. The largest
|
||||
gap is not another execution engine; it is a governed Reporting semantic layer
|
||||
that turns curated Dataflow outputs and module read models into dimensions,
|
||||
measures, parameterized views, pivots, charts, and publishable reports.
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
Reporting should consume only capability-published datasets or module read
|
||||
models. It must not query another module's ORM tables directly.
|
||||
|
||||
The minimum durable contracts are:
|
||||
|
||||
- **Analytical dataset:** stable reference, schema, freshness, provenance,
|
||||
scope, row-level policy hook, and supported query operations.
|
||||
- **Semantic model:** dimensions, levels/hierarchies, measures, calculations,
|
||||
labels, formats, totals, and default filters.
|
||||
- **Report definition:** semantic-model revision, parameters, layout,
|
||||
visualization, sort/filter state, and access policy.
|
||||
- **Execution:** immutable definition revision, bound parameters, source
|
||||
fingerprints, effective authorization, result summary, and diagnostics.
|
||||
- **Publication:** target capability, format profile, retention, schedule,
|
||||
delivery evidence, and idempotency key.
|
||||
- **Contribution:** modules may announce report sources, semantic fragments,
|
||||
templates, and dashboard widgets without Reporting importing them.
|
||||
|
||||
PostgreSQL should remain the first execution target. Optional analytical or
|
||||
search engines can be adapters later; report definitions must not depend on
|
||||
one engine's private syntax.
|
||||
|
||||
## Import Boundary
|
||||
|
||||
A future SuperX importer may read declarative metadata for sources,
|
||||
parameters, dimensions, measures, hierarchies, layouts, and report catalogues.
|
||||
It must produce native GovOPlaN definitions, never retain executable SQL or
|
||||
stored procedures as an unchecked runtime escape hatch.
|
||||
|
||||
Every import result needs a machine-readable mapping report:
|
||||
|
||||
- source object and stable source identifier
|
||||
- generated GovOPlaN object and revision
|
||||
- exact, approximated, omitted, and unsupported semantics
|
||||
- required manual bindings or Policy decisions
|
||||
- source dialect/provider assumptions
|
||||
- warnings for custom functions, procedural code, implicit authorization, and
|
||||
presentation behavior that has no native equivalent
|
||||
|
||||
Unsupported semantics must block activation unless an administrator explicitly
|
||||
accepts a documented approximation. Imported definitions remain ordinary
|
||||
versioned GovOPlaN definitions after migration; the runtime must not depend on
|
||||
the original SuperX installation.
|
||||
|
||||
## Delivery State
|
||||
|
||||
### Phase 1: Semantic Core - complete
|
||||
|
||||
- scaffold the Reporting module and manifest
|
||||
- define analytical dataset, dimension, hierarchy, measure, parameter, and
|
||||
report-definition schemas
|
||||
- consume one Dataflow-published dataset through a capability
|
||||
- enforce scope, Policy provenance, and source freshness
|
||||
|
||||
### Phase 2: Interactive BI - vertical complete
|
||||
|
||||
- parameter panel and saved views
|
||||
- table, pivot, drill-down, totals, calculated measures, and export of the
|
||||
currently authorized result
|
||||
- stable URL/reference contract for report state
|
||||
- dashboard contribution for saved reports
|
||||
|
||||
### Phase 3: Visualization And Publication - vertical complete
|
||||
|
||||
- chart renderer provider contract with an initial bounded chart catalogue
|
||||
- scheduled runs and publication through Files, Mail, or another declared
|
||||
target capability
|
||||
- accessible tabular fallback and report-specific access explanations
|
||||
|
||||
### Phase 4: Quality And Domain Packs - foundation complete
|
||||
|
||||
- reusable report assertions and dataset comparison plans
|
||||
- execution evidence and trend views
|
||||
- versioned domain report packages owned by the relevant domain modules
|
||||
- import tooling for metadata where licensing and semantic mapping permit it
|
||||
|
||||
Packaged domain catalogues and an importer that translates actual source
|
||||
metadata remain product depth. The native contracts, quality evidence, and
|
||||
blocking import assessment required to implement them are present.
|
||||
|
||||
## Product Boundary
|
||||
|
||||
Dataflow remains responsible for producing governed tabular datasets.
|
||||
Reporting owns analytical semantics and presentation. Dashboard arranges
|
||||
widgets but does not become the report engine. Workflow coordinates human
|
||||
handoffs but does not own report execution. Templates format documents but do
|
||||
not own report data or query semantics.
|
||||
|
||||
The assessment and its implementation split are tracked by
|
||||
[`govoplan-reporting#3`](https://git.add-ideas.de/GovOPlaN/govoplan-reporting/issues/3).
|
||||
@@ -0,0 +1,75 @@
|
||||
# Reporting User Guide
|
||||
|
||||
## Open and run a report
|
||||
|
||||
Open **Reporting** at `/reports` and choose a report from the left catalogue.
|
||||
The catalogue separates source-module reports from Reporting semantic reports.
|
||||
The header identifies the immutable report revision that will be used. Select a
|
||||
summary, detail, or pivot shape, choose dimensions and measures, enter any
|
||||
report parameters, and select **Run**.
|
||||
|
||||
Only rows authorized for the current tenant and principal reach the query
|
||||
engine. A successful result shows its authorized row count, visualization,
|
||||
and table. When a saved chart does not match an ad-hoc query, Reporting shows
|
||||
the accessible table fallback instead of changing or rejecting the query.
|
||||
|
||||
Summary and pivot rows expose a detail action. Selecting it creates a short-lived,
|
||||
account-bound drill context, rechecks the report, semantic model, dataset, row
|
||||
policy, and source fingerprint, and then displays only the authorized contributing
|
||||
rows. The path above the table records every aggregate dimension used for the
|
||||
drill. If the source changed, run the report again rather than treating stale
|
||||
aggregate and detail states as equivalent.
|
||||
|
||||
## Inspect evidence
|
||||
|
||||
The right panel lists previous runs and definition/source pins. Select an
|
||||
earlier run to inspect its retained result. Content and output hashes identify
|
||||
the exact definition and output. Warnings explain freshness, inferred schema,
|
||||
or provider diagnostics. A failed quality gate records a failed execution and
|
||||
does not publish a result.
|
||||
|
||||
A report configured against an exact published Dataflow run reads that run's
|
||||
immutable Datasource materialization. It does not rerun the flow with current
|
||||
inputs. The evidence identifies the Dataflow run and materialization, and access
|
||||
to both is checked again when the report runs.
|
||||
|
||||
The **Effective access** explanation states when dimensions, measures, source
|
||||
rows, or actions were removed by Policy. A result with no hidden elements says
|
||||
so explicitly; catalogue visibility never grants access to protected detail.
|
||||
|
||||
## Save and export
|
||||
|
||||
Use **Save current view** to keep the current query under your account. Saved
|
||||
views do not alter the report definition. CSV and JSON downloads contain only
|
||||
the authorized result of the selected execution. CSV values that begin with a
|
||||
spreadsheet formula marker are escaped.
|
||||
|
||||
Users with scheduling permission can create an hourly, daily, weekly, or
|
||||
30-day interval from the current revision, parameters, and query. Scheduled
|
||||
runs continue to use those exact pins until the schedule is edited.
|
||||
|
||||
The Schedules panel can pause or resume each schedule with optimistic revision
|
||||
checking. Users with publication permission can publish a successful execution
|
||||
to Files or Mail. Files stores CSV, JSON, or accessible HTML through managed
|
||||
artifact storage. Mail submits a bounded report notice through its durable
|
||||
outbox and requires a usable profile, sender, and recipient. Unavailable targets
|
||||
remain explained but cannot be selected as a valid destination. Publication
|
||||
history records the target, result, time, output hash, and provider evidence.
|
||||
|
||||
When Dashboard is enabled, the **Reports** widget lists active reports without
|
||||
copying result data into Dashboard. Its item limit is configurable per widget.
|
||||
|
||||
## Run a module report
|
||||
|
||||
Module reports retain their source module's access rules. Select the source
|
||||
object and any optional parameters, enter the concrete purpose for the run,
|
||||
and verify the effective tenant audience before selecting **Run**. Reporting
|
||||
will refuse a result that does not carry the declared privacy transforms,
|
||||
source revision, and tenant scope.
|
||||
|
||||
The result groups declared metrics and details without exposing undeclared
|
||||
provider data. The Governance panel explains the risk class, permitted export
|
||||
formats, retention ceiling, contract version, and required privacy transforms.
|
||||
An export records a separate purpose, actor, audience, time, format, and output
|
||||
hash. A missing or restricted module report is not replaced with a less
|
||||
protected client-side query.
|
||||
@@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-reporting"
|
||||
version = "0.1.18"
|
||||
description = "GovOPlaN governed reporting and semantic BI module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.18",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
govoplan_reporting = ["py.typed"]
|
||||
|
||||
[project.entry-points."govoplan.modules"]
|
||||
reporting = "govoplan_reporting.backend.manifest:get_manifest"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""GovOPlaN Reporting module."""
|
||||
|
||||
__version__ = "0.1.18"
|
||||
@@ -0,0 +1 @@
|
||||
"""Reporting backend package."""
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.auth import has_scope
|
||||
from govoplan_core.core.modules import AccessDecision
|
||||
|
||||
|
||||
class ReportingScopeAclProvider:
|
||||
def __init__(self, resource_type: str) -> None:
|
||||
self.resource_type = resource_type
|
||||
|
||||
def can_read(self, principal: object, resource_id: str) -> bool:
|
||||
del resource_id
|
||||
return has_scope(principal, "reporting:definition:read")
|
||||
|
||||
def can_write(self, principal: object, resource_id: str) -> bool:
|
||||
del resource_id
|
||||
return has_scope(principal, "reporting:definition:write")
|
||||
|
||||
def explain(self, principal: object, resource_id: str) -> AccessDecision:
|
||||
del resource_id
|
||||
allowed = self.can_read(principal, "")
|
||||
return AccessDecision(
|
||||
allowed=allowed,
|
||||
reason=None if allowed else "Missing scope: reporting:definition:read",
|
||||
requirements=("reporting:definition:read",),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ReportingScopeAclProvider"]
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_REPORTING_REGISTRY = "reporting.registry"
|
||||
CAPABILITY_REPORTING_RUNNER = "reporting.runner"
|
||||
CAPABILITY_REPORTING_SCHEDULER = "reporting.scheduler"
|
||||
CAPABILITY_REPORTING_CHART_RENDERER = "reporting.chart_renderer"
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES = "reporting.publication.files"
|
||||
CAPABILITY_REPORTING_PUBLICATION_MAIL = "reporting.publication.mail"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReportingDatasetReadRequest:
|
||||
source_ref: str
|
||||
source_revision: int | None
|
||||
parameters: Mapping[str, object] = field(default_factory=dict)
|
||||
row_limit: int = 2_000
|
||||
expected_definition_hash: str | None = None
|
||||
expected_source_fingerprints: tuple[Mapping[str, object], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReportingDatasetReadResult:
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
total_rows: int
|
||||
truncated: bool
|
||||
output_hash: str
|
||||
executor_version: str
|
||||
definition_hash: str | None = None
|
||||
source_fingerprints: tuple[Mapping[str, object], ...] = ()
|
||||
diagnostics: tuple[Mapping[str, object], ...] = ()
|
||||
generated_at: datetime | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ReportingReadModelProvider(Protocol):
|
||||
def read_dataset(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ReportingDatasetReadRequest,
|
||||
) -> ReportingDatasetReadResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReportingRowPolicyRequest:
|
||||
dataset_id: str
|
||||
dataset_revision: int
|
||||
policy_ref: str
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReportingRowPolicyResult:
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
decision_ref: str
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ReportingRowPolicyProvider(Protocol):
|
||||
def authorize_rows(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ReportingRowPolicyRequest,
|
||||
) -> ReportingRowPolicyResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReportingPublicationPayload:
|
||||
publication_id: str
|
||||
execution_id: str
|
||||
tenant_id: str
|
||||
report_id: str
|
||||
report_revision: int
|
||||
format: str
|
||||
target_ref: str | None
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
schema: tuple[Mapping[str, object], ...]
|
||||
output_hash: str
|
||||
options: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ReportingPublicationTarget(Protocol):
|
||||
def publish_report(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
payload: ReportingPublicationPayload,
|
||||
) -> Mapping[str, object]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ReportingChartRenderer(Protocol):
|
||||
def render(
|
||||
self, *, visualization: object, result: object
|
||||
) -> Mapping[str, object]: ...
|
||||
|
||||
|
||||
def capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_REPORTING_CHART_RENDERER",
|
||||
"CAPABILITY_REPORTING_PUBLICATION_FILES",
|
||||
"CAPABILITY_REPORTING_PUBLICATION_MAIL",
|
||||
"CAPABILITY_REPORTING_REGISTRY",
|
||||
"CAPABILITY_REPORTING_RUNNER",
|
||||
"CAPABILITY_REPORTING_SCHEDULER",
|
||||
"ReportingChartRenderer",
|
||||
"ReportingDatasetReadRequest",
|
||||
"ReportingDatasetReadResult",
|
||||
"ReportingPublicationPayload",
|
||||
"ReportingPublicationTarget",
|
||||
"ReportingReadModelProvider",
|
||||
"ReportingRowPolicyProvider",
|
||||
"ReportingRowPolicyRequest",
|
||||
"ReportingRowPolicyResult",
|
||||
"capability",
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Reporting database models."""
|
||||
|
||||
from govoplan_reporting.backend.db.models import (
|
||||
ReportingDefinitionGrant,
|
||||
ReportingDefinitionIdentity,
|
||||
ReportingDefinitionRevision,
|
||||
ReportingDrillContext,
|
||||
ReportingExecution,
|
||||
ReportingImportAssessment,
|
||||
ReportingPublication,
|
||||
ReportingQualityResult,
|
||||
ReportingSavedView,
|
||||
ReportingSchedule,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ReportingDefinitionGrant",
|
||||
"ReportingDefinitionIdentity",
|
||||
"ReportingDefinitionRevision",
|
||||
"ReportingDrillContext",
|
||||
"ReportingExecution",
|
||||
"ReportingImportAssessment",
|
||||
"ReportingPublication",
|
||||
"ReportingQualityResult",
|
||||
"ReportingSavedView",
|
||||
"ReportingSchedule",
|
||||
]
|
||||
@@ -0,0 +1,607 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class ReportingDefinitionIdentity(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_definition_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_id",
|
||||
name="uq_reporting_definition_identity",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_key",
|
||||
name="uq_reporting_definition_key",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_definition_catalog",
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_key",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
definition_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
definition_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
|
||||
|
||||
class ReportingDefinitionRevision(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_definition_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_id",
|
||||
"revision",
|
||||
name="uq_reporting_definition_revision",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_reporting_definition_idempotency",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"event_id",
|
||||
name="uq_reporting_definition_event",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_definition_current",
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_id",
|
||||
"superseded_at",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_definition_parent",
|
||||
"tenant_id",
|
||||
"parent_kind",
|
||||
"parent_id",
|
||||
"parent_revision",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_definition_list",
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"status",
|
||||
"recorded_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
identity_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("reporting_definition_identities.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
definition_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
definition_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("reporting_definition_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
parent_kind: Mapped[str | None] = mapped_column(
|
||||
String(40), nullable=True, index=True
|
||||
)
|
||||
parent_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
parent_revision: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
visibility: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
change_reason: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
changed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
|
||||
|
||||
class ReportingExecution(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_executions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "execution_id", name="uq_reporting_execution"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_reporting_execution_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_execution_history",
|
||||
"tenant_id",
|
||||
"report_id",
|
||||
"started_at",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_execution_status",
|
||||
"tenant_id",
|
||||
"status",
|
||||
"started_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
execution_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
report_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
report_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
semantic_model_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True
|
||||
)
|
||||
semantic_model_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
dataset_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
dataset_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
parameters: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
query: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
source_fingerprints: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
definition_hashes: Mapped[dict[str, str]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
output_hash: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True
|
||||
)
|
||||
executor_version: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
result_schema: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
result_rows: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
total_rows: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
truncated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
diagnostics: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
|
||||
|
||||
class ReportingProviderExecution(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_provider_executions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"execution_id",
|
||||
name="uq_reporting_provider_execution",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_reporting_provider_execution_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_provider_execution_history",
|
||||
"tenant_id",
|
||||
"provider_id",
|
||||
"report_id",
|
||||
"generated_at",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_provider_execution_retention",
|
||||
"tenant_id",
|
||||
"expires_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
execution_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
provider_id: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
report_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
report_revision: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
contract_version: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
purpose: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
audience_scope: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
parameters: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
result_schema: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
result_payload: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
source_revisions: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
effective_scope: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
privacy_transforms: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
governance_provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
retention_class: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
retention_redacted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
output_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
generated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
|
||||
|
||||
class ReportingProviderExport(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_provider_exports"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"export_id",
|
||||
name="uq_reporting_provider_export",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_provider_export_history",
|
||||
"tenant_id",
|
||||
"provider_execution_id",
|
||||
"exported_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
export_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
provider_execution_id: Mapped[str] = mapped_column(
|
||||
ForeignKey(
|
||||
"reporting_provider_executions.id",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
execution_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
format: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
purpose: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
audience_scope: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
output_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
exported_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
|
||||
|
||||
class ReportingDefinitionGrant(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_definition_grants"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
name="uq_reporting_definition_grant",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_definition_grant_subject",
|
||||
"tenant_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
"active",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_definition_grant_object",
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_id",
|
||||
"active",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
definition_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
subject_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, nullable=False, index=True
|
||||
)
|
||||
source_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
|
||||
class ReportingSavedView(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_saved_views"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "view_id", name="uq_reporting_saved_view"),
|
||||
Index(
|
||||
"ix_reporting_saved_view_catalog",
|
||||
"tenant_id",
|
||||
"report_id",
|
||||
"owner_id",
|
||||
"shared",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
view_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
report_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
report_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
owner_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
owner_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
state: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
shared: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False, index=True
|
||||
)
|
||||
access: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class ReportingSchedule(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_schedules"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "schedule_id", name="uq_reporting_schedule"),
|
||||
Index(
|
||||
"ix_reporting_schedule_due",
|
||||
"enabled",
|
||||
"next_run_at",
|
||||
"tenant_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
schedule_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
report_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
report_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
trigger_kind: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
trigger_config: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
parameters: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
query: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
publication_target: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, nullable=False, index=True
|
||||
)
|
||||
next_run_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_execution_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
class ReportingPublication(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_publications"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "publication_id", name="uq_reporting_publication"
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_reporting_publication_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_publication_history",
|
||||
"tenant_id",
|
||||
"execution_id",
|
||||
"created_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
publication_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
execution_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
target_capability: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
target_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
format: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class ReportingDrillContext(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_drill_contexts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "drill_context_id", name="uq_reporting_drill_context"
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_drill_context_expiry",
|
||||
"tenant_id",
|
||||
"expires_at",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_drill_context_execution",
|
||||
"tenant_id",
|
||||
"execution_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
drill_context_id: Mapped[str] = mapped_column(
|
||||
String(36), nullable=False, index=True
|
||||
)
|
||||
execution_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
token_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
context_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
dimension_path: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
source_fingerprints: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
policy_provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
last_accessed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class ReportingQualityResult(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_quality_results"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "result_id", name="uq_reporting_quality_result"),
|
||||
Index(
|
||||
"ix_reporting_quality_history",
|
||||
"tenant_id",
|
||||
"quality_plan_id",
|
||||
"evaluated_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
result_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
quality_plan_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True
|
||||
)
|
||||
quality_plan_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
dataset_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
dataset_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
output_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
assertions: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
source_fingerprints: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
evaluated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
class ReportingImportAssessment(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_import_assessments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "assessment_id", name="uq_reporting_import_assessment"
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_import_history",
|
||||
"tenant_id",
|
||||
"source_system",
|
||||
"created_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
assessment_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
source_system: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
source_id: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
source_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
mapping_report: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
accepted_approximations: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
assessed_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ReportingDefinitionGrant",
|
||||
"ReportingDefinitionIdentity",
|
||||
"ReportingDefinitionRevision",
|
||||
"ReportingDrillContext",
|
||||
"ReportingExecution",
|
||||
"ReportingImportAssessment",
|
||||
"ReportingPublication",
|
||||
"ReportingProviderExecution",
|
||||
"ReportingProviderExport",
|
||||
"ReportingQualityResult",
|
||||
"ReportingSavedView",
|
||||
"ReportingSchedule",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from govoplan_reporting.backend.db.models import ReportingDefinitionRevision
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReportingDefinitionRecord:
|
||||
tenant_id: str
|
||||
definition_kind: str
|
||||
definition_id: str
|
||||
definition_key: str
|
||||
revision: int
|
||||
name: str
|
||||
description: str | None
|
||||
status: str
|
||||
visibility: str
|
||||
content_hash: str
|
||||
recorded_at: datetime
|
||||
change_reason: str
|
||||
payload: dict[str, Any]
|
||||
parent_kind: str | None = None
|
||||
parent_id: str | None = None
|
||||
parent_revision: int | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"definition_kind": self.definition_kind,
|
||||
"definition_id": self.definition_id,
|
||||
"definition_key": self.definition_key,
|
||||
"revision": self.revision,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"status": self.status,
|
||||
"visibility": self.visibility,
|
||||
"content_hash": self.content_hash,
|
||||
"parent_kind": self.parent_kind,
|
||||
"parent_id": self.parent_id,
|
||||
"parent_revision": self.parent_revision,
|
||||
"recorded_at": _datetime_text(self.recorded_at),
|
||||
"change_reason": self.change_reason,
|
||||
"payload": dict(self.payload),
|
||||
}
|
||||
|
||||
|
||||
def definition_from_row(
|
||||
row: ReportingDefinitionRevision,
|
||||
) -> ReportingDefinitionRecord:
|
||||
return ReportingDefinitionRecord(
|
||||
tenant_id=row.tenant_id,
|
||||
definition_kind=row.definition_kind,
|
||||
definition_id=row.definition_id,
|
||||
definition_key=row.definition_key,
|
||||
revision=row.revision,
|
||||
name=row.name,
|
||||
description=row.description,
|
||||
status=row.status,
|
||||
visibility=row.visibility,
|
||||
content_hash=row.content_hash,
|
||||
parent_kind=row.parent_kind,
|
||||
parent_id=row.parent_id,
|
||||
parent_revision=row.parent_revision,
|
||||
recorded_at=row.recorded_at,
|
||||
change_reason=row.change_reason,
|
||||
payload=dict(row.payload or {}),
|
||||
)
|
||||
|
||||
|
||||
def _datetime_text(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
__all__ = ["ReportingDefinitionRecord", "definition_from_row"]
|
||||
@@ -0,0 +1,388 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_reporting.backend.db.models import (
|
||||
ReportingDrillContext,
|
||||
ReportingExecution,
|
||||
)
|
||||
from govoplan_reporting.backend.definitions import get_definition
|
||||
from govoplan_reporting.backend.execution import (
|
||||
ReportingExecutionError,
|
||||
_apply_row_policy,
|
||||
_read_dataset,
|
||||
_validate_schema,
|
||||
get_execution,
|
||||
)
|
||||
from govoplan_reporting.backend.postgres_planner import execute_postgres_query
|
||||
from govoplan_reporting.backend.query_engine import execute_semantic_query
|
||||
from govoplan_reporting.backend.schemas import (
|
||||
DatasetDefinition,
|
||||
FilterClause,
|
||||
ReportDefinition,
|
||||
ReportQuery,
|
||||
SemanticModelDefinition,
|
||||
)
|
||||
|
||||
|
||||
DRILL_CONTEXT_TTL = timedelta(minutes=20)
|
||||
|
||||
|
||||
class ReportingDrillError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def create_drill_context(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
execution_id: str,
|
||||
aggregate_row: Mapping[str, object],
|
||||
limit: int,
|
||||
) -> dict[str, object]:
|
||||
execution_payload = get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
if execution_payload is None or execution_payload.get("status") != "succeeded":
|
||||
raise LookupError("Successful Reporting execution not found.")
|
||||
row = _execution(session, _tenant(principal), execution_id)
|
||||
normalized_aggregate = _json_value(dict(aggregate_row))
|
||||
if normalized_aggregate not in [
|
||||
_json_value(dict(item)) for item in row.result_rows or []
|
||||
]:
|
||||
raise ReportingDrillError(
|
||||
"The selected aggregate row does not belong to this execution."
|
||||
)
|
||||
semantic_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="semantic_model",
|
||||
definition_id=row.semantic_model_id,
|
||||
revision=row.semantic_model_revision,
|
||||
)
|
||||
if semantic_record is None:
|
||||
raise PermissionError("The report semantic model is no longer accessible.")
|
||||
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
|
||||
query = ReportQuery.model_validate(row.query or {})
|
||||
dimension_keys = _drill_dimensions(query, semantic)
|
||||
dimension_map = {item.key: item for item in semantic.dimensions}
|
||||
path = [
|
||||
{
|
||||
"dimension": key,
|
||||
"label": dimension_map[key].label,
|
||||
"value": normalized_aggregate.get(key),
|
||||
}
|
||||
for key in dimension_keys
|
||||
if key in normalized_aggregate
|
||||
]
|
||||
if not path:
|
||||
raise ReportingDrillError(
|
||||
"This aggregate has no dimension path to drill through."
|
||||
)
|
||||
bounded_limit = max(1, min(int(limit), 500))
|
||||
actor_id = _actor(principal)
|
||||
if not actor_id:
|
||||
raise ReportingDrillError("Drill-through requires an accountable actor.")
|
||||
drill_context_id = str(uuid.uuid4())
|
||||
secret = secrets.token_urlsafe(32)
|
||||
token = f"{drill_context_id}.{secret}"
|
||||
context = {
|
||||
"execution_id": execution_id,
|
||||
"output_hash": row.output_hash,
|
||||
"actor_id": actor_id,
|
||||
"dimension_path": path,
|
||||
"source_fingerprints": list(row.source_fingerprints or []),
|
||||
"limit": bounded_limit,
|
||||
}
|
||||
item = ReportingDrillContext(
|
||||
tenant_id=row.tenant_id,
|
||||
drill_context_id=drill_context_id,
|
||||
execution_id=execution_id,
|
||||
token_sha256=_sha256(token),
|
||||
context_sha256=_sha256(context),
|
||||
actor_id=actor_id,
|
||||
dimension_path=path,
|
||||
source_fingerprints=list(row.source_fingerprints or []),
|
||||
policy_provenance=dict(
|
||||
execution_payload.get("delivery_authorization") or {}
|
||||
),
|
||||
expires_at=utc_now() + DRILL_CONTEXT_TTL,
|
||||
)
|
||||
item.policy_provenance["limit"] = bounded_limit
|
||||
session.add(item)
|
||||
session.flush()
|
||||
return {
|
||||
"token": token,
|
||||
"drill_context_id": drill_context_id,
|
||||
"execution_id": execution_id,
|
||||
"dimension_path": path,
|
||||
"expires_at": _datetime_text(item.expires_at),
|
||||
}
|
||||
|
||||
|
||||
def resolve_drill_context(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
token: str,
|
||||
) -> dict[str, object]:
|
||||
context_id, separator, _secret = token.partition(".")
|
||||
if not separator or not context_id:
|
||||
raise ReportingDrillError("The drill-through context token is invalid.")
|
||||
item = (
|
||||
session.query(ReportingDrillContext)
|
||||
.filter(
|
||||
ReportingDrillContext.tenant_id == _tenant(principal),
|
||||
ReportingDrillContext.drill_context_id == context_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if item is None or not hmac.compare_digest(item.token_sha256, _sha256(token)):
|
||||
raise LookupError("Reporting drill-through context not found.")
|
||||
if item.actor_id != _actor(principal):
|
||||
raise PermissionError(
|
||||
"This drill-through context belongs to another account."
|
||||
)
|
||||
if _aware(item.expires_at) <= utc_now():
|
||||
raise ReportingDrillError("The drill-through context has expired.")
|
||||
row = _execution(session, item.tenant_id, item.execution_id)
|
||||
expected_context = {
|
||||
"execution_id": row.execution_id,
|
||||
"output_hash": row.output_hash,
|
||||
"actor_id": item.actor_id,
|
||||
"dimension_path": list(item.dimension_path or []),
|
||||
"source_fingerprints": list(item.source_fingerprints or []),
|
||||
"limit": int((item.policy_provenance or {}).get("limit", 200)),
|
||||
}
|
||||
if not hmac.compare_digest(item.context_sha256, _sha256(expected_context)):
|
||||
raise ReportingDrillError(
|
||||
"The persisted drill-through context failed its integrity check."
|
||||
)
|
||||
execution_payload = get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=row.execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
if execution_payload is None:
|
||||
raise LookupError("Reporting execution not found.")
|
||||
report_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="report",
|
||||
definition_id=row.report_id,
|
||||
revision=row.report_revision,
|
||||
)
|
||||
semantic_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="semantic_model",
|
||||
definition_id=row.semantic_model_id,
|
||||
revision=row.semantic_model_revision,
|
||||
)
|
||||
dataset_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="dataset",
|
||||
definition_id=row.dataset_id,
|
||||
revision=row.dataset_revision,
|
||||
)
|
||||
if report_record is None or semantic_record is None or dataset_record is None:
|
||||
raise PermissionError(
|
||||
"The report source graph is no longer accessible for drill-through."
|
||||
)
|
||||
report = ReportDefinition.model_validate(report_record.payload)
|
||||
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
|
||||
dataset = DatasetDefinition.model_validate(dataset_record.payload)
|
||||
source = _read_dataset(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
dataset=dataset,
|
||||
parameters=dict(row.parameters or {}),
|
||||
)
|
||||
if not _fingerprints_equal(
|
||||
item.source_fingerprints or [], source.source_fingerprints
|
||||
):
|
||||
raise ReportingExecutionError(
|
||||
"The source fingerprint changed after the aggregate execution; run the report again before drilling through."
|
||||
)
|
||||
normalized_rows = tuple(_json_value(dict(source_row)) for source_row in source.rows)
|
||||
_validate_schema(dataset, normalized_rows)
|
||||
authorized_rows, row_policy = _apply_row_policy(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
dataset_id=dataset_record.definition_id,
|
||||
dataset_revision=dataset_record.revision,
|
||||
dataset=dataset,
|
||||
rows=normalized_rows,
|
||||
)
|
||||
original = ReportQuery.model_validate(row.query or {})
|
||||
hidden_dimensions = _strings(report.access_policy.get("hidden_dimensions"))
|
||||
visible_dimensions = [
|
||||
dimension.key
|
||||
for dimension in semantic.dimensions
|
||||
if dimension.key not in hidden_dimensions
|
||||
]
|
||||
filters = list(original.filters)
|
||||
filters.extend(
|
||||
FilterClause(
|
||||
dimension=str(path_item["dimension"]),
|
||||
operator="eq",
|
||||
value=path_item.get("value"),
|
||||
)
|
||||
for path_item in item.dimension_path or []
|
||||
)
|
||||
detail_query = ReportQuery(
|
||||
mode="detail",
|
||||
dimensions=visible_dimensions,
|
||||
filters=filters,
|
||||
limit=int((item.policy_provenance or {}).get("limit", 200)),
|
||||
)
|
||||
result = execute_postgres_query(
|
||||
session,
|
||||
rows=authorized_rows,
|
||||
dataset=dataset,
|
||||
semantic_model=semantic,
|
||||
query=detail_query,
|
||||
) or execute_semantic_query(authorized_rows, semantic, detail_query)
|
||||
item.last_accessed_at = utc_now()
|
||||
item.policy_provenance = {
|
||||
**dict(item.policy_provenance or {}),
|
||||
"resolved_row_policy": dict(row_policy),
|
||||
"delivery_authorization": dict(
|
||||
execution_payload.get("delivery_authorization") or {}
|
||||
),
|
||||
}
|
||||
session.flush()
|
||||
return {
|
||||
"drill_context_id": item.drill_context_id,
|
||||
"execution_id": item.execution_id,
|
||||
"dimension_path": list(item.dimension_path or []),
|
||||
"rows": list(result.rows),
|
||||
"schema": list(result.schema),
|
||||
"total_rows": result.total_rows,
|
||||
"truncated": result.truncated or source.truncated,
|
||||
"source_fingerprints": list(source.source_fingerprints),
|
||||
"policy_provenance": dict(item.policy_provenance or {}),
|
||||
"expires_at": _datetime_text(item.expires_at),
|
||||
}
|
||||
|
||||
|
||||
def _drill_dimensions(
|
||||
query: ReportQuery,
|
||||
semantic: SemanticModelDefinition,
|
||||
) -> tuple[str, ...]:
|
||||
if query.mode == "pivot" and query.pivot is not None:
|
||||
return tuple(dict.fromkeys((*query.pivot.rows, *query.pivot.columns)))
|
||||
return tuple(query.dimensions or semantic.default_dimensions)
|
||||
|
||||
|
||||
def _execution(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
execution_id: str,
|
||||
) -> ReportingExecution:
|
||||
row = (
|
||||
session.query(ReportingExecution)
|
||||
.filter(
|
||||
ReportingExecution.tenant_id == tenant_id,
|
||||
ReportingExecution.execution_id == execution_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
raise LookupError("Reporting execution not found.")
|
||||
return row
|
||||
|
||||
|
||||
def _fingerprints_equal(
|
||||
expected: Sequence[Mapping[str, object]],
|
||||
actual: Sequence[Mapping[str, object]],
|
||||
) -> bool:
|
||||
normalize = lambda values: sorted( # noqa: E731 - compact canonicalizer
|
||||
json.dumps(
|
||||
_json_value(dict(item)),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
)
|
||||
for item in values
|
||||
)
|
||||
return normalize(expected) == normalize(actual)
|
||||
|
||||
|
||||
def _tenant(principal: object) -> str:
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id:
|
||||
raise ReportingDrillError("Drill-through requires a tenant-bound principal.")
|
||||
return tenant_id
|
||||
|
||||
|
||||
def _actor(principal: object) -> str | None:
|
||||
for value in (
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
):
|
||||
if str(value or "").strip():
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def _strings(value: object) -> set[str]:
|
||||
if not isinstance(value, (list, tuple, set, frozenset)):
|
||||
return set()
|
||||
return {str(item) for item in value if str(item).strip()}
|
||||
|
||||
|
||||
def _sha256(value: object) -> str:
|
||||
payload = value if isinstance(value, str) else json.dumps(
|
||||
_json_value(value),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _json_value(value: object) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return _aware(value).isoformat()
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _datetime_text(value: datetime) -> str:
|
||||
return _aware(value).isoformat()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DRILL_CONTEXT_TTL",
|
||||
"ReportingDrillError",
|
||||
"create_drill_context",
|
||||
"resolve_drill_context",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Literal, cast
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.policy import (
|
||||
DefinitionGovernanceAction,
|
||||
DefinitionGovernanceRequest,
|
||||
DefinitionScopeRef,
|
||||
PolicyDecision,
|
||||
PolicySourceStep,
|
||||
definition_governance_policy,
|
||||
)
|
||||
from govoplan_reporting.backend.domain import ReportingDefinitionRecord
|
||||
from govoplan_reporting.backend.schemas import DefinitionGovernance
|
||||
|
||||
|
||||
_LIMITS = (
|
||||
"inherit_to_lower_scopes",
|
||||
"allow_run",
|
||||
"allow_reuse",
|
||||
"allow_automation",
|
||||
)
|
||||
_SCOPE_RANK = {"system": 0, "tenant": 1, "group": 2, "user": 3}
|
||||
|
||||
|
||||
class ReportingGovernanceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_definition_governance(
|
||||
payload: Mapping[str, object],
|
||||
principal: object,
|
||||
*,
|
||||
administrative: bool,
|
||||
) -> dict[str, object]:
|
||||
result = dict(payload)
|
||||
raw = result.get("governance")
|
||||
governance = DefinitionGovernance.model_validate(
|
||||
raw if isinstance(raw, Mapping) else {}
|
||||
)
|
||||
scope_type = governance.scope_type
|
||||
scope_id = str(governance.scope_id or "").strip() or None
|
||||
tenant_id = _tenant(principal)
|
||||
if scope_type == "system":
|
||||
if not _has_scope(principal, "system:governance:write"):
|
||||
raise PermissionError(
|
||||
"System Reporting definitions require system governance permission."
|
||||
)
|
||||
elif scope_type == "tenant":
|
||||
if scope_id not in {None, tenant_id}:
|
||||
raise PermissionError(
|
||||
"Reporting definitions can only target the active tenant."
|
||||
)
|
||||
scope_id = tenant_id
|
||||
elif scope_type == "group":
|
||||
if scope_id not in _string_set(getattr(principal, "group_ids", ())):
|
||||
if not administrative:
|
||||
raise PermissionError(
|
||||
"Group Reporting definitions require membership in that group."
|
||||
)
|
||||
elif scope_type == "user":
|
||||
own_ids = {
|
||||
str(getattr(principal, "account_id", "") or ""),
|
||||
str(getattr(principal, "membership_id", "") or ""),
|
||||
}
|
||||
if scope_id not in own_ids and not administrative:
|
||||
raise PermissionError(
|
||||
"User Reporting definitions can only target the current account."
|
||||
)
|
||||
if scope_id == str(getattr(principal, "membership_id", "") or ""):
|
||||
scope_id = str(getattr(principal, "account_id", "") or "")
|
||||
effective = _effective_limits(governance)
|
||||
result["governance"] = governance.model_copy(
|
||||
update={
|
||||
"scope_id": scope_id,
|
||||
"inherit_to_lower_scopes": effective["inherit_to_lower_scopes"],
|
||||
"allow_run": effective["allow_run"],
|
||||
"allow_reuse": effective["allow_reuse"],
|
||||
"allow_automation": effective["allow_automation"],
|
||||
"source_effective_limits": dict(effective),
|
||||
}
|
||||
).model_dump(mode="json")
|
||||
return result
|
||||
|
||||
|
||||
def validate_parent_governance(
|
||||
child_payload: Mapping[str, object],
|
||||
parent_payload: Mapping[str, object],
|
||||
) -> None:
|
||||
child = _governance(child_payload)
|
||||
parent = _governance(parent_payload)
|
||||
child_scope = _scope(child)
|
||||
parent_scope = _scope(parent)
|
||||
if _SCOPE_RANK[child_scope.scope_type] < _SCOPE_RANK[parent_scope.scope_type]:
|
||||
raise ReportingGovernanceError(
|
||||
"A Reporting definition cannot broaden the scope of its parent."
|
||||
)
|
||||
if child_scope != parent_scope and not parent.inherit_to_lower_scopes:
|
||||
raise ReportingGovernanceError(
|
||||
"The parent Reporting definition is not inherited by lower scopes."
|
||||
)
|
||||
parent_limits = _effective_limits(parent)
|
||||
child_limits = _effective_limits(child)
|
||||
broadened = [key for key in _LIMITS if child_limits[key] and not parent_limits[key]]
|
||||
if broadened:
|
||||
raise ReportingGovernanceError(
|
||||
"A child Reporting definition cannot broaden inherited limits: "
|
||||
+ ", ".join(sorted(broadened))
|
||||
)
|
||||
|
||||
|
||||
def apply_parent_governance(
|
||||
child_payload: Mapping[str, object],
|
||||
parent_payload: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Persist the effective parent restriction and its immediate provenance."""
|
||||
|
||||
validate_parent_governance(child_payload, parent_payload)
|
||||
child = _governance(child_payload)
|
||||
parent = _governance(parent_payload)
|
||||
parent_limits = _effective_limits(parent)
|
||||
effective = {
|
||||
key: bool(getattr(child, key)) and parent_limits[key] for key in _LIMITS
|
||||
}
|
||||
parent_scope = {
|
||||
"scope_type": parent.scope_type,
|
||||
"scope_id": parent.scope_id,
|
||||
}
|
||||
if parent.source_scope:
|
||||
parent_scope["inherited_from"] = dict(parent.source_scope)
|
||||
result = dict(child_payload)
|
||||
result["governance"] = child.model_copy(
|
||||
update={
|
||||
"inherit_to_lower_scopes": effective["inherit_to_lower_scopes"],
|
||||
"allow_run": effective["allow_run"],
|
||||
"allow_reuse": effective["allow_reuse"],
|
||||
"allow_automation": effective["allow_automation"],
|
||||
"source_scope": parent_scope,
|
||||
"source_effective_limits": effective,
|
||||
"derivation_provenance": {
|
||||
**dict(child.derivation_provenance),
|
||||
"parent_scope": parent_scope,
|
||||
"restriction_mode": "intersection",
|
||||
},
|
||||
}
|
||||
).model_dump(mode="json")
|
||||
return result
|
||||
|
||||
|
||||
def definition_decision(
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
record: ReportingDefinitionRecord,
|
||||
action: DefinitionGovernanceAction,
|
||||
) -> PolicyDecision:
|
||||
governance = _governance(record.payload)
|
||||
source = _scope(governance)
|
||||
target = _target_scope(source, principal)
|
||||
request = DefinitionGovernanceRequest(
|
||||
module_id="reporting",
|
||||
definition_ref=f"{record.definition_kind}:{record.definition_id}:{record.revision}",
|
||||
tenant_id=_tenant(principal),
|
||||
definition_scope=source,
|
||||
target_scope=target,
|
||||
definition_kind=cast(Literal["flow", "template"], "flow"),
|
||||
action=action,
|
||||
actor=_principal_ref(principal),
|
||||
status=record.status,
|
||||
inherit_to_lower_scopes=governance.inherit_to_lower_scopes,
|
||||
allow_run=governance.allow_run,
|
||||
allow_reuse=governance.allow_reuse,
|
||||
allow_automation=governance.allow_automation,
|
||||
context={
|
||||
"ancestor_limits": dict(governance.source_effective_limits),
|
||||
"ancestor_source": dict(governance.source_scope or {}),
|
||||
"reporting_definition_kind": record.definition_kind,
|
||||
},
|
||||
)
|
||||
provider = definition_governance_policy(registry)
|
||||
if provider is not None:
|
||||
return provider.resolve_definition_action(session, request=request)
|
||||
return _fallback_decision(request)
|
||||
|
||||
|
||||
def require_definition_action(
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
record: ReportingDefinitionRecord,
|
||||
action: DefinitionGovernanceAction,
|
||||
) -> PolicyDecision:
|
||||
decision = definition_decision(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
record=record,
|
||||
action=action,
|
||||
)
|
||||
if not decision.allowed:
|
||||
raise PermissionError(
|
||||
decision.reason or f"Reporting definition action is denied: {action}."
|
||||
)
|
||||
return decision
|
||||
|
||||
|
||||
def governance_payload(payload: Mapping[str, object]) -> dict[str, object]:
|
||||
governance = _governance(payload)
|
||||
return {
|
||||
**governance.model_dump(mode="json"),
|
||||
"effective_limits": _effective_limits(governance),
|
||||
}
|
||||
|
||||
|
||||
def scope_visible(payload: Mapping[str, object], principal: object) -> bool:
|
||||
governance = _governance(payload)
|
||||
scope = _scope(governance)
|
||||
if scope.scope_type == "system":
|
||||
return governance.inherit_to_lower_scopes or _has_scope(
|
||||
principal, "reporting:definition:admin"
|
||||
)
|
||||
if scope.scope_type == "tenant":
|
||||
return scope.scope_id in {None, _tenant(principal)}
|
||||
if scope.scope_type == "group":
|
||||
return scope.scope_id in _string_set(getattr(principal, "group_ids", ()))
|
||||
return scope.scope_id in {
|
||||
str(getattr(principal, "account_id", "") or ""),
|
||||
str(getattr(principal, "membership_id", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _fallback_decision(request: DefinitionGovernanceRequest) -> PolicyDecision:
|
||||
source = request.definition_scope
|
||||
target = request.target_scope
|
||||
same_scope = source == target
|
||||
inherited = (
|
||||
_SCOPE_RANK[target.scope_type] >= _SCOPE_RANK[source.scope_type]
|
||||
and request.inherit_to_lower_scopes
|
||||
)
|
||||
visible = same_scope or inherited
|
||||
if request.action == "view":
|
||||
allowed = visible
|
||||
elif request.action == "edit":
|
||||
allowed = same_scope
|
||||
elif request.action == "run":
|
||||
allowed = visible and request.status == "active" and request.allow_run
|
||||
elif request.action == "reuse":
|
||||
allowed = visible and request.allow_reuse
|
||||
elif request.action == "automate":
|
||||
allowed = visible and request.allow_automation
|
||||
else:
|
||||
allowed = visible and request.allow_reuse
|
||||
reason = (
|
||||
None
|
||||
if allowed
|
||||
else (
|
||||
"The Reporting definition's scope or inherited limits do not allow this action."
|
||||
)
|
||||
)
|
||||
return PolicyDecision(
|
||||
allowed=allowed,
|
||||
reason=reason,
|
||||
source_path=(
|
||||
PolicySourceStep(
|
||||
scope_type=source.scope_type,
|
||||
scope_id=source.scope_id,
|
||||
label="Reporting definition governance",
|
||||
applied_fields=_LIMITS,
|
||||
policy={
|
||||
"inherit_to_lower_scopes": request.inherit_to_lower_scopes,
|
||||
"allow_run": request.allow_run,
|
||||
"allow_reuse": request.allow_reuse,
|
||||
"allow_automation": request.allow_automation,
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=() if allowed else (f"reporting.definition.{request.action}",),
|
||||
details={
|
||||
"provider": "reporting.conservative_fallback",
|
||||
"definition_scope": source.path,
|
||||
"target_scope": target.path,
|
||||
"action": request.action,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _governance(payload: Mapping[str, object]) -> DefinitionGovernance:
|
||||
raw = payload.get("governance")
|
||||
return DefinitionGovernance.model_validate(raw if isinstance(raw, Mapping) else {})
|
||||
|
||||
|
||||
def _scope(governance: DefinitionGovernance) -> DefinitionScopeRef:
|
||||
return DefinitionScopeRef(
|
||||
scope_type=governance.scope_type,
|
||||
scope_id=governance.scope_id,
|
||||
)
|
||||
|
||||
|
||||
def _target_scope(source: DefinitionScopeRef, principal: object) -> DefinitionScopeRef:
|
||||
if source.scope_type == "group" and source.scope_id in _string_set(
|
||||
getattr(principal, "group_ids", ())
|
||||
):
|
||||
return source
|
||||
own_ids = {
|
||||
str(getattr(principal, "account_id", "") or ""),
|
||||
str(getattr(principal, "membership_id", "") or ""),
|
||||
}
|
||||
if source.scope_type == "user" and source.scope_id in own_ids:
|
||||
return source
|
||||
return DefinitionScopeRef("tenant", _tenant(principal))
|
||||
|
||||
|
||||
def _effective_limits(governance: DefinitionGovernance) -> dict[str, bool]:
|
||||
source = governance.source_effective_limits
|
||||
return {
|
||||
key: bool(getattr(governance, key)) and source.get(key, True) is True
|
||||
for key in _LIMITS
|
||||
}
|
||||
|
||||
|
||||
def _principal_ref(principal: object) -> PrincipalRef:
|
||||
converter = getattr(principal, "to_platform_principal", None)
|
||||
if callable(converter):
|
||||
converted = converter()
|
||||
if isinstance(converted, PrincipalRef):
|
||||
return converted
|
||||
return PrincipalRef(
|
||||
account_id=str(getattr(principal, "account_id", "") or "system"),
|
||||
membership_id=_optional(getattr(principal, "membership_id", None)),
|
||||
tenant_id=_tenant(principal),
|
||||
identity_id=_optional(getattr(principal, "identity_id", None)),
|
||||
scopes=frozenset(_string_set(getattr(principal, "scopes", ()))),
|
||||
group_ids=frozenset(_string_set(getattr(principal, "group_ids", ()))),
|
||||
role_ids=frozenset(_string_set(getattr(principal, "role_ids", ()))),
|
||||
function_assignment_ids=frozenset(
|
||||
_string_set(getattr(principal, "function_assignment_ids", ()))
|
||||
),
|
||||
service_account_id=_optional(getattr(principal, "service_account_id", None)),
|
||||
acting_assignment_id=_optional(
|
||||
getattr(principal, "acting_assignment_id", None)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _has_scope(principal: object, scope: str) -> bool:
|
||||
method = getattr(principal, "has", None)
|
||||
if callable(method):
|
||||
return bool(method(scope))
|
||||
return scope in _string_set(getattr(principal, "scopes", ()))
|
||||
|
||||
|
||||
def _tenant(principal: object) -> str:
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id:
|
||||
raise ReportingGovernanceError(
|
||||
"Reporting governance requires a tenant-bound principal."
|
||||
)
|
||||
return tenant_id
|
||||
|
||||
|
||||
def _string_set(value: object) -> set[str]:
|
||||
if isinstance(value, (str, bytes)):
|
||||
return {str(value)} if value else set()
|
||||
try:
|
||||
return {str(item) for item in value or () if str(item).strip()} # type: ignore[union-attr]
|
||||
except TypeError:
|
||||
return set()
|
||||
|
||||
|
||||
def _optional(value: object) -> str | None:
|
||||
clean = str(value or "").strip()
|
||||
return clean or None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ReportingGovernanceError",
|
||||
"apply_parent_governance",
|
||||
"definition_decision",
|
||||
"governance_payload",
|
||||
"normalize_definition_governance",
|
||||
"require_definition_action",
|
||||
"scope_visible",
|
||||
"validate_parent_governance",
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ReportingGovernanceError",
|
||||
"definition_decision",
|
||||
"governance_payload",
|
||||
"normalize_definition_governance",
|
||||
"require_definition_action",
|
||||
"scope_visible",
|
||||
"validate_parent_governance",
|
||||
]
|
||||
@@ -0,0 +1,571 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_DATASET_OUTPUT
|
||||
from govoplan_core.core.files import CAPABILITY_FILES_ARTIFACT_STORE
|
||||
from govoplan_core.core.mail import CAPABILITY_MAIL_NOTIFICATION_DELIVERY
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleArchitectureDocumentation,
|
||||
ModuleMaturityEvidence,
|
||||
)
|
||||
from govoplan_core.core.reporting import (
|
||||
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
|
||||
CAPABILITY_REPORTING_RETENTION,
|
||||
)
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_reporting.backend.acl import ReportingScopeAclProvider
|
||||
from govoplan_reporting.backend.contracts import (
|
||||
CAPABILITY_REPORTING_CHART_RENDERER,
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||
CAPABILITY_REPORTING_PUBLICATION_MAIL,
|
||||
CAPABILITY_REPORTING_REGISTRY,
|
||||
CAPABILITY_REPORTING_RUNNER,
|
||||
CAPABILITY_REPORTING_SCHEDULER,
|
||||
)
|
||||
from govoplan_reporting.backend.db import models as reporting_models
|
||||
from govoplan_reporting.backend.definitions import (
|
||||
ADMIN_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
)
|
||||
from govoplan_reporting.backend.execution import (
|
||||
QUALITY_SCOPE,
|
||||
RUN_SCOPE,
|
||||
SqlReportingRunner,
|
||||
)
|
||||
from govoplan_reporting.backend.operations import (
|
||||
IMPORT_SCOPE,
|
||||
PUBLISH_SCOPE,
|
||||
SCHEDULE_SCOPE,
|
||||
SqlReportingScheduler,
|
||||
)
|
||||
from govoplan_reporting.backend.query_engine import DefaultChartRenderer
|
||||
from govoplan_reporting.backend.publication_targets import (
|
||||
FilesReportingPublicationTarget,
|
||||
MailReportingPublicationTarget,
|
||||
)
|
||||
from govoplan_reporting.backend.registry import SqlReportingRegistry
|
||||
from govoplan_reporting.backend.search_source import create_reporting_search_source
|
||||
|
||||
|
||||
MODULE_ID = "reporting"
|
||||
MODULE_NAME = "Reporting"
|
||||
MODULE_VERSION = "0.1.18"
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category=MODULE_NAME,
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(
|
||||
READ_SCOPE,
|
||||
"View reporting definitions",
|
||||
"Read accessible datasets, semantic models, reports, and quality plans.",
|
||||
),
|
||||
_permission(
|
||||
WRITE_SCOPE,
|
||||
"Manage reporting definitions",
|
||||
"Create immutable revisions of Reporting definitions.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE,
|
||||
"Administer reporting",
|
||||
"Manage restricted definitions and Reporting governance.",
|
||||
),
|
||||
_permission(
|
||||
RUN_SCOPE,
|
||||
"Run reports",
|
||||
"Execute accessible report revisions and export their authorized result.",
|
||||
),
|
||||
_permission(
|
||||
PUBLISH_SCOPE,
|
||||
"Publish reports",
|
||||
"Send successful report results to configured publication providers.",
|
||||
),
|
||||
_permission(
|
||||
SCHEDULE_SCOPE,
|
||||
"Schedule reports",
|
||||
"Create schedules and dispatch due report runs.",
|
||||
),
|
||||
_permission(
|
||||
QUALITY_SCOPE,
|
||||
"Run report quality plans",
|
||||
"Evaluate dataset quality plans and inspect evidence.",
|
||||
),
|
||||
_permission(
|
||||
IMPORT_SCOPE,
|
||||
"Assess report imports",
|
||||
"Assess external BI metadata and accept bounded approximations.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="reporting_analyst",
|
||||
name="Reporting analyst",
|
||||
description="Define semantic reports, run them, and save analytical views.",
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE, RUN_SCOPE, QUALITY_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="reporting_publisher",
|
||||
name="Reporting publisher",
|
||||
description="Run, schedule, export, and publish accessible reports.",
|
||||
permissions=(READ_SCOPE, RUN_SCOPE, PUBLISH_SCOPE, SCHEDULE_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="reporting_administrator",
|
||||
name="Reporting administrator",
|
||||
description="Administer definitions, imports, quality, schedules, and publications.",
|
||||
permissions=tuple(item.scope for item in PERMISSIONS),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
from govoplan_reporting.backend.router import create_router
|
||||
|
||||
return create_router(context.registry)
|
||||
|
||||
|
||||
def _registry(context: ModuleContext) -> SqlReportingRegistry:
|
||||
del context
|
||||
return SqlReportingRegistry()
|
||||
|
||||
|
||||
def _runner(context: ModuleContext) -> SqlReportingRunner:
|
||||
return SqlReportingRunner(context.registry)
|
||||
|
||||
|
||||
def _scheduler(context: ModuleContext) -> SqlReportingScheduler:
|
||||
return SqlReportingScheduler(context.registry)
|
||||
|
||||
|
||||
def _chart_renderer(context: ModuleContext) -> DefaultChartRenderer:
|
||||
del context
|
||||
return DefaultChartRenderer()
|
||||
|
||||
|
||||
def _files_publication(context: ModuleContext) -> FilesReportingPublicationTarget:
|
||||
return FilesReportingPublicationTarget(context.registry)
|
||||
|
||||
|
||||
def _mail_publication(context: ModuleContext) -> MailReportingPublicationTarget:
|
||||
return MailReportingPublicationTarget(context.registry)
|
||||
|
||||
|
||||
def _retention(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_reporting.backend.retention import ReportingRetentionService
|
||||
|
||||
return ReportingRetentionService()
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
definitions = (
|
||||
session.query(reporting_models.ReportingDefinitionRevision)
|
||||
.filter(
|
||||
reporting_models.ReportingDefinitionRevision.tenant_id == tenant_id,
|
||||
reporting_models.ReportingDefinitionRevision.superseded_at.is_(None),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
reports = (
|
||||
session.query(reporting_models.ReportingDefinitionRevision)
|
||||
.filter(
|
||||
reporting_models.ReportingDefinitionRevision.tenant_id == tenant_id,
|
||||
reporting_models.ReportingDefinitionRevision.definition_kind == "report",
|
||||
reporting_models.ReportingDefinitionRevision.superseded_at.is_(None),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
executions = (
|
||||
session.query(reporting_models.ReportingExecution)
|
||||
.filter(reporting_models.ReportingExecution.tenant_id == tenant_id)
|
||||
.count()
|
||||
)
|
||||
schedules = (
|
||||
session.query(reporting_models.ReportingSchedule)
|
||||
.filter(
|
||||
reporting_models.ReportingSchedule.tenant_id == tenant_id,
|
||||
reporting_models.ReportingSchedule.enabled.is_(True),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
return {
|
||||
"reporting_definitions": definitions,
|
||||
"reports": reports,
|
||||
"report_executions": executions,
|
||||
"active_report_schedules": schedules,
|
||||
}
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name=MODULE_NAME,
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("access",),
|
||||
optional_dependencies=(
|
||||
"dataflow",
|
||||
"datasources",
|
||||
"connectors",
|
||||
"dashboard",
|
||||
"files",
|
||||
"mail",
|
||||
"templates",
|
||||
"workflow_engine",
|
||||
"policy",
|
||||
"search",
|
||||
"notifications",
|
||||
),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
|
||||
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
|
||||
CAPABILITY_FILES_ARTIFACT_STORE,
|
||||
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_router,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/reports",
|
||||
label="Reporting",
|
||||
icon="clipboard-pen-line",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=74,
|
||||
surface_id="reporting.navigation",
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/reporting-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/reports",
|
||||
component="ReportingPage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=74,
|
||||
surface_id="reporting.workspace",
|
||||
),
|
||||
FrontendRoute(
|
||||
path="/reporting",
|
||||
component="ReportingPage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=175,
|
||||
surface_id="reporting.compatibility",
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/reports",
|
||||
label="Reporting",
|
||||
icon="clipboard-pen-line",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=74,
|
||||
surface_id="reporting.navigation",
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="reporting.parameters",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Report parameters and filters",
|
||||
parent_id="reporting.workspace",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
id="reporting.results",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Authorized report results",
|
||||
parent_id="reporting.workspace",
|
||||
order=40,
|
||||
),
|
||||
ViewSurface(
|
||||
id="reporting.widget.reports",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Reports dashboard widget",
|
||||
order=75,
|
||||
),
|
||||
),
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="reporting.registry", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="reporting.runner", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="reporting.scheduler", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="reporting.chart_renderer", version="0.1.0"),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_REPORTING_PUBLICATION_FILES, version="1.0.0"
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_REPORTING_PUBLICATION_MAIL, version="1.0.0"
|
||||
),
|
||||
ModuleInterfaceProvider(name=CAPABILITY_REPORTING_RETENTION, version="1.0.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
name="dataflow.dataset_output",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_POLICY_REPORTING_GOVERNANCE,
|
||||
version_min="1.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_FILES_ARTIFACT_STORE,
|
||||
version_min="0.1.14",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="mail.notification_delivery",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_REPORTING_REGISTRY: _registry,
|
||||
CAPABILITY_REPORTING_RUNNER: _runner,
|
||||
CAPABILITY_REPORTING_SCHEDULER: _scheduler,
|
||||
CAPABILITY_REPORTING_CHART_RENDERER: _chart_renderer,
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES: _files_publication,
|
||||
CAPABILITY_REPORTING_PUBLICATION_MAIL: _mail_publication,
|
||||
CAPABILITY_REPORTING_RETENTION: _retention,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_REPORTING_REGISTRY: CapabilityDocumentation(
|
||||
label="Reporting definition registry",
|
||||
summary="Stores versioned datasets, semantic models, reports, and quality plans.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
CAPABILITY_REPORTING_RUNNER: CapabilityDocumentation(
|
||||
label="Governed report runner",
|
||||
summary="Executes a pinned report graph over an authorized provider-owned dataset.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
CAPABILITY_REPORTING_SCHEDULER: CapabilityDocumentation(
|
||||
label="Report schedule dispatcher",
|
||||
summary="Claims due report schedules and records run/publication evidence.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
CAPABILITY_REPORTING_CHART_RENDERER: CapabilityDocumentation(
|
||||
label="Report chart renderer",
|
||||
summary="Builds provider-neutral chart models with an accessible tabular fallback.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES: CapabilityDocumentation(
|
||||
label="Files report publication",
|
||||
summary="Stores an immutable authorized report output through Files managed artifact storage.",
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
CAPABILITY_REPORTING_PUBLICATION_MAIL: CapabilityDocumentation(
|
||||
label="Mail report publication",
|
||||
summary="Submits an idempotent report notice through Mail's durable delivery outbox.",
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
CAPABILITY_REPORTING_RETENTION: CapabilityDocumentation(
|
||||
label="Reporting result retention",
|
||||
summary="Minimizes expired provider-report detail while retaining audit hashes and provenance.",
|
||||
contract_version="1.0",
|
||||
documentation_types=("admin",),
|
||||
audience=("privacy_officer", "operator", "system_admin"),
|
||||
),
|
||||
},
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
id="reporting.reports",
|
||||
factory=create_reporting_search_source,
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
reporting_models.ReportingImportAssessment,
|
||||
reporting_models.ReportingQualityResult,
|
||||
reporting_models.ReportingPublication,
|
||||
reporting_models.ReportingSchedule,
|
||||
reporting_models.ReportingSavedView,
|
||||
reporting_models.ReportingDefinitionGrant,
|
||||
reporting_models.ReportingExecution,
|
||||
reporting_models.ReportingDrillContext,
|
||||
reporting_models.ReportingProviderExport,
|
||||
reporting_models.ReportingProviderExecution,
|
||||
reporting_models.ReportingDefinitionRevision,
|
||||
reporting_models.ReportingDefinitionIdentity,
|
||||
label="Reporting",
|
||||
),
|
||||
retirement_notes=(
|
||||
"Destructive retirement requires a database snapshot and removes "
|
||||
"Reporting definitions, results, quality evidence, schedules, and publications."
|
||||
),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
reporting_models.ReportingDefinitionIdentity,
|
||||
reporting_models.ReportingDefinitionRevision,
|
||||
reporting_models.ReportingDefinitionGrant,
|
||||
reporting_models.ReportingExecution,
|
||||
reporting_models.ReportingDrillContext,
|
||||
reporting_models.ReportingProviderExecution,
|
||||
reporting_models.ReportingProviderExport,
|
||||
reporting_models.ReportingSavedView,
|
||||
reporting_models.ReportingSchedule,
|
||||
reporting_models.ReportingPublication,
|
||||
reporting_models.ReportingQualityResult,
|
||||
reporting_models.ReportingImportAssessment,
|
||||
label="Reporting",
|
||||
),
|
||||
),
|
||||
resource_acl_providers=(
|
||||
ReportingScopeAclProvider("analytical_dataset"),
|
||||
ReportingScopeAclProvider("semantic_model"),
|
||||
ReportingScopeAclProvider("report"),
|
||||
ReportingScopeAclProvider("report_execution"),
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="reporting.governed-bi",
|
||||
title="Governed reporting and semantic BI",
|
||||
summary="Build reproducible reports over provider-owned datasets without bypassing module or row-level access.",
|
||||
body=(
|
||||
"Reporting pins dataset, semantic-model, and report revisions. Runs retain "
|
||||
"definition hashes, source fingerprints, policy provenance, quality evidence, "
|
||||
"authorized result rows, diagnostics, and output hashes. Safe dimensions, "
|
||||
"aggregations, typed expressions, filters, pivots, saved views, chart models, "
|
||||
"schedules, exports, and publication providers replace unchecked SQL in the "
|
||||
"presentation layer. PostgreSQL executes bounded semantic plans when available. "
|
||||
"Signed drill contexts reauthorize contributor rows, and Files/Mail publication "
|
||||
"adapters retain idempotent evidence. A dataset may pin one successful published Dataflow run, which is read from its exact Datasource materialization after both source boundaries reauthorize the current principal. Dataflow and module read models remain source owners."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "product_owner"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Reporting module boundary",
|
||||
href="govoplan-reporting/docs/REPORTING_BOUNDARY.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="SuperX capability assessment",
|
||||
href="govoplan-reporting/docs/SUPERX_CAPABILITY_ASSESSMENT.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Reporting user guide",
|
||||
href="govoplan-reporting/docs/USER_GUIDE.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Reporting administration guide",
|
||||
href="govoplan-reporting/docs/ADMIN_GUIDE.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Reporting interface pattern audit",
|
||||
href="govoplan-reporting/docs/INTERFACE_PATTERN_MIGRATION.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
architecture=ModuleArchitectureDeclaration(
|
||||
layer="data_reporting_integration",
|
||||
kind="domain",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_reporting_service.py",
|
||||
summary="Proves revision pinning, safe semantic execution, quality gates, access, replay, exports, and import blocking.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/REPORTING_BOUNDARY.md",
|
||||
summary="Defines governed analytical source, semantic, execution, and publication ownership.",
|
||||
),
|
||||
),
|
||||
known_limits=(
|
||||
"Dataflow is the first live dataset adapter; additional module read models use the provider-neutral contract.",
|
||||
"Direct browser export supports CSV and JSON. Files supports CSV, JSON, and HTML publication; Mail submits a bounded report notice. XLSX/PDF require a renderer provider.",
|
||||
"Import assessment produces blocking diagnostics but does not execute source SQL or automatically activate generated definitions.",
|
||||
"The built-in chart catalogue covers bounded bar, column, line, area, pie, donut, and metric views; specialized visual renderers remain replaceable adapters.",
|
||||
),
|
||||
owned_concepts=(
|
||||
"analytical dataset binding",
|
||||
"semantic dimension hierarchy and measure",
|
||||
"report definition and saved view",
|
||||
"report execution and publication evidence",
|
||||
"report quality plan and import assessment",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"raw datasource ingestion",
|
||||
"data transformation pipeline",
|
||||
"source module authorization",
|
||||
"template document rendering",
|
||||
"file or DMS storage",
|
||||
),
|
||||
documentation=ModuleArchitectureDocumentation(
|
||||
operations=("docs/OPERATIONS.md",),
|
||||
recovery=("docs/OPERATIONS.md",),
|
||||
security=("docs/OPERATIONS.md",),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
@@ -0,0 +1 @@
|
||||
"""Reporting Alembic revisions."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Reporting migration versions."""
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
"""Add governed cross-module report executions and export history.
|
||||
|
||||
Revision ID: b7c4e1a9d2f6
|
||||
Revises: e5b2c9d4f7a1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b7c4e1a9d2f6"
|
||||
down_revision = "e5b2c9d4f7a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"reporting_provider_executions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("execution_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("provider_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("report_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("report_revision", sa.String(length=255), nullable=False),
|
||||
sa.Column("contract_version", sa.String(length=30), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("purpose", sa.Text(), nullable=False),
|
||||
sa.Column("audience_scope", sa.JSON(), nullable=False),
|
||||
sa.Column("parameters", sa.JSON(), nullable=False),
|
||||
sa.Column("result_schema", sa.JSON(), nullable=False),
|
||||
sa.Column("result_payload", sa.JSON(), nullable=False),
|
||||
sa.Column("source_revisions", sa.JSON(), nullable=False),
|
||||
sa.Column("effective_scope", sa.JSON(), nullable=False),
|
||||
sa.Column("privacy_transforms", sa.JSON(), nullable=False),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("governance_provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("retention_class", sa.String(length=120), nullable=False),
|
||||
sa.Column("retention_days", sa.Integer(), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("retention_redacted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("output_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_provider_executions")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"execution_id",
|
||||
name="uq_reporting_provider_execution",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_reporting_provider_execution_idempotency",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"reporting_provider_executions",
|
||||
"tenant_id",
|
||||
"execution_id",
|
||||
"provider_id",
|
||||
"report_id",
|
||||
"expires_at",
|
||||
"retention_redacted_at",
|
||||
"output_hash",
|
||||
"generated_at",
|
||||
"actor_id",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_provider_execution_history",
|
||||
"reporting_provider_executions",
|
||||
["tenant_id", "provider_id", "report_id", "generated_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_provider_execution_retention",
|
||||
"reporting_provider_executions",
|
||||
["tenant_id", "expires_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"reporting_provider_exports",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("export_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("provider_execution_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("execution_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("format", sa.String(length=30), nullable=False),
|
||||
sa.Column("purpose", sa.Text(), nullable=False),
|
||||
sa.Column("audience_scope", sa.JSON(), nullable=False),
|
||||
sa.Column("output_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("exported_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["provider_execution_id"],
|
||||
["reporting_provider_executions.id"],
|
||||
name=op.f(
|
||||
"fk_reporting_provider_exports_provider_execution_id_reporting_provider_executions"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_provider_exports")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"export_id",
|
||||
name="uq_reporting_provider_export",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"reporting_provider_exports",
|
||||
"tenant_id",
|
||||
"export_id",
|
||||
"provider_execution_id",
|
||||
"execution_id",
|
||||
"output_hash",
|
||||
"exported_at",
|
||||
"actor_id",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_provider_export_history",
|
||||
"reporting_provider_exports",
|
||||
["tenant_id", "provider_execution_id", "exported_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("reporting_provider_exports")
|
||||
op.drop_table("reporting_provider_executions")
|
||||
|
||||
|
||||
def _indexes(table: str, *columns: str) -> None:
|
||||
for column in columns:
|
||||
op.create_index(op.f(f"ix_{table}_{column}"), table, [column], unique=False)
|
||||
+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")
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
"""v0.1.14 governed Reporting baseline.
|
||||
|
||||
Revision ID: e5b2c9d4f7a1
|
||||
Revises: None
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "e5b2c9d4f7a1"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"reporting_definition_identities",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("definition_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("definition_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("definition_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_definition_identities")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_id",
|
||||
name="uq_reporting_definition_identity",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_key",
|
||||
name="uq_reporting_definition_key",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"reporting_definition_identities",
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_id",
|
||||
"definition_key",
|
||||
"created_by",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_definition_catalog",
|
||||
"reporting_definition_identities",
|
||||
["tenant_id", "definition_kind", "definition_key"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"reporting_definition_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("identity_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("definition_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("definition_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("definition_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("parent_kind", sa.String(length=40), nullable=True),
|
||||
sa.Column("parent_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("parent_revision", sa.Integer(), nullable=True),
|
||||
sa.Column("name", sa.String(length=500), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("visibility", sa.String(length=30), nullable=False),
|
||||
sa.Column("content_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("change_reason", sa.String(length=1000), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("event_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["identity_id"],
|
||||
["reporting_definition_identities.id"],
|
||||
name=op.f(
|
||||
"fk_reporting_definition_revisions_identity_id_reporting_definition_identities"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["previous_revision_id"],
|
||||
["reporting_definition_revisions.id"],
|
||||
name=op.f(
|
||||
"fk_reporting_definition_revisions_previous_revision_id_reporting_definition_revisions"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_definition_revisions")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_id",
|
||||
"revision",
|
||||
name="uq_reporting_definition_revision",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_reporting_definition_idempotency",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"event_id",
|
||||
name="uq_reporting_definition_event",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"reporting_definition_revisions",
|
||||
"tenant_id",
|
||||
"identity_id",
|
||||
"definition_kind",
|
||||
"definition_id",
|
||||
"definition_key",
|
||||
"previous_revision_id",
|
||||
"parent_kind",
|
||||
"parent_id",
|
||||
"status",
|
||||
"visibility",
|
||||
"content_hash",
|
||||
"event_id",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
"changed_by",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_definition_current",
|
||||
"reporting_definition_revisions",
|
||||
["tenant_id", "definition_kind", "definition_id", "superseded_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_definition_parent",
|
||||
"reporting_definition_revisions",
|
||||
["tenant_id", "parent_kind", "parent_id", "parent_revision"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_definition_list",
|
||||
"reporting_definition_revisions",
|
||||
["tenant_id", "definition_kind", "status", "recorded_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"reporting_definition_grants",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("definition_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("definition_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("subject_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("subject_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("permissions", sa.JSON(), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("source_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_definition_grants")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
name="uq_reporting_definition_grant",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"reporting_definition_grants",
|
||||
"tenant_id",
|
||||
"definition_kind",
|
||||
"definition_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
"active",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_definition_grant_subject",
|
||||
"reporting_definition_grants",
|
||||
["tenant_id", "subject_kind", "subject_id", "active"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_definition_grant_object",
|
||||
"reporting_definition_grants",
|
||||
["tenant_id", "definition_kind", "definition_id", "active"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"reporting_executions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("execution_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("report_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("report_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("semantic_model_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("semantic_model_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("dataset_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("dataset_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("parameters", sa.JSON(), nullable=False),
|
||||
sa.Column("query", sa.JSON(), nullable=False),
|
||||
sa.Column("source_fingerprints", sa.JSON(), nullable=False),
|
||||
sa.Column("definition_hashes", sa.JSON(), nullable=False),
|
||||
sa.Column("output_hash", sa.String(length=64), nullable=True),
|
||||
sa.Column("executor_version", sa.String(length=255), nullable=True),
|
||||
sa.Column("result_schema", sa.JSON(), nullable=False),
|
||||
sa.Column("result_rows", sa.JSON(), nullable=False),
|
||||
sa.Column("total_rows", sa.Integer(), nullable=False),
|
||||
sa.Column("truncated", sa.Boolean(), nullable=False),
|
||||
sa.Column("diagnostics", sa.JSON(), nullable=False),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_executions")),
|
||||
sa.UniqueConstraint("tenant_id", "execution_id", name="uq_reporting_execution"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_reporting_execution_idempotency",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"reporting_executions",
|
||||
"tenant_id",
|
||||
"execution_id",
|
||||
"report_id",
|
||||
"semantic_model_id",
|
||||
"dataset_id",
|
||||
"status",
|
||||
"output_hash",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"actor_id",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_execution_history",
|
||||
"reporting_executions",
|
||||
["tenant_id", "report_id", "started_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_execution_status",
|
||||
"reporting_executions",
|
||||
["tenant_id", "status", "started_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"reporting_saved_views",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("view_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("report_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("report_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("owner_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("owner_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("name", sa.String(length=500), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("state", sa.JSON(), nullable=False),
|
||||
sa.Column("shared", sa.Boolean(), nullable=False),
|
||||
sa.Column("access", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_saved_views")),
|
||||
sa.UniqueConstraint("tenant_id", "view_id", name="uq_reporting_saved_view"),
|
||||
)
|
||||
_indexes(
|
||||
"reporting_saved_views",
|
||||
"tenant_id",
|
||||
"view_id",
|
||||
"report_id",
|
||||
"owner_kind",
|
||||
"owner_id",
|
||||
"shared",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_saved_view_catalog",
|
||||
"reporting_saved_views",
|
||||
["tenant_id", "report_id", "owner_id", "shared"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"reporting_schedules",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("schedule_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("report_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("report_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=500), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("trigger_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("trigger_config", sa.JSON(), nullable=False),
|
||||
sa.Column("parameters", sa.JSON(), nullable=False),
|
||||
sa.Column("query", sa.JSON(), nullable=False),
|
||||
sa.Column("publication_target", sa.JSON(), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("next_run_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_execution_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_schedules")),
|
||||
sa.UniqueConstraint("tenant_id", "schedule_id", name="uq_reporting_schedule"),
|
||||
)
|
||||
_indexes(
|
||||
"reporting_schedules",
|
||||
"tenant_id",
|
||||
"schedule_id",
|
||||
"report_id",
|
||||
"enabled",
|
||||
"next_run_at",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_schedule_due",
|
||||
"reporting_schedules",
|
||||
["enabled", "next_run_at", "tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"reporting_publications",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("publication_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("execution_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("target_capability", sa.String(length=255), nullable=False),
|
||||
sa.Column("target_ref", sa.String(length=1000), nullable=True),
|
||||
sa.Column("format", sa.String(length=30), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_publications")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "publication_id", name="uq_reporting_publication"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_reporting_publication_idempotency",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"reporting_publications",
|
||||
"tenant_id",
|
||||
"publication_id",
|
||||
"execution_id",
|
||||
"status",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_publication_history",
|
||||
"reporting_publications",
|
||||
["tenant_id", "execution_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"reporting_quality_results",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("result_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("quality_plan_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("quality_plan_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("dataset_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("dataset_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("output_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("assertions", sa.JSON(), nullable=False),
|
||||
sa.Column("source_fingerprints", sa.JSON(), nullable=False),
|
||||
sa.Column("evaluated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_quality_results")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "result_id", name="uq_reporting_quality_result"
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"reporting_quality_results",
|
||||
"tenant_id",
|
||||
"result_id",
|
||||
"quality_plan_id",
|
||||
"dataset_id",
|
||||
"status",
|
||||
"output_hash",
|
||||
"evaluated_at",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_quality_history",
|
||||
"reporting_quality_results",
|
||||
["tenant_id", "quality_plan_id", "evaluated_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"reporting_import_assessments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("assessment_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_system", sa.String(length=255), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=500), nullable=False),
|
||||
sa.Column("source_fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column("mapping_report", sa.JSON(), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("accepted_approximations", sa.JSON(), nullable=False),
|
||||
sa.Column("assessed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_import_assessments")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"assessment_id",
|
||||
name="uq_reporting_import_assessment",
|
||||
),
|
||||
)
|
||||
_indexes(
|
||||
"reporting_import_assessments",
|
||||
"tenant_id",
|
||||
"assessment_id",
|
||||
"source_system",
|
||||
"status",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_import_history",
|
||||
"reporting_import_assessments",
|
||||
["tenant_id", "source_system", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("reporting_import_assessments")
|
||||
op.drop_table("reporting_quality_results")
|
||||
op.drop_table("reporting_publications")
|
||||
op.drop_table("reporting_schedules")
|
||||
op.drop_table("reporting_saved_views")
|
||||
op.drop_table("reporting_definition_grants")
|
||||
op.drop_table("reporting_executions")
|
||||
op.drop_table("reporting_definition_revisions")
|
||||
op.drop_table("reporting_definition_identities")
|
||||
|
||||
|
||||
def _indexes(table: str, *columns: str) -> None:
|
||||
for column in columns:
|
||||
op.create_index(
|
||||
op.f(f"ix_{table}_{column}"),
|
||||
table,
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
@@ -0,0 +1,904 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import csv
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from io import StringIO
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
EventTenantRef,
|
||||
PlatformEvent,
|
||||
emit_platform_event,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_reporting.backend.contracts import (
|
||||
ReportingPublicationPayload,
|
||||
ReportingPublicationTarget,
|
||||
capability,
|
||||
)
|
||||
from govoplan_reporting.backend.db.models import (
|
||||
ReportingImportAssessment,
|
||||
ReportingPublication,
|
||||
ReportingSavedView,
|
||||
ReportingSchedule,
|
||||
)
|
||||
from govoplan_reporting.backend.definitions import ADMIN_SCOPE, get_definition
|
||||
from govoplan_reporting.backend.execution import (
|
||||
ReportingExecutionFailure,
|
||||
execute_report,
|
||||
get_execution,
|
||||
)
|
||||
from govoplan_reporting.backend.schemas import ReportQuery
|
||||
|
||||
|
||||
PUBLISH_SCOPE = "reporting:report:publish"
|
||||
SCHEDULE_SCOPE = "reporting:schedule:write"
|
||||
IMPORT_SCOPE = "reporting:import:assess"
|
||||
|
||||
|
||||
class ReportingOperationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class SqlReportingScheduler:
|
||||
def __init__(self, registry: object | None) -> None:
|
||||
self.registry = registry
|
||||
|
||||
def dispatch_due(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
limit: int = 20,
|
||||
) -> Mapping[str, object]:
|
||||
return dispatch_due_schedules(
|
||||
_session(session),
|
||||
principal,
|
||||
registry=self.registry,
|
||||
now=now,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def upsert_saved_view(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
view_id: str,
|
||||
report_id: str,
|
||||
report_revision: int,
|
||||
name: str,
|
||||
state: Mapping[str, object],
|
||||
shared: bool,
|
||||
access: Mapping[str, object],
|
||||
expected_revision: int | None,
|
||||
) -> dict[str, object]:
|
||||
report = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="report",
|
||||
definition_id=report_id,
|
||||
revision=report_revision,
|
||||
)
|
||||
if report is None:
|
||||
raise LookupError("Reporting report definition not found.")
|
||||
_validate_saved_view_state(state)
|
||||
owner_id = _actor(principal)
|
||||
if owner_id is None:
|
||||
raise ReportingOperationError("Saved views require an account owner.")
|
||||
row = (
|
||||
session.query(ReportingSavedView)
|
||||
.filter(
|
||||
ReportingSavedView.tenant_id == _tenant(principal),
|
||||
ReportingSavedView.view_id == view_id,
|
||||
)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
if expected_revision is not None:
|
||||
raise ReportingOperationError(
|
||||
"Saved-view revision conflict: no view exists."
|
||||
)
|
||||
row = ReportingSavedView(
|
||||
tenant_id=_tenant(principal),
|
||||
view_id=_required(view_id, "Saved-view identifier", 36),
|
||||
report_id=report_id,
|
||||
report_revision=report_revision,
|
||||
owner_kind="account",
|
||||
owner_id=owner_id,
|
||||
name=_required(name, "Saved-view name", 500),
|
||||
revision=1,
|
||||
state=_json_value(state),
|
||||
shared=shared,
|
||||
access=_json_value(access),
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
if row.owner_id != owner_id and not _has_scope(principal, ADMIN_SCOPE):
|
||||
raise PermissionError("Only the saved-view owner or an admin may edit it.")
|
||||
if expected_revision != row.revision:
|
||||
raise ReportingOperationError(
|
||||
"Saved-view revision conflict: the expected revision is stale."
|
||||
)
|
||||
row.report_id = report_id
|
||||
row.report_revision = report_revision
|
||||
row.name = _required(name, "Saved-view name", 500)
|
||||
row.state = _json_value(state)
|
||||
row.shared = shared
|
||||
row.access = _json_value(access)
|
||||
row.revision += 1
|
||||
session.flush()
|
||||
return _saved_view_payload(row)
|
||||
|
||||
|
||||
def list_saved_views(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
report_id: str,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
if (
|
||||
get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="report",
|
||||
definition_id=report_id,
|
||||
)
|
||||
is None
|
||||
):
|
||||
return ()
|
||||
owner_id = _actor(principal)
|
||||
rows = (
|
||||
session.query(ReportingSavedView)
|
||||
.filter(
|
||||
ReportingSavedView.tenant_id == _tenant(principal),
|
||||
ReportingSavedView.report_id == report_id,
|
||||
or_(
|
||||
ReportingSavedView.shared.is_(True),
|
||||
ReportingSavedView.owner_id == owner_id,
|
||||
),
|
||||
)
|
||||
.order_by(ReportingSavedView.name.asc())
|
||||
.all()
|
||||
)
|
||||
return tuple(_saved_view_payload(row) for row in rows)
|
||||
|
||||
|
||||
def delete_saved_view(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
view_id: str,
|
||||
) -> bool:
|
||||
row = (
|
||||
session.query(ReportingSavedView)
|
||||
.filter(
|
||||
ReportingSavedView.tenant_id == _tenant(principal),
|
||||
ReportingSavedView.view_id == view_id,
|
||||
)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
if row.owner_id != _actor(principal) and not _has_scope(principal, ADMIN_SCOPE):
|
||||
raise PermissionError("Only the saved-view owner or an admin may delete it.")
|
||||
session.delete(row)
|
||||
session.flush()
|
||||
return True
|
||||
|
||||
|
||||
def upsert_schedule(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
schedule_id: str,
|
||||
report_id: str,
|
||||
report_revision: int,
|
||||
name: str,
|
||||
trigger_kind: str,
|
||||
trigger_config: Mapping[str, object],
|
||||
parameters: Mapping[str, object],
|
||||
query: ReportQuery,
|
||||
publication_target: Mapping[str, object],
|
||||
enabled: bool,
|
||||
next_run_at: datetime | None,
|
||||
expected_revision: int | None,
|
||||
) -> dict[str, object]:
|
||||
_require_scope(principal, SCHEDULE_SCOPE)
|
||||
report = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="report",
|
||||
definition_id=report_id,
|
||||
revision=report_revision,
|
||||
)
|
||||
if report is None or report.status != "active":
|
||||
raise ReportingOperationError("Schedules require an active report revision.")
|
||||
normalized_next = _validate_trigger(
|
||||
trigger_kind,
|
||||
trigger_config,
|
||||
next_run_at=next_run_at,
|
||||
)
|
||||
row = (
|
||||
session.query(ReportingSchedule)
|
||||
.filter(
|
||||
ReportingSchedule.tenant_id == _tenant(principal),
|
||||
ReportingSchedule.schedule_id == schedule_id,
|
||||
)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
if expected_revision is not None:
|
||||
raise ReportingOperationError(
|
||||
"Reporting schedule revision conflict: no schedule exists."
|
||||
)
|
||||
row = ReportingSchedule(
|
||||
tenant_id=_tenant(principal),
|
||||
schedule_id=_required(schedule_id, "Reporting schedule identifier", 36),
|
||||
report_id=report_id,
|
||||
report_revision=report_revision,
|
||||
name=_required(name, "Reporting schedule name", 500),
|
||||
revision=1,
|
||||
trigger_kind=trigger_kind,
|
||||
trigger_config=_json_value(trigger_config),
|
||||
parameters=_json_value(parameters),
|
||||
query=query.model_dump(mode="json"),
|
||||
publication_target=_json_value(publication_target),
|
||||
enabled=enabled,
|
||||
next_run_at=normalized_next,
|
||||
created_by=_actor(principal),
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
if expected_revision != row.revision:
|
||||
raise ReportingOperationError(
|
||||
"Reporting schedule revision conflict: the expected revision is stale."
|
||||
)
|
||||
row.report_id = report_id
|
||||
row.report_revision = report_revision
|
||||
row.name = _required(name, "Reporting schedule name", 500)
|
||||
row.trigger_kind = trigger_kind
|
||||
row.trigger_config = _json_value(trigger_config)
|
||||
row.parameters = _json_value(parameters)
|
||||
row.query = query.model_dump(mode="json")
|
||||
row.publication_target = _json_value(publication_target)
|
||||
row.enabled = enabled
|
||||
row.next_run_at = normalized_next
|
||||
row.revision += 1
|
||||
session.flush()
|
||||
return _schedule_payload(row)
|
||||
|
||||
|
||||
def list_schedules(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
report_id: str | None = None,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
statement = session.query(ReportingSchedule).filter(
|
||||
ReportingSchedule.tenant_id == _tenant(principal)
|
||||
)
|
||||
if report_id:
|
||||
if (
|
||||
get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="report",
|
||||
definition_id=report_id,
|
||||
)
|
||||
is None
|
||||
):
|
||||
return ()
|
||||
statement = statement.filter(ReportingSchedule.report_id == report_id)
|
||||
return tuple(
|
||||
_schedule_payload(row)
|
||||
for row in statement.order_by(ReportingSchedule.name.asc()).all()
|
||||
)
|
||||
|
||||
|
||||
def dispatch_due_schedules(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
now: datetime | None,
|
||||
limit: int,
|
||||
) -> dict[str, object]:
|
||||
_require_scope(principal, SCHEDULE_SCOPE)
|
||||
current = now or utc_now()
|
||||
_aware(current, "Reporting scheduler time")
|
||||
rows = (
|
||||
session.query(ReportingSchedule)
|
||||
.filter(
|
||||
ReportingSchedule.enabled.is_(True),
|
||||
ReportingSchedule.next_run_at.is_not(None),
|
||||
ReportingSchedule.next_run_at <= current,
|
||||
)
|
||||
.order_by(ReportingSchedule.next_run_at.asc())
|
||||
.limit(max(1, min(limit, 100)))
|
||||
.with_for_update(skip_locked=True)
|
||||
.all()
|
||||
)
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
execution_ids: list[str] = []
|
||||
for row in rows:
|
||||
scheduled_for = row.next_run_at or current
|
||||
try:
|
||||
execution = execute_report(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
report_id=row.report_id,
|
||||
report_revision=row.report_revision,
|
||||
parameters=dict(row.parameters or {}),
|
||||
query=ReportQuery.model_validate(row.query or {}),
|
||||
idempotency_key=f"schedule:{row.schedule_id}:{scheduled_for.isoformat()}",
|
||||
)
|
||||
execution_id = str(execution["execution_id"])
|
||||
execution_ids.append(execution_id)
|
||||
target = dict(row.publication_target or {})
|
||||
if target:
|
||||
publish_execution(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
execution_id=execution_id,
|
||||
target_capability=str(target.get("target_capability") or ""),
|
||||
target_ref=(
|
||||
str(target["target_ref"])
|
||||
if target.get("target_ref") is not None
|
||||
else None
|
||||
),
|
||||
format=str(target.get("format") or "csv"),
|
||||
idempotency_key=f"schedule-publication:{row.schedule_id}:{scheduled_for.isoformat()}",
|
||||
options=dict(target.get("options") or {}),
|
||||
)
|
||||
succeeded += 1
|
||||
row.last_execution_id = execution_id
|
||||
except (ReportingExecutionFailure, ReportingOperationError, LookupError):
|
||||
failed += 1
|
||||
row.last_run_at = current
|
||||
_advance_schedule(row, scheduled_for)
|
||||
session.flush()
|
||||
return {
|
||||
"claimed": len(rows),
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"execution_ids": execution_ids,
|
||||
}
|
||||
|
||||
|
||||
def publish_execution(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
execution_id: str,
|
||||
target_capability: str,
|
||||
target_ref: str | None,
|
||||
format: str,
|
||||
idempotency_key: str,
|
||||
options: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
_require_scope(principal, PUBLISH_SCOPE)
|
||||
execution_payload = get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
if execution_payload is None:
|
||||
raise LookupError("Reporting execution not found.")
|
||||
if execution_payload["status"] != "succeeded":
|
||||
raise ReportingOperationError("Only successful report executions can publish.")
|
||||
clean_capability = _required(
|
||||
target_capability,
|
||||
"Reporting publication target capability",
|
||||
255,
|
||||
)
|
||||
clean_format = str(format).casefold()
|
||||
if clean_format not in {"json", "csv", "xlsx", "html", "pdf"}:
|
||||
raise ReportingOperationError("Unsupported Reporting publication format.")
|
||||
clean_key = _required(idempotency_key, "Reporting publication idempotency key", 255)
|
||||
existing = (
|
||||
session.query(ReportingPublication)
|
||||
.filter(
|
||||
ReportingPublication.tenant_id == _tenant(principal),
|
||||
ReportingPublication.idempotency_key == clean_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
if (
|
||||
existing.execution_id != execution_id
|
||||
or existing.target_capability != clean_capability
|
||||
or existing.target_ref != target_ref
|
||||
or existing.format != clean_format
|
||||
):
|
||||
raise ReportingOperationError("Reporting publication idempotency conflict.")
|
||||
return _publication_payload(existing)
|
||||
provider = capability(registry, clean_capability)
|
||||
if not isinstance(provider, ReportingPublicationTarget):
|
||||
raise ReportingOperationError(
|
||||
f"Reporting publication provider {clean_capability!r} is unavailable."
|
||||
)
|
||||
publication = ReportingPublication(
|
||||
tenant_id=_tenant(principal),
|
||||
publication_id=str(uuid.uuid4()),
|
||||
execution_id=execution_id,
|
||||
target_capability=clean_capability,
|
||||
target_ref=target_ref,
|
||||
format=clean_format,
|
||||
status="running",
|
||||
idempotency_key=clean_key,
|
||||
)
|
||||
session.add(publication)
|
||||
session.flush()
|
||||
try:
|
||||
evidence = provider.publish_report(
|
||||
session,
|
||||
principal,
|
||||
payload=ReportingPublicationPayload(
|
||||
publication_id=publication.publication_id,
|
||||
execution_id=execution_id,
|
||||
tenant_id=_tenant(principal),
|
||||
report_id=str(execution_payload["report_id"]),
|
||||
report_revision=int(execution_payload["report_revision"]),
|
||||
format=clean_format,
|
||||
target_ref=target_ref,
|
||||
rows=tuple(execution_payload["rows"]), # type: ignore[arg-type]
|
||||
schema=tuple(execution_payload["schema"]), # type: ignore[arg-type]
|
||||
output_hash=str(execution_payload["output_hash"]),
|
||||
options=dict(options),
|
||||
),
|
||||
)
|
||||
publication.status = "succeeded"
|
||||
publication.evidence = _json_value(evidence)
|
||||
publication.completed_at = utc_now()
|
||||
except Exception as exc:
|
||||
publication.status = "failed"
|
||||
publication.error = str(exc)
|
||||
publication.completed_at = utc_now()
|
||||
session.flush()
|
||||
raise ReportingOperationError(str(exc)) from exc
|
||||
session.flush()
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type=f"reporting.publication.{publication.status}",
|
||||
module_id="reporting",
|
||||
payload={
|
||||
"publication_id": publication.publication_id,
|
||||
"execution_id": publication.execution_id,
|
||||
"target_capability": publication.target_capability,
|
||||
"target_ref": publication.target_ref,
|
||||
"format": publication.format,
|
||||
"evidence": dict(publication.evidence or {}),
|
||||
},
|
||||
actor=EventActorRef(type="account", id=_actor(principal)),
|
||||
tenant=EventTenantRef(id=publication.tenant_id),
|
||||
resource=EventObjectRef(
|
||||
type="report_publication",
|
||||
id=publication.publication_id,
|
||||
),
|
||||
classification="internal",
|
||||
),
|
||||
)
|
||||
return _publication_payload(publication)
|
||||
|
||||
|
||||
def list_publications(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
execution_id: str | None = None,
|
||||
limit: int = 100,
|
||||
registry: object | None = None,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
_require_scope(principal, PUBLISH_SCOPE)
|
||||
statement = session.query(ReportingPublication).filter(
|
||||
ReportingPublication.tenant_id == _tenant(principal)
|
||||
)
|
||||
if execution_id:
|
||||
if (
|
||||
get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
is None
|
||||
):
|
||||
return ()
|
||||
statement = statement.filter(
|
||||
ReportingPublication.execution_id == execution_id
|
||||
)
|
||||
rows = (
|
||||
statement.order_by(ReportingPublication.created_at.desc())
|
||||
.limit(max(1, min(limit, 200)))
|
||||
.all()
|
||||
)
|
||||
return tuple(_publication_payload(row) for row in rows)
|
||||
|
||||
|
||||
def export_execution(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
execution_id: str,
|
||||
format: str,
|
||||
registry: object | None = None,
|
||||
) -> tuple[bytes, str, str]:
|
||||
payload = get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
if payload is None:
|
||||
raise LookupError("Reporting execution not found.")
|
||||
if payload["status"] != "succeeded":
|
||||
raise ReportingOperationError("Only successful executions can be exported.")
|
||||
rows = tuple(payload["rows"]) # type: ignore[arg-type]
|
||||
if format == "json":
|
||||
content = json.dumps(
|
||||
{"schema": payload["schema"], "rows": rows},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return content, "application/json", f"report-{execution_id}.json"
|
||||
if format != "csv":
|
||||
raise ReportingOperationError("Direct export supports CSV or JSON.")
|
||||
fields = tuple(
|
||||
dict.fromkeys(
|
||||
str(key) for row in rows if isinstance(row, Mapping) for key in row
|
||||
)
|
||||
)
|
||||
stream = StringIO(newline="")
|
||||
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
if isinstance(row, Mapping):
|
||||
writer.writerow({key: _safe_csv_cell(row.get(key)) for key in fields})
|
||||
return (
|
||||
stream.getvalue().encode("utf-8-sig"),
|
||||
"text/csv; charset=utf-8",
|
||||
f"report-{execution_id}.csv",
|
||||
)
|
||||
|
||||
|
||||
def assess_import(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
source_system: str,
|
||||
source_id: str,
|
||||
metadata: Mapping[str, object],
|
||||
accepted_approximations: list[str],
|
||||
) -> dict[str, object]:
|
||||
_require_scope(principal, IMPORT_SCOPE)
|
||||
raw_features = metadata.get("features", [])
|
||||
if not isinstance(raw_features, list):
|
||||
raise ReportingOperationError("Import metadata features must be a list.")
|
||||
features = tuple(dict.fromkeys(str(item) for item in raw_features))
|
||||
exact_features = {
|
||||
"dataset",
|
||||
"dimension",
|
||||
"hierarchy",
|
||||
"measure",
|
||||
"parameter",
|
||||
"table",
|
||||
"pivot",
|
||||
"chart",
|
||||
"quality_assertion",
|
||||
"saved_view",
|
||||
}
|
||||
approximated_features = {
|
||||
"provider_specific_format",
|
||||
"dialect_function",
|
||||
"dashboard_layout",
|
||||
}
|
||||
unsupported_features = {
|
||||
"raw_sql",
|
||||
"stored_procedure",
|
||||
"runtime_script",
|
||||
"implicit_authorization",
|
||||
"unchecked_custom_function",
|
||||
}
|
||||
exact = sorted(set(features) & exact_features)
|
||||
approximated = sorted(set(features) & approximated_features)
|
||||
unsupported = sorted(
|
||||
(set(features) & unsupported_features)
|
||||
| (
|
||||
set(features)
|
||||
- exact_features
|
||||
- approximated_features
|
||||
- unsupported_features
|
||||
)
|
||||
)
|
||||
accepted = sorted(set(accepted_approximations) & set(approximated))
|
||||
pending = sorted(set(approximated) - set(accepted))
|
||||
status = "ready" if not unsupported and not pending else "blocked"
|
||||
mapping_report = {
|
||||
"contract_version": "1",
|
||||
"source_system": source_system,
|
||||
"source_id": source_id,
|
||||
"exact": exact,
|
||||
"approximated": approximated,
|
||||
"accepted_approximations": accepted,
|
||||
"pending_approximations": pending,
|
||||
"unsupported": unsupported,
|
||||
"manual_bindings": list(metadata.get("manual_bindings", [])),
|
||||
"provider_assumptions": list(metadata.get("provider_assumptions", [])),
|
||||
"activation_allowed": status == "ready",
|
||||
}
|
||||
row = ReportingImportAssessment(
|
||||
tenant_id=_tenant(principal),
|
||||
assessment_id=str(uuid.uuid4()),
|
||||
source_system=_required(source_system, "Import source system", 255),
|
||||
source_id=_required(source_id, "Import source identifier", 500),
|
||||
source_fingerprint=_sha256(metadata),
|
||||
mapping_report=mapping_report,
|
||||
status=status,
|
||||
accepted_approximations=accepted,
|
||||
assessed_by=_actor(principal),
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return _assessment_payload(row)
|
||||
|
||||
|
||||
def list_import_assessments(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
limit: int = 100,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
_require_scope(principal, IMPORT_SCOPE)
|
||||
rows = (
|
||||
session.query(ReportingImportAssessment)
|
||||
.filter(ReportingImportAssessment.tenant_id == _tenant(principal))
|
||||
.order_by(ReportingImportAssessment.created_at.desc())
|
||||
.limit(max(1, min(limit, 200)))
|
||||
.all()
|
||||
)
|
||||
return tuple(_assessment_payload(row) for row in rows)
|
||||
|
||||
|
||||
def _validate_saved_view_state(state: Mapping[str, object]) -> None:
|
||||
query = state.get("query")
|
||||
if query is not None:
|
||||
if not isinstance(query, Mapping):
|
||||
raise ReportingOperationError("Saved-view query must be an object.")
|
||||
ReportQuery.model_validate(query)
|
||||
if len(state) > 100:
|
||||
raise ReportingOperationError("Saved-view state is limited to 100 entries.")
|
||||
|
||||
|
||||
def _validate_trigger(
|
||||
trigger_kind: str,
|
||||
trigger_config: Mapping[str, object],
|
||||
*,
|
||||
next_run_at: datetime | None,
|
||||
) -> datetime | None:
|
||||
if trigger_kind not in {"scheduled", "interval"}:
|
||||
raise ReportingOperationError("Unsupported Reporting schedule trigger.")
|
||||
if next_run_at is not None:
|
||||
_aware(next_run_at, "Reporting next_run_at")
|
||||
if trigger_kind == "scheduled":
|
||||
if next_run_at is None:
|
||||
raise ReportingOperationError("Scheduled reports require next_run_at.")
|
||||
return next_run_at
|
||||
seconds = int(trigger_config.get("seconds", 0))
|
||||
if not 60 <= seconds <= 31_536_000:
|
||||
raise ReportingOperationError(
|
||||
"Reporting intervals must be between 60 seconds and one year."
|
||||
)
|
||||
return next_run_at or utc_now() + timedelta(seconds=seconds)
|
||||
|
||||
|
||||
def _advance_schedule(row: ReportingSchedule, scheduled_for: datetime) -> None:
|
||||
if row.trigger_kind == "scheduled":
|
||||
row.enabled = False
|
||||
row.next_run_at = None
|
||||
return
|
||||
if scheduled_for.tzinfo is None or scheduled_for.utcoffset() is None:
|
||||
scheduled_for = scheduled_for.replace(tzinfo=UTC)
|
||||
seconds = int((row.trigger_config or {}).get("seconds", 0))
|
||||
next_run = scheduled_for + timedelta(seconds=seconds)
|
||||
now = utc_now()
|
||||
while next_run <= now:
|
||||
next_run += timedelta(seconds=seconds)
|
||||
row.next_run_at = next_run
|
||||
|
||||
|
||||
def _saved_view_payload(row: ReportingSavedView) -> dict[str, object]:
|
||||
return {
|
||||
"view_id": row.view_id,
|
||||
"report_id": row.report_id,
|
||||
"report_revision": row.report_revision,
|
||||
"owner_kind": row.owner_kind,
|
||||
"owner_id": row.owner_id,
|
||||
"name": row.name,
|
||||
"revision": row.revision,
|
||||
"state": dict(row.state or {}),
|
||||
"shared": row.shared,
|
||||
"access": dict(row.access or {}),
|
||||
"updated_at": _datetime_text(row.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def _schedule_payload(row: ReportingSchedule) -> dict[str, object]:
|
||||
return {
|
||||
"schedule_id": row.schedule_id,
|
||||
"report_id": row.report_id,
|
||||
"report_revision": row.report_revision,
|
||||
"name": row.name,
|
||||
"revision": row.revision,
|
||||
"trigger_kind": row.trigger_kind,
|
||||
"trigger_config": dict(row.trigger_config or {}),
|
||||
"parameters": dict(row.parameters or {}),
|
||||
"query": dict(row.query or {}),
|
||||
"publication_target": dict(row.publication_target or {}),
|
||||
"enabled": row.enabled,
|
||||
"next_run_at": _datetime_text(row.next_run_at),
|
||||
"last_run_at": _datetime_text(row.last_run_at),
|
||||
"last_execution_id": row.last_execution_id,
|
||||
}
|
||||
|
||||
|
||||
def _publication_payload(row: ReportingPublication) -> dict[str, object]:
|
||||
return {
|
||||
"publication_id": row.publication_id,
|
||||
"execution_id": row.execution_id,
|
||||
"target_capability": row.target_capability,
|
||||
"target_ref": row.target_ref,
|
||||
"format": row.format,
|
||||
"status": row.status,
|
||||
"evidence": dict(row.evidence or {}),
|
||||
"error": row.error,
|
||||
"completed_at": _datetime_text(row.completed_at),
|
||||
}
|
||||
|
||||
|
||||
def _assessment_payload(row: ReportingImportAssessment) -> dict[str, object]:
|
||||
return {
|
||||
"assessment_id": row.assessment_id,
|
||||
"source_system": row.source_system,
|
||||
"source_id": row.source_id,
|
||||
"source_fingerprint": row.source_fingerprint,
|
||||
"mapping_report": dict(row.mapping_report or {}),
|
||||
"status": row.status,
|
||||
"accepted_approximations": list(row.accepted_approximations or []),
|
||||
"assessed_by": row.assessed_by,
|
||||
"created_at": _datetime_text(row.created_at),
|
||||
}
|
||||
|
||||
|
||||
def _safe_csv_cell(value: object) -> object:
|
||||
if isinstance(value, (dict, list, tuple)):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if isinstance(value, str) and value.startswith(("=", "+", "-", "@")):
|
||||
return f"'{value}"
|
||||
return value
|
||||
|
||||
|
||||
def _has_scope(principal: object, scope: str) -> bool:
|
||||
method = getattr(principal, "has", None)
|
||||
if callable(method):
|
||||
return bool(method(scope))
|
||||
return scopes_grant_compatible(
|
||||
frozenset(getattr(principal, "scopes", ()) or ()),
|
||||
scope,
|
||||
)
|
||||
|
||||
|
||||
def _require_scope(principal: object, scope: str) -> None:
|
||||
if not _has_scope(principal, scope):
|
||||
raise PermissionError(f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _tenant(principal: object) -> str:
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id:
|
||||
raise ReportingOperationError(
|
||||
"Reporting operations require a tenant-bound principal."
|
||||
)
|
||||
return tenant_id
|
||||
|
||||
|
||||
def _actor(principal: object) -> str | None:
|
||||
for value in (
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
):
|
||||
if str(value or "").strip():
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Reporting operations require a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
def _required(value: object, label: str, maximum: int) -> str:
|
||||
result = str(value or "").strip()
|
||||
if not result:
|
||||
raise ReportingOperationError(f"{label} is required.")
|
||||
if len(result) > maximum:
|
||||
raise ReportingOperationError(f"{label} is limited to {maximum} characters.")
|
||||
return result
|
||||
|
||||
|
||||
def _aware(value: datetime, label: str) -> None:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ReportingOperationError(f"{label} must include a timezone.")
|
||||
|
||||
|
||||
def _sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
_json_value(value),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _json_value(value: object) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if hasattr(value, "model_dump"):
|
||||
return _json_value(value.model_dump(mode="json"))
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _datetime_text(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IMPORT_SCOPE",
|
||||
"PUBLISH_SCOPE",
|
||||
"SCHEDULE_SCOPE",
|
||||
"ReportingOperationError",
|
||||
"SqlReportingScheduler",
|
||||
"assess_import",
|
||||
"delete_saved_view",
|
||||
"dispatch_due_schedules",
|
||||
"export_execution",
|
||||
"list_import_assessments",
|
||||
"list_publications",
|
||||
"list_saved_views",
|
||||
"list_schedules",
|
||||
"publish_execution",
|
||||
"upsert_saved_view",
|
||||
"upsert_schedule",
|
||||
]
|
||||
@@ -0,0 +1,509 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_reporting.backend.query_engine import (
|
||||
QueryResult,
|
||||
ReportingQueryError,
|
||||
infer_query_schema,
|
||||
)
|
||||
from govoplan_reporting.backend.schemas import (
|
||||
DatasetDefinition,
|
||||
DimensionDefinition,
|
||||
FilterClause,
|
||||
MeasureDefinition,
|
||||
ReportQuery,
|
||||
SemanticModelDefinition,
|
||||
TypedExpression,
|
||||
)
|
||||
|
||||
|
||||
POSTGRES_PLANNER_VERSION = "reporting-postgresql-v1"
|
||||
_IDENTIFIER = re.compile(r"^[a-z0-9._-]{1,120}$")
|
||||
|
||||
|
||||
class PostgresPlanningError(ReportingQueryError):
|
||||
pass
|
||||
|
||||
|
||||
def execute_postgres_query(
|
||||
session: Session,
|
||||
*,
|
||||
rows: Sequence[Mapping[str, object]],
|
||||
dataset: DatasetDefinition,
|
||||
semantic_model: SemanticModelDefinition,
|
||||
query: ReportQuery,
|
||||
) -> QueryResult | None:
|
||||
"""Execute a bounded semantic plan in PostgreSQL, or return None for fallback."""
|
||||
|
||||
if session.bind is None or session.bind.dialect.name != "postgresql":
|
||||
return None
|
||||
if query.mode == "pivot":
|
||||
return None
|
||||
plan = compile_postgres_query(dataset, semantic_model, query)
|
||||
parameters = {
|
||||
**plan.parameters,
|
||||
"rows_json": json.dumps(
|
||||
[_json_value(dict(item)) for item in rows],
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
),
|
||||
"result_limit": query.limit,
|
||||
"result_offset": query.offset,
|
||||
}
|
||||
result = session.execute(text(plan.sql), parameters).mappings().all()
|
||||
total_rows = int(result[0]["__reporting_total"]) if result else 0
|
||||
output = tuple(
|
||||
{
|
||||
str(key): _json_value(value)
|
||||
for key, value in item.items()
|
||||
if key != "__reporting_total"
|
||||
}
|
||||
for item in result
|
||||
)
|
||||
return QueryResult(
|
||||
rows=output,
|
||||
total_rows=total_rows,
|
||||
schema=infer_query_schema(output),
|
||||
truncated=query.offset + len(output) < total_rows,
|
||||
diagnostics=(
|
||||
{
|
||||
"severity": "info",
|
||||
"code": "postgresql_semantic_plan",
|
||||
"message": "Filters, grouping, measures, ordering, and bounds were executed by the PostgreSQL Reporting planner.",
|
||||
"planner_version": POSTGRES_PLANNER_VERSION,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CompiledPostgresPlan:
|
||||
__slots__ = ("sql", "parameters")
|
||||
|
||||
def __init__(self, sql: str, parameters: Mapping[str, object]) -> None:
|
||||
self.sql = sql
|
||||
self.parameters = dict(parameters)
|
||||
|
||||
|
||||
def compile_postgres_query(
|
||||
dataset: DatasetDefinition,
|
||||
semantic_model: SemanticModelDefinition,
|
||||
query: ReportQuery,
|
||||
) -> CompiledPostgresPlan:
|
||||
dimensions = {item.key: item for item in semantic_model.dimensions}
|
||||
measures = {item.key: item for item in semantic_model.measures}
|
||||
selected_dimensions = tuple(query.dimensions or semantic_model.default_dimensions)
|
||||
selected_measures = tuple(query.measures or semantic_model.default_measures)
|
||||
_known(selected_dimensions, dimensions, "dimensions")
|
||||
_known(selected_measures, measures, "measures")
|
||||
_known(
|
||||
tuple(item.dimension for item in query.filters), dimensions, "filter dimensions"
|
||||
)
|
||||
selected_keys = set(selected_dimensions)
|
||||
if query.mode != "detail":
|
||||
selected_keys.update(selected_measures)
|
||||
_known(
|
||||
tuple(item.key for item in query.sort),
|
||||
{key: True for key in selected_keys},
|
||||
"sort fields",
|
||||
)
|
||||
|
||||
parameters: dict[str, object] = {}
|
||||
source = (
|
||||
"WITH source AS ("
|
||||
"SELECT value AS source_row "
|
||||
"FROM jsonb_array_elements(CAST(:rows_json AS jsonb)) AS source_items(value)"
|
||||
")"
|
||||
)
|
||||
where = _filter_sql(query.filters, dimensions, parameters)
|
||||
if query.mode == "detail":
|
||||
fields = selected_dimensions
|
||||
if not fields:
|
||||
if not dataset.fields:
|
||||
raise PostgresPlanningError(
|
||||
"PostgreSQL detail planning requires selected dimensions or a pinned dataset schema."
|
||||
)
|
||||
field_types = {item.name: item.type for item in dataset.fields}
|
||||
projections = [
|
||||
f"{_source_value(item.name, item.type, parameters, f'detail_{index}')} AS {_quote(item.name)}"
|
||||
for index, item in enumerate(dataset.fields)
|
||||
]
|
||||
selected_keys = set(field_types)
|
||||
else:
|
||||
projections = [
|
||||
f"{_dimension_value(dimensions[key], parameters, f'detail_{index}')} AS {_quote(key)}"
|
||||
for index, key in enumerate(fields)
|
||||
]
|
||||
body = "SELECT " + ", ".join(projections) + " FROM source" + where
|
||||
else:
|
||||
dimension_projections = [
|
||||
(
|
||||
key,
|
||||
_dimension_value(dimensions[key], parameters, f"dimension_{index}"),
|
||||
)
|
||||
for index, key in enumerate(selected_dimensions)
|
||||
]
|
||||
selected_base_keys = [
|
||||
key
|
||||
for key in selected_measures
|
||||
if measures[key].aggregation != "calculated"
|
||||
]
|
||||
calculated = [
|
||||
measures[key]
|
||||
for key in selected_measures
|
||||
if measures[key].aggregation == "calculated"
|
||||
]
|
||||
dependency_keys = list(
|
||||
dict.fromkeys(
|
||||
dependency
|
||||
for item in calculated
|
||||
for dependency in _calculated_dependencies(
|
||||
item.expression, measures, stack=(item.key,)
|
||||
)
|
||||
)
|
||||
)
|
||||
base_measure_keys = list(dict.fromkeys((*selected_base_keys, *dependency_keys)))
|
||||
base_measures = [measures[key] for key in base_measure_keys]
|
||||
grouped_select = [
|
||||
f"{expression} AS {_quote(key)}"
|
||||
for key, expression in dimension_projections
|
||||
] + [
|
||||
f"{_aggregate_sql(item, parameters, index)} AS {_quote(item.key)}"
|
||||
for index, item in enumerate(base_measures)
|
||||
]
|
||||
if not grouped_select:
|
||||
raise PostgresPlanningError(
|
||||
"Summary queries require at least one dimension or measure."
|
||||
)
|
||||
grouped = "SELECT " + ", ".join(grouped_select) + " FROM source" + where
|
||||
if dimension_projections:
|
||||
grouped += " GROUP BY " + ", ".join(
|
||||
expression for _key, expression in dimension_projections
|
||||
)
|
||||
if calculated:
|
||||
outer = [_quote(key) for key in selected_dimensions] + [
|
||||
_quote(key) for key in selected_base_keys
|
||||
]
|
||||
outer.extend(
|
||||
f"{_calculated_sql(item.expression, parameters, f'calculated_{index}', measures=measures, stack=(item.key,))} AS {_quote(item.key)}"
|
||||
for index, item in enumerate(calculated)
|
||||
)
|
||||
body = "SELECT " + ", ".join(outer) + f" FROM ({grouped}) AS grouped"
|
||||
else:
|
||||
body = grouped
|
||||
order = ""
|
||||
if query.sort:
|
||||
order = " ORDER BY " + ", ".join(
|
||||
f"{_quote(item.key)} {item.direction.upper()} NULLS LAST"
|
||||
for item in query.sort
|
||||
)
|
||||
sql = (
|
||||
source
|
||||
+ " SELECT planned.*, COUNT(*) OVER() AS __reporting_total FROM ("
|
||||
+ body
|
||||
+ ") AS planned"
|
||||
+ order
|
||||
+ " LIMIT :result_limit OFFSET :result_offset"
|
||||
)
|
||||
return CompiledPostgresPlan(sql, parameters)
|
||||
|
||||
|
||||
def _filter_sql(
|
||||
filters: Sequence[FilterClause],
|
||||
dimensions: Mapping[str, DimensionDefinition],
|
||||
parameters: dict[str, object],
|
||||
) -> str:
|
||||
clauses: list[str] = []
|
||||
for index, clause in enumerate(filters):
|
||||
value = _dimension_value(
|
||||
dimensions[clause.dimension], parameters, f"filter_field_{index}"
|
||||
)
|
||||
prefix = f"filter_{index}"
|
||||
if clause.operator == "is_null":
|
||||
clauses.append(f"{value} IS NULL")
|
||||
continue
|
||||
if clause.operator == "not_null":
|
||||
clauses.append(f"{value} IS NOT NULL")
|
||||
continue
|
||||
if clause.operator in {"in", "not_in"}:
|
||||
if not isinstance(clause.value, (list, tuple)):
|
||||
raise PostgresPlanningError("Set filters require a list value.")
|
||||
if not clause.value or len(clause.value) > 500:
|
||||
raise PostgresPlanningError(
|
||||
"Set filters require between 1 and 500 values."
|
||||
)
|
||||
names: list[str] = []
|
||||
for item_index, item in enumerate(clause.value):
|
||||
name = f"{prefix}_{item_index}"
|
||||
parameters[name] = item
|
||||
names.append(f":{name}")
|
||||
operator = "NOT IN" if clause.operator == "not_in" else "IN"
|
||||
clauses.append(f"{value} {operator} ({', '.join(names)})")
|
||||
continue
|
||||
if clause.operator == "between":
|
||||
if not isinstance(clause.value, (list, tuple)) or len(clause.value) != 2:
|
||||
raise PostgresPlanningError("Between filters require two values.")
|
||||
parameters[f"{prefix}_low"] = clause.value[0]
|
||||
parameters[f"{prefix}_high"] = clause.value[1]
|
||||
clauses.append(f"{value} BETWEEN :{prefix}_low AND :{prefix}_high")
|
||||
continue
|
||||
parameters[prefix] = clause.value
|
||||
if clause.operator == "contains":
|
||||
parameters[prefix] = f"%{_like(str(clause.value or ''))}%"
|
||||
clauses.append(
|
||||
f"LOWER(CAST({value} AS text)) LIKE LOWER(:{prefix}) ESCAPE '\\'"
|
||||
)
|
||||
elif clause.operator == "starts_with":
|
||||
parameters[prefix] = f"{_like(str(clause.value or ''))}%"
|
||||
clauses.append(
|
||||
f"LOWER(CAST({value} AS text)) LIKE LOWER(:{prefix}) ESCAPE '\\'"
|
||||
)
|
||||
else:
|
||||
operator = {
|
||||
"eq": "=",
|
||||
"ne": "<>",
|
||||
"gt": ">",
|
||||
"gte": ">=",
|
||||
"lt": "<",
|
||||
"lte": "<=",
|
||||
}.get(clause.operator)
|
||||
if operator is None:
|
||||
raise PostgresPlanningError(
|
||||
f"Unsupported PostgreSQL filter operator: {clause.operator}."
|
||||
)
|
||||
clauses.append(f"{value} {operator} :{prefix}")
|
||||
return " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
|
||||
|
||||
def _aggregate_sql(
|
||||
measure: MeasureDefinition,
|
||||
parameters: dict[str, object],
|
||||
index: int,
|
||||
) -> str:
|
||||
if measure.aggregation == "count" and measure.field is None:
|
||||
return "COUNT(*)"
|
||||
field = _source_value(
|
||||
measure.field or "",
|
||||
"number" if measure.aggregation in {"sum", "average"} else "string",
|
||||
parameters,
|
||||
f"measure_{index}",
|
||||
)
|
||||
if measure.aggregation == "count":
|
||||
return f"COUNT({field})"
|
||||
if measure.aggregation == "count_distinct":
|
||||
return f"COUNT(DISTINCT {field})"
|
||||
function = {
|
||||
"sum": "SUM",
|
||||
"average": "AVG",
|
||||
"minimum": "MIN",
|
||||
"maximum": "MAX",
|
||||
}.get(measure.aggregation)
|
||||
if function is None:
|
||||
raise PostgresPlanningError(
|
||||
f"Unsupported PostgreSQL aggregation: {measure.aggregation}."
|
||||
)
|
||||
return f"{function}({field})"
|
||||
|
||||
|
||||
def _calculated_sql(
|
||||
expression: TypedExpression | None,
|
||||
parameters: dict[str, object],
|
||||
prefix: str,
|
||||
*,
|
||||
measures: Mapping[str, MeasureDefinition],
|
||||
stack: tuple[str, ...],
|
||||
) -> str:
|
||||
if expression is None:
|
||||
return "NULL"
|
||||
if expression.op == "literal":
|
||||
parameters[prefix] = expression.value
|
||||
return f":{prefix}"
|
||||
if expression.op == "measure":
|
||||
reference = expression.ref or ""
|
||||
target = measures.get(reference)
|
||||
if target is None:
|
||||
raise PostgresPlanningError(
|
||||
f"Calculated measure references unknown measure: {reference}."
|
||||
)
|
||||
if target.aggregation != "calculated":
|
||||
return _quote(reference)
|
||||
if reference in stack:
|
||||
raise PostgresPlanningError(
|
||||
"Calculated measure dependency cycle: "
|
||||
+ " -> ".join((*stack, reference))
|
||||
)
|
||||
return _calculated_sql(
|
||||
target.expression,
|
||||
parameters,
|
||||
prefix + "_" + reference,
|
||||
measures=measures,
|
||||
stack=(*stack, reference),
|
||||
)
|
||||
if expression.op == "field":
|
||||
raise PostgresPlanningError(
|
||||
"Calculated aggregate measures may reference measures, not source fields."
|
||||
)
|
||||
values = [
|
||||
_calculated_sql(
|
||||
item,
|
||||
parameters,
|
||||
f"{prefix}_{index}",
|
||||
measures=measures,
|
||||
stack=stack,
|
||||
)
|
||||
for index, item in enumerate(expression.args)
|
||||
]
|
||||
if expression.op in {"add", "multiply", "and", "or"}:
|
||||
operator = {"add": "+", "multiply": "*", "and": "AND", "or": "OR"}[
|
||||
expression.op
|
||||
]
|
||||
return "(" + f" {operator} ".join(values) + ")"
|
||||
if expression.op in {"subtract", "divide", "eq", "ne", "gt", "gte", "lt", "lte"}:
|
||||
if len(values) != 2:
|
||||
raise PostgresPlanningError(
|
||||
f"Expression {expression.op} requires exactly two arguments."
|
||||
)
|
||||
operator = {
|
||||
"subtract": "-",
|
||||
"divide": "/",
|
||||
"eq": "=",
|
||||
"ne": "<>",
|
||||
"gt": ">",
|
||||
"gte": ">=",
|
||||
"lt": "<",
|
||||
"lte": "<=",
|
||||
}[expression.op]
|
||||
right = f"NULLIF({values[1]}, 0)" if expression.op == "divide" else values[1]
|
||||
return f"({values[0]} {operator} {right})"
|
||||
if expression.op == "not":
|
||||
if len(values) != 1:
|
||||
raise PostgresPlanningError("Expression not requires one argument.")
|
||||
return f"(NOT {values[0]})"
|
||||
if expression.op == "coalesce":
|
||||
return "COALESCE(" + ", ".join(values) + ")"
|
||||
if expression.op == "case":
|
||||
if len(values) < 3 or len(values) % 2 == 0:
|
||||
raise PostgresPlanningError(
|
||||
"Case expressions require condition/value pairs and a default."
|
||||
)
|
||||
branches = " ".join(
|
||||
f"WHEN {values[index]} THEN {values[index + 1]}"
|
||||
for index in range(0, len(values) - 1, 2)
|
||||
)
|
||||
return f"(CASE {branches} ELSE {values[-1]} END)"
|
||||
raise PostgresPlanningError(
|
||||
f"Unsupported PostgreSQL expression operator: {expression.op}."
|
||||
)
|
||||
|
||||
|
||||
def _calculated_dependencies(
|
||||
expression: TypedExpression | None,
|
||||
measures: Mapping[str, MeasureDefinition],
|
||||
*,
|
||||
stack: tuple[str, ...],
|
||||
) -> tuple[str, ...]:
|
||||
if expression is None:
|
||||
return ()
|
||||
if expression.op == "measure":
|
||||
reference = expression.ref or ""
|
||||
target = measures.get(reference)
|
||||
if target is None:
|
||||
raise PostgresPlanningError(
|
||||
f"Calculated measure references unknown measure: {reference}."
|
||||
)
|
||||
if target.aggregation != "calculated":
|
||||
return (reference,)
|
||||
if reference in stack:
|
||||
raise PostgresPlanningError(
|
||||
"Calculated measure dependency cycle: "
|
||||
+ " -> ".join((*stack, reference))
|
||||
)
|
||||
return _calculated_dependencies(
|
||||
target.expression,
|
||||
measures,
|
||||
stack=(*stack, reference),
|
||||
)
|
||||
dependencies: list[str] = []
|
||||
for item in expression.args:
|
||||
dependencies.extend(_calculated_dependencies(item, measures, stack=stack))
|
||||
return tuple(dict.fromkeys(dependencies))
|
||||
|
||||
|
||||
def _dimension_value(
|
||||
dimension: DimensionDefinition,
|
||||
parameters: dict[str, object],
|
||||
prefix: str,
|
||||
) -> str:
|
||||
return _source_value(dimension.field, dimension.type, parameters, prefix)
|
||||
|
||||
|
||||
def _source_value(
|
||||
field: str,
|
||||
field_type: str,
|
||||
parameters: dict[str, object],
|
||||
prefix: str,
|
||||
) -> str:
|
||||
parameters[prefix] = field
|
||||
raw = f"source_row ->> :{prefix}"
|
||||
if field_type == "integer":
|
||||
return f"NULLIF({raw}, '')::bigint"
|
||||
if field_type == "number":
|
||||
return f"NULLIF({raw}, '')::numeric"
|
||||
if field_type == "boolean":
|
||||
return f"NULLIF({raw}, '')::boolean"
|
||||
if field_type == "date":
|
||||
return f"NULLIF({raw}, '')::date"
|
||||
if field_type == "datetime":
|
||||
return f"NULLIF({raw}, '')::timestamptz"
|
||||
if field_type == "json":
|
||||
return f"source_row -> :{prefix}"
|
||||
return raw
|
||||
|
||||
|
||||
def _known(keys: Sequence[str], available: Mapping[str, object], label: str) -> None:
|
||||
unknown = set(keys) - set(available)
|
||||
if unknown:
|
||||
raise PostgresPlanningError(
|
||||
f"Report query references unknown {label}: " + ", ".join(sorted(unknown))
|
||||
)
|
||||
|
||||
|
||||
def _quote(value: str) -> str:
|
||||
if not _IDENTIFIER.fullmatch(value):
|
||||
raise PostgresPlanningError(f"Unsafe Reporting identifier: {value!r}.")
|
||||
return '"' + value.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def _like(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _json_value(value: object) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
integral = value.to_integral_value()
|
||||
return int(integral) if value == integral else float(value)
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"POSTGRES_PLANNER_VERSION",
|
||||
"CompiledPostgresPlan",
|
||||
"PostgresPlanningError",
|
||||
"compile_postgres_query",
|
||||
"execute_postgres_query",
|
||||
]
|
||||
@@ -0,0 +1,835 @@
|
||||
"""Governed execution boundary for module-contributed reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import csv
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from hashlib import sha256
|
||||
import io
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.core.reporting import (
|
||||
ReportDescriptor,
|
||||
ReportParameterOption,
|
||||
ReportProvider,
|
||||
ReportProviderRequest,
|
||||
ReportingGovernanceDecision,
|
||||
ReportingGovernanceRequest,
|
||||
report_providers,
|
||||
reporting_governance_provider,
|
||||
)
|
||||
from govoplan_reporting.backend.db.models import (
|
||||
ReportingProviderExecution,
|
||||
ReportingProviderExport,
|
||||
)
|
||||
|
||||
|
||||
class ProviderReportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def list_provider_reports(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
) -> dict[str, object]:
|
||||
reports: list[dict[str, object]] = []
|
||||
diagnostics: list[dict[str, str]] = []
|
||||
for provider_id, provider in report_providers(registry):
|
||||
try:
|
||||
for descriptor in provider.list_reports(session, principal):
|
||||
_validate_descriptor(provider_id, descriptor)
|
||||
decision = _governance_decision(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
descriptor=descriptor,
|
||||
action="catalogue",
|
||||
purpose=None,
|
||||
audience_scope={},
|
||||
)
|
||||
reports.append(
|
||||
{
|
||||
**descriptor.to_dict(),
|
||||
"available": decision.allowed,
|
||||
"unavailable_reason": decision.reason,
|
||||
"governance": _decision_payload(decision),
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - isolate optional providers.
|
||||
diagnostics.append(
|
||||
{
|
||||
"provider_id": provider_id,
|
||||
"code": "provider_catalogue_failed",
|
||||
"message": "The provider catalogue could not be loaded.",
|
||||
"error_type": type(exc).__name__,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"reports": sorted(
|
||||
reports,
|
||||
key=lambda item: (str(item["title"]), str(item["provider_id"])),
|
||||
),
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
|
||||
def provider_parameter_options(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
provider_id: str,
|
||||
report_id: str,
|
||||
parameter_key: str,
|
||||
query: str,
|
||||
limit: int,
|
||||
) -> tuple[ReportParameterOption, ...]:
|
||||
provider, descriptor = _provider_report(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
provider_id=provider_id,
|
||||
report_id=report_id,
|
||||
)
|
||||
parameter = next(
|
||||
(item for item in descriptor.parameters if item.key == parameter_key),
|
||||
None,
|
||||
)
|
||||
if parameter is None or not parameter.options_from_provider:
|
||||
raise ProviderReportError("This report parameter has no provider options")
|
||||
return provider.parameter_options(
|
||||
session,
|
||||
principal,
|
||||
report_id=report_id,
|
||||
parameter_key=parameter_key,
|
||||
query=query,
|
||||
limit=max(1, min(limit, 200)),
|
||||
)
|
||||
|
||||
|
||||
def execute_provider_report(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
provider_id: str,
|
||||
report_id: str,
|
||||
parameters: Mapping[str, object],
|
||||
purpose: str,
|
||||
audience_scope: Mapping[str, object],
|
||||
idempotency_key: str,
|
||||
) -> dict[str, object]:
|
||||
provider, descriptor = _provider_report(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
provider_id=provider_id,
|
||||
report_id=report_id,
|
||||
)
|
||||
clean_purpose = purpose.strip()
|
||||
clean_idempotency_key = idempotency_key.strip()
|
||||
clean_parameters = _validated_parameters(descriptor, parameters)
|
||||
clean_audience = _json_mapping(audience_scope, "audience scope")
|
||||
if descriptor.purpose_required and not clean_purpose:
|
||||
raise ProviderReportError("A report purpose is required")
|
||||
if descriptor.audience_scope_required and not clean_audience:
|
||||
raise ProviderReportError("An effective audience scope is required")
|
||||
request_hash = _hash(
|
||||
{
|
||||
"provider_id": provider_id,
|
||||
"report_id": report_id,
|
||||
"revision": descriptor.revision,
|
||||
"parameters": clean_parameters,
|
||||
"purpose": clean_purpose,
|
||||
"audience_scope": clean_audience,
|
||||
}
|
||||
)
|
||||
replay = (
|
||||
session.query(ReportingProviderExecution)
|
||||
.filter(
|
||||
ReportingProviderExecution.tenant_id == _tenant(principal),
|
||||
ReportingProviderExecution.idempotency_key == clean_idempotency_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if replay is not None:
|
||||
if replay.request_sha256 != request_hash:
|
||||
raise ProviderReportError(
|
||||
"The provider-report idempotency key belongs to another request"
|
||||
)
|
||||
if _expired(replay) or replay.retention_redacted_at is not None:
|
||||
raise ProviderReportError(
|
||||
"The replayed provider-report result has expired; use a new idempotency key"
|
||||
)
|
||||
_require_result_access(provider, session, principal, replay)
|
||||
return provider_execution_payload(replay)
|
||||
|
||||
preflight = _governance_decision(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
descriptor=descriptor,
|
||||
action="execute",
|
||||
purpose=clean_purpose,
|
||||
audience_scope=clean_audience,
|
||||
)
|
||||
_require_allowed(preflight)
|
||||
result = provider.execute_report(
|
||||
session,
|
||||
principal,
|
||||
request=ReportProviderRequest(
|
||||
report_id=report_id,
|
||||
parameters=clean_parameters,
|
||||
purpose=clean_purpose,
|
||||
audience_scope=clean_audience,
|
||||
),
|
||||
)
|
||||
_validate_result(descriptor, result, tenant_id=_tenant(principal))
|
||||
decision = _governance_decision(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
descriptor=descriptor,
|
||||
action="execute",
|
||||
purpose=clean_purpose,
|
||||
audience_scope=clean_audience,
|
||||
applied_privacy_transforms=result.applied_privacy_transforms,
|
||||
)
|
||||
_require_allowed(decision)
|
||||
missing_policy_transforms = set(decision.required_privacy_transforms) - set(
|
||||
result.applied_privacy_transforms
|
||||
)
|
||||
if missing_policy_transforms:
|
||||
raise ProviderReportError(
|
||||
"The report omitted Policy-required privacy transformations: "
|
||||
+ ", ".join(sorted(missing_policy_transforms))
|
||||
)
|
||||
generated_at = _aware(result.generated_at)
|
||||
result_payload = _json_mapping(result.payload, "provider report payload")
|
||||
expires_at = (
|
||||
generated_at + timedelta(days=decision.retention_days)
|
||||
if decision.retention_days is not None
|
||||
else None
|
||||
)
|
||||
row = ReportingProviderExecution(
|
||||
tenant_id=_tenant(principal),
|
||||
execution_id=str(uuid.uuid4()),
|
||||
provider_id=provider_id,
|
||||
report_id=report_id,
|
||||
report_revision=descriptor.revision,
|
||||
contract_version=descriptor.contract_version,
|
||||
idempotency_key=clean_idempotency_key,
|
||||
request_sha256=request_hash,
|
||||
purpose=clean_purpose,
|
||||
audience_scope=clean_audience,
|
||||
parameters=clean_parameters,
|
||||
result_schema=[item.to_dict() for item in descriptor.result_schema],
|
||||
result_payload=result_payload,
|
||||
source_revisions=[dict(item) for item in result.source_revisions],
|
||||
effective_scope=dict(result.effective_scope),
|
||||
privacy_transforms=list(result.applied_privacy_transforms),
|
||||
provenance=dict(result.provenance),
|
||||
governance_provenance=dict(decision.provenance),
|
||||
retention_class=descriptor.retention_class,
|
||||
retention_days=decision.retention_days,
|
||||
expires_at=expires_at,
|
||||
output_hash=_hash(result_payload),
|
||||
generated_at=generated_at,
|
||||
actor_id=_actor(principal),
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="reporting.provider_report.executed",
|
||||
object_type="reporting_provider_execution",
|
||||
object_id=row.execution_id,
|
||||
details={
|
||||
"provider_id": provider_id,
|
||||
"report_id": report_id,
|
||||
"report_revision": descriptor.revision,
|
||||
"purpose": clean_purpose,
|
||||
"audience_scope": clean_audience,
|
||||
"source_revision_count": len(row.source_revisions),
|
||||
"privacy_transforms": row.privacy_transforms,
|
||||
"retention_class": row.retention_class,
|
||||
"retention_days": row.retention_days,
|
||||
"output_hash": row.output_hash,
|
||||
},
|
||||
)
|
||||
return provider_execution_payload(row)
|
||||
|
||||
|
||||
def get_provider_execution(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
execution_id: str,
|
||||
) -> dict[str, object] | None:
|
||||
row = _provider_execution(session, principal, execution_id=execution_id)
|
||||
if row is None or _expired(row):
|
||||
return None
|
||||
provider, _descriptor = _provider_report(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
provider_id=row.provider_id,
|
||||
report_id=row.report_id,
|
||||
)
|
||||
_require_result_access(provider, session, principal, row)
|
||||
return provider_execution_payload(row)
|
||||
|
||||
|
||||
def export_provider_execution(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
execution_id: str,
|
||||
format: str,
|
||||
purpose: str,
|
||||
audience_scope: Mapping[str, object],
|
||||
) -> tuple[bytes, str, str]:
|
||||
row = _provider_execution(session, principal, execution_id=execution_id)
|
||||
if row is None or _expired(row):
|
||||
raise LookupError("Provider report execution not found")
|
||||
provider, descriptor = _provider_report(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
provider_id=row.provider_id,
|
||||
report_id=row.report_id,
|
||||
)
|
||||
_require_result_access(provider, session, principal, row)
|
||||
clean_format = format.strip().lower()
|
||||
if clean_format not in descriptor.export_formats:
|
||||
raise ProviderReportError("This report does not support the export format")
|
||||
clean_purpose = purpose.strip()
|
||||
clean_audience = _json_mapping(audience_scope, "audience scope")
|
||||
decision = _governance_decision(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
descriptor=descriptor,
|
||||
action="export",
|
||||
purpose=clean_purpose,
|
||||
audience_scope=clean_audience,
|
||||
export_format=clean_format,
|
||||
applied_privacy_transforms=tuple(row.privacy_transforms or ()),
|
||||
)
|
||||
_require_allowed(decision)
|
||||
if clean_format not in decision.export_formats:
|
||||
raise ProviderReportError("Policy does not allow this export format")
|
||||
content, media_type, extension = _serialize_export(
|
||||
clean_format,
|
||||
row.result_payload,
|
||||
row.result_schema,
|
||||
)
|
||||
output_hash = sha256(content).hexdigest()
|
||||
export = ReportingProviderExport(
|
||||
tenant_id=row.tenant_id,
|
||||
export_id=str(uuid.uuid4()),
|
||||
provider_execution_id=row.id,
|
||||
execution_id=row.execution_id,
|
||||
format=clean_format,
|
||||
purpose=clean_purpose,
|
||||
audience_scope=clean_audience,
|
||||
output_hash=output_hash,
|
||||
exported_at=datetime.now(UTC),
|
||||
actor_id=_actor(principal),
|
||||
)
|
||||
session.add(export)
|
||||
session.flush()
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="reporting.provider_report.exported",
|
||||
object_type="reporting_provider_execution",
|
||||
object_id=row.execution_id,
|
||||
details={
|
||||
"export_id": export.export_id,
|
||||
"format": clean_format,
|
||||
"purpose": clean_purpose,
|
||||
"audience_scope": clean_audience,
|
||||
"output_hash": output_hash,
|
||||
},
|
||||
)
|
||||
filename = f"{row.provider_id}-{row.report_id}-{row.execution_id}.{extension}"
|
||||
return content, media_type, filename
|
||||
|
||||
|
||||
def list_provider_exports(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
execution_id: str,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
row = _provider_execution(session, principal, execution_id=execution_id)
|
||||
if row is None or _expired(row):
|
||||
return ()
|
||||
provider, _descriptor = _provider_report(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
provider_id=row.provider_id,
|
||||
report_id=row.report_id,
|
||||
)
|
||||
_require_result_access(provider, session, principal, row)
|
||||
exports = (
|
||||
session.query(ReportingProviderExport)
|
||||
.filter(
|
||||
ReportingProviderExport.tenant_id == row.tenant_id,
|
||||
ReportingProviderExport.provider_execution_id == row.id,
|
||||
)
|
||||
.order_by(ReportingProviderExport.exported_at.desc())
|
||||
.all()
|
||||
)
|
||||
return tuple(
|
||||
{
|
||||
"export_id": item.export_id,
|
||||
"execution_id": item.execution_id,
|
||||
"format": item.format,
|
||||
"purpose": item.purpose,
|
||||
"audience_scope": dict(item.audience_scope or {}),
|
||||
"output_hash": item.output_hash,
|
||||
"exported_at": item.exported_at.isoformat(),
|
||||
"actor_id": item.actor_id,
|
||||
}
|
||||
for item in exports
|
||||
)
|
||||
|
||||
|
||||
def provider_execution_payload(row: ReportingProviderExecution) -> dict[str, object]:
|
||||
return {
|
||||
"execution_id": row.execution_id,
|
||||
"provider_id": row.provider_id,
|
||||
"report_id": row.report_id,
|
||||
"report_revision": row.report_revision,
|
||||
"contract_version": row.contract_version,
|
||||
"purpose": row.purpose,
|
||||
"audience_scope": dict(row.audience_scope or {}),
|
||||
"parameters": dict(row.parameters or {}),
|
||||
"result_schema": list(row.result_schema or []),
|
||||
"result": dict(row.result_payload or {}),
|
||||
"source_revisions": list(row.source_revisions or []),
|
||||
"effective_scope": dict(row.effective_scope or {}),
|
||||
"privacy_transforms": list(row.privacy_transforms or []),
|
||||
"provenance": dict(row.provenance or {}),
|
||||
"governance_provenance": dict(row.governance_provenance or {}),
|
||||
"retention_class": row.retention_class,
|
||||
"retention_days": row.retention_days,
|
||||
"expires_at": row.expires_at.isoformat() if row.expires_at else None,
|
||||
"output_hash": row.output_hash,
|
||||
"generated_at": row.generated_at.isoformat(),
|
||||
"actor_id": row.actor_id,
|
||||
}
|
||||
|
||||
|
||||
def _provider_report(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
provider_id: str,
|
||||
report_id: str,
|
||||
) -> tuple[ReportProvider, ReportDescriptor]:
|
||||
provider = dict(report_providers(registry)).get(provider_id)
|
||||
if provider is None:
|
||||
raise LookupError("Report provider is unavailable")
|
||||
descriptor = next(
|
||||
(
|
||||
item
|
||||
for item in provider.list_reports(session, principal)
|
||||
if item.report_id == report_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if descriptor is None:
|
||||
raise LookupError("Provider report is unavailable")
|
||||
_validate_descriptor(provider_id, descriptor)
|
||||
return provider, descriptor
|
||||
|
||||
|
||||
def _validate_descriptor(provider_id: str, descriptor: ReportDescriptor) -> None:
|
||||
if descriptor.provider_id != provider_id:
|
||||
raise ProviderReportError("Report descriptor provider id differs")
|
||||
if not descriptor.report_id or not descriptor.revision:
|
||||
raise ProviderReportError("Report descriptors require stable ids and revisions")
|
||||
parameter_keys = [item.key for item in descriptor.parameters]
|
||||
field_paths = [item.path for item in descriptor.result_schema]
|
||||
transform_ids = [item.id for item in descriptor.privacy_transforms]
|
||||
if len(parameter_keys) != len(set(parameter_keys)):
|
||||
raise ProviderReportError("Report parameter keys must be unique")
|
||||
if not field_paths or len(field_paths) != len(set(field_paths)):
|
||||
raise ProviderReportError("Report result paths must be non-empty and unique")
|
||||
if len(transform_ids) != len(set(transform_ids)):
|
||||
raise ProviderReportError("Report privacy transforms must be unique")
|
||||
if any(item.sensitive for item in descriptor.result_schema) and (
|
||||
descriptor.reidentification_risk != "high"
|
||||
):
|
||||
raise ProviderReportError(
|
||||
"Sensitive result fields require high risk classification"
|
||||
)
|
||||
|
||||
|
||||
def _validate_result(
|
||||
descriptor: ReportDescriptor,
|
||||
result: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> None:
|
||||
if not hasattr(result, "report_id") or result.report_id != descriptor.report_id:
|
||||
raise ProviderReportError("Provider result report id differs")
|
||||
if not result.source_revisions:
|
||||
raise ProviderReportError("Provider result has no source revision provenance")
|
||||
if str(result.effective_scope.get("tenant_id") or "") != tenant_id:
|
||||
raise ProviderReportError(
|
||||
"Provider result effective scope differs from the tenant"
|
||||
)
|
||||
required = {item.id for item in descriptor.privacy_transforms if item.required}
|
||||
missing = required - set(result.applied_privacy_transforms)
|
||||
if missing:
|
||||
raise ProviderReportError(
|
||||
"Provider result omitted required privacy transformations: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
declared = {item.path: item for item in descriptor.result_schema}
|
||||
for field in descriptor.result_schema:
|
||||
if field.type != "suppressed_count":
|
||||
continue
|
||||
value = _path_value(result.payload, field.path)
|
||||
if not isinstance(value, Mapping) or set(value) != {"value", "suppressed"}:
|
||||
raise ProviderReportError(
|
||||
f"Provider result has an invalid suppressed count: {field.path}"
|
||||
)
|
||||
if value["value"] is not None and (
|
||||
not isinstance(value["value"], int) or isinstance(value["value"], bool)
|
||||
):
|
||||
raise ProviderReportError(
|
||||
f"Provider result has an invalid suppressed-count value: {field.path}"
|
||||
)
|
||||
if not isinstance(value["suppressed"], bool):
|
||||
raise ProviderReportError(
|
||||
f"Provider result has an invalid suppression flag: {field.path}"
|
||||
)
|
||||
for path in _leaf_paths(result.payload):
|
||||
if path in declared:
|
||||
continue
|
||||
if any(
|
||||
path.startswith(declared_path + ".")
|
||||
and field.type in {"object", "suppressed_count"}
|
||||
for declared_path, field in declared.items()
|
||||
):
|
||||
continue
|
||||
raise ProviderReportError(f"Provider result contains undeclared field: {path}")
|
||||
|
||||
|
||||
def _validated_parameters(
|
||||
descriptor: ReportDescriptor,
|
||||
parameters: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
clean = _json_mapping(parameters, "report parameters")
|
||||
declared = {item.key: item for item in descriptor.parameters}
|
||||
unknown = set(clean) - set(declared)
|
||||
if unknown:
|
||||
raise ProviderReportError(
|
||||
"Unknown report parameters: " + ", ".join(sorted(unknown))
|
||||
)
|
||||
missing = [
|
||||
item.key
|
||||
for item in descriptor.parameters
|
||||
if item.required and clean.get(item.key) in (None, "")
|
||||
]
|
||||
if missing:
|
||||
raise ProviderReportError(
|
||||
"Missing report parameters: " + ", ".join(sorted(missing))
|
||||
)
|
||||
invalid = [
|
||||
item.key
|
||||
for item in descriptor.parameters
|
||||
if item.key in clean
|
||||
and clean[item.key] is not None
|
||||
and not _valid_parameter_value(item.type, clean[item.key])
|
||||
]
|
||||
if invalid:
|
||||
raise ProviderReportError(
|
||||
"Invalid report parameter types: " + ", ".join(sorted(invalid))
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _valid_parameter_value(parameter_type: str, value: object) -> bool:
|
||||
if parameter_type in {"string", "reference"}:
|
||||
return isinstance(value, str)
|
||||
if parameter_type == "boolean":
|
||||
return isinstance(value, bool)
|
||||
if parameter_type == "integer":
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
if parameter_type == "number":
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
if parameter_type == "date":
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
if parameter_type == "datetime":
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _governance_decision(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
descriptor: ReportDescriptor,
|
||||
action: str,
|
||||
purpose: str | None,
|
||||
audience_scope: Mapping[str, object],
|
||||
export_format: str | None = None,
|
||||
applied_privacy_transforms: tuple[str, ...] = (),
|
||||
) -> ReportingGovernanceDecision:
|
||||
required = tuple(item.id for item in descriptor.privacy_transforms if item.required)
|
||||
provider = reporting_governance_provider(registry)
|
||||
if provider is None:
|
||||
allowed = descriptor.reidentification_risk != "high"
|
||||
if descriptor.purpose_required and action != "catalogue" and not purpose:
|
||||
allowed = False
|
||||
if (
|
||||
descriptor.audience_scope_required
|
||||
and action != "catalogue"
|
||||
and not audience_scope
|
||||
):
|
||||
allowed = False
|
||||
if action == "export" and export_format not in descriptor.export_formats:
|
||||
allowed = False
|
||||
return ReportingGovernanceDecision(
|
||||
allowed=allowed,
|
||||
reason=None
|
||||
if allowed
|
||||
else "The built-in Reporting privacy baseline denied this action.",
|
||||
retention_days=30,
|
||||
export_formats=descriptor.export_formats,
|
||||
required_privacy_transforms=required,
|
||||
provenance={"provider": "reporting.built_in_baseline", "version": "1"},
|
||||
)
|
||||
decision = provider.decide_reporting_action(
|
||||
session,
|
||||
principal,
|
||||
request=ReportingGovernanceRequest(
|
||||
action=action, # type: ignore[arg-type]
|
||||
tenant_id=_tenant(principal),
|
||||
provider_id=descriptor.provider_id,
|
||||
report_id=descriptor.report_id,
|
||||
purpose=purpose,
|
||||
audience_scope=audience_scope,
|
||||
retention_class=descriptor.retention_class,
|
||||
export_format=export_format,
|
||||
reidentification_risk=descriptor.reidentification_risk,
|
||||
declared_privacy_transforms=required,
|
||||
applied_privacy_transforms=applied_privacy_transforms,
|
||||
),
|
||||
)
|
||||
unsupported = set(decision.required_privacy_transforms) - {
|
||||
item.id for item in descriptor.privacy_transforms
|
||||
}
|
||||
if not unsupported:
|
||||
return decision
|
||||
return ReportingGovernanceDecision(
|
||||
allowed=False,
|
||||
reason=(
|
||||
"Policy requires privacy transformations this report does not support: "
|
||||
+ ", ".join(sorted(unsupported))
|
||||
),
|
||||
retention_days=decision.retention_days,
|
||||
export_formats=decision.export_formats,
|
||||
required_privacy_transforms=decision.required_privacy_transforms,
|
||||
provenance={
|
||||
**dict(decision.provenance),
|
||||
"unsupported_required_privacy_transforms": sorted(unsupported),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _serialize_export(
|
||||
format: str,
|
||||
payload: Mapping[str, object],
|
||||
schema: list[dict[str, object]],
|
||||
) -> tuple[bytes, str, str]:
|
||||
if format == "json":
|
||||
return (
|
||||
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True).encode(
|
||||
"utf-8"
|
||||
),
|
||||
"application/json",
|
||||
"json",
|
||||
)
|
||||
if format != "csv":
|
||||
raise ProviderReportError("Unsupported provider report export format")
|
||||
output = io.StringIO(newline="")
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([str(item["label"]) for item in schema])
|
||||
writer.writerow(
|
||||
[_csv_value(_path_value(payload, str(item["path"]))) for item in schema]
|
||||
)
|
||||
return output.getvalue().encode("utf-8-sig"), "text/csv; charset=utf-8", "csv"
|
||||
|
||||
|
||||
def _csv_value(value: object) -> object:
|
||||
if isinstance(value, Mapping):
|
||||
if value.get("suppressed") is True:
|
||||
return "suppressed"
|
||||
if set(value).issuperset({"value", "suppressed"}):
|
||||
value = value.get("value")
|
||||
else:
|
||||
value = json.dumps(value, ensure_ascii=False, sort_keys=True)
|
||||
if isinstance(value, (list, tuple)):
|
||||
value = json.dumps(value, ensure_ascii=False, sort_keys=True)
|
||||
if isinstance(value, str) and value.startswith(("=", "+", "-", "@", "\t", "\r")):
|
||||
return "'" + value
|
||||
return "" if value is None else value
|
||||
|
||||
|
||||
def _path_value(payload: Mapping[str, object], path: str) -> object:
|
||||
value: object = payload
|
||||
for part in path.split("."):
|
||||
if not isinstance(value, Mapping):
|
||||
return None
|
||||
value = value.get(part)
|
||||
return value
|
||||
|
||||
|
||||
def _leaf_paths(value: object, prefix: str = "") -> tuple[str, ...]:
|
||||
if isinstance(value, Mapping):
|
||||
rows: list[str] = []
|
||||
for key, item in value.items():
|
||||
path = f"{prefix}.{key}" if prefix else str(key)
|
||||
rows.extend(_leaf_paths(item, path))
|
||||
return tuple(rows)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return (prefix,)
|
||||
return (prefix,)
|
||||
|
||||
|
||||
def _provider_execution(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
execution_id: str,
|
||||
) -> ReportingProviderExecution | None:
|
||||
return (
|
||||
session.query(ReportingProviderExecution)
|
||||
.filter(
|
||||
ReportingProviderExecution.tenant_id == _tenant(principal),
|
||||
ReportingProviderExecution.execution_id == execution_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def _require_result_access(
|
||||
provider: ReportProvider,
|
||||
session: Session,
|
||||
principal: object,
|
||||
row: ReportingProviderExecution,
|
||||
) -> None:
|
||||
if not provider.authorize_result(
|
||||
session,
|
||||
principal,
|
||||
report_id=row.report_id,
|
||||
source_revisions=tuple(row.source_revisions or ()),
|
||||
effective_scope=dict(row.effective_scope or {}),
|
||||
):
|
||||
raise PermissionError(
|
||||
"The source module no longer permits access to this report result"
|
||||
)
|
||||
|
||||
|
||||
def _expired(row: ReportingProviderExecution) -> bool:
|
||||
if row.expires_at is None:
|
||||
return False
|
||||
return _aware(row.expires_at) <= datetime.now(UTC)
|
||||
|
||||
|
||||
def _require_allowed(decision: ReportingGovernanceDecision) -> None:
|
||||
if not decision.allowed:
|
||||
raise PermissionError(decision.reason or "Reporting Policy denied this action")
|
||||
|
||||
|
||||
def _decision_payload(decision: ReportingGovernanceDecision) -> dict[str, object]:
|
||||
return {
|
||||
"allowed": decision.allowed,
|
||||
"reason": decision.reason,
|
||||
"retention_days": decision.retention_days,
|
||||
"export_formats": list(decision.export_formats),
|
||||
"required_privacy_transforms": list(decision.required_privacy_transforms),
|
||||
"provenance": dict(decision.provenance),
|
||||
}
|
||||
|
||||
|
||||
def _json_mapping(value: Mapping[str, object], label: str) -> dict[str, object]:
|
||||
try:
|
||||
payload = json.loads(json.dumps(value, sort_keys=True, separators=(",", ":")))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ProviderReportError(f"The {label} must contain JSON values") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ProviderReportError(f"The {label} must be an object")
|
||||
return payload
|
||||
|
||||
|
||||
def _hash(value: object) -> str:
|
||||
return sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _tenant(principal: object) -> str:
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id:
|
||||
raise PermissionError("A tenant principal is required")
|
||||
return tenant_id
|
||||
|
||||
|
||||
def _actor(principal: object) -> str | None:
|
||||
user = getattr(principal, "user", None)
|
||||
actor_id = getattr(user, "id", None)
|
||||
return str(actor_id) if actor_id else None
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProviderReportError",
|
||||
"execute_provider_report",
|
||||
"export_provider_execution",
|
||||
"get_provider_execution",
|
||||
"list_provider_exports",
|
||||
"list_provider_reports",
|
||||
"provider_parameter_options",
|
||||
]
|
||||
@@ -0,0 +1,303 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
import csv
|
||||
from html import escape
|
||||
from io import StringIO
|
||||
import json
|
||||
import re
|
||||
|
||||
from govoplan_core.core.files import (
|
||||
CAPABILITY_FILES_ARTIFACT_STORE,
|
||||
ManagedArtifactStore,
|
||||
ManagedArtifactWriteRequest,
|
||||
)
|
||||
from govoplan_core.core.mail import (
|
||||
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
|
||||
NotificationMailDeliveryProvider,
|
||||
NotificationMailDeliveryRequest,
|
||||
)
|
||||
from govoplan_reporting.backend.contracts import (
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||
CAPABILITY_REPORTING_PUBLICATION_MAIL,
|
||||
ReportingPublicationPayload,
|
||||
capability,
|
||||
)
|
||||
|
||||
|
||||
class FilesReportingPublicationTarget:
|
||||
def __init__(self, registry: object | None) -> None:
|
||||
self.registry = registry
|
||||
|
||||
def publish_report(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
payload: ReportingPublicationPayload,
|
||||
) -> Mapping[str, object]:
|
||||
provider = capability(self.registry, CAPABILITY_FILES_ARTIFACT_STORE)
|
||||
if not isinstance(provider, ManagedArtifactStore):
|
||||
raise RuntimeError(
|
||||
"Files publication requires the enabled files.artifact_store capability."
|
||||
)
|
||||
content, content_type, extension = _serialize(payload)
|
||||
filename = _filename(payload, extension)
|
||||
folder = str(payload.target_ref or "Generated/Reports").strip()
|
||||
stored = provider.store_artifact(
|
||||
session,
|
||||
principal,
|
||||
request=ManagedArtifactWriteRequest(
|
||||
filename=filename,
|
||||
payload=content,
|
||||
content_type=content_type,
|
||||
folder=folder,
|
||||
description=(
|
||||
f"Reporting publication for {payload.report_id} revision "
|
||||
f"{payload.report_revision}."
|
||||
),
|
||||
idempotency_key=f"reporting:{payload.publication_id}",
|
||||
metadata={
|
||||
"producer_module": "reporting",
|
||||
"publication_id": payload.publication_id,
|
||||
"execution_id": payload.execution_id,
|
||||
"report_id": payload.report_id,
|
||||
"report_revision": payload.report_revision,
|
||||
"output_hash": payload.output_hash,
|
||||
},
|
||||
),
|
||||
)
|
||||
return {
|
||||
"provider": CAPABILITY_FILES_ARTIFACT_STORE,
|
||||
"status": "stored",
|
||||
"file_asset_id": stored.file_asset_id,
|
||||
"file_version_id": stored.file_version_id,
|
||||
"filename": stored.filename,
|
||||
"display_path": stored.display_path,
|
||||
"sha256": stored.sha256,
|
||||
"size_bytes": stored.size_bytes,
|
||||
"output_hash": payload.output_hash,
|
||||
}
|
||||
|
||||
|
||||
class MailReportingPublicationTarget:
|
||||
def __init__(self, registry: object | None) -> None:
|
||||
self.registry = registry
|
||||
|
||||
def publish_report(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
payload: ReportingPublicationPayload,
|
||||
) -> Mapping[str, object]:
|
||||
provider = capability(self.registry, CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
|
||||
if not isinstance(provider, NotificationMailDeliveryProvider):
|
||||
raise RuntimeError(
|
||||
"Mail publication requires the enabled mail.notificationDelivery capability."
|
||||
)
|
||||
recipient = str(payload.target_ref or "").strip()
|
||||
if not recipient:
|
||||
raise ValueError("Mail publication requires a recipient address.")
|
||||
options = dict(payload.options)
|
||||
profile_id = _required_option(options, "mail_profile_id", "Mail profile")
|
||||
from_address = _required_option(options, "from_address", "Sender address")
|
||||
subject = str(
|
||||
options.get("subject")
|
||||
or f"Report {payload.report_id} revision {payload.report_revision}"
|
||||
).strip()
|
||||
action_url = str(options.get("action_url") or "").strip() or None
|
||||
preview = _text_preview(payload.rows, payload.schema)
|
||||
result = provider.submit_notification_mail(
|
||||
session,
|
||||
NotificationMailDeliveryRequest(
|
||||
tenant_id=payload.tenant_id,
|
||||
notification_id=f"reporting-publication:{payload.publication_id}",
|
||||
recipient=recipient,
|
||||
subject=subject,
|
||||
body_text=(
|
||||
f"Report: {payload.report_id}\n"
|
||||
f"Revision: {payload.report_revision}\n"
|
||||
f"Rows: {len(payload.rows)}\n"
|
||||
f"Output hash: {payload.output_hash}\n\n"
|
||||
f"{preview}"
|
||||
),
|
||||
action_url=action_url,
|
||||
mail_profile_id=profile_id,
|
||||
from_address=from_address,
|
||||
smtp_server_id=_optional(options.get("smtp_server_id")),
|
||||
smtp_credential_id=_optional(options.get("smtp_credential_id")),
|
||||
metadata={
|
||||
"producer_module": "reporting",
|
||||
"publication_id": payload.publication_id,
|
||||
"execution_id": payload.execution_id,
|
||||
"report_id": payload.report_id,
|
||||
"report_revision": payload.report_revision,
|
||||
"output_hash": payload.output_hash,
|
||||
},
|
||||
),
|
||||
)
|
||||
status = str(result.get("status") or "").casefold()
|
||||
if status not in {"accepted", "queued", "submitted", "succeeded"}:
|
||||
raise RuntimeError(
|
||||
str(result.get("error") or "Mail did not accept the report publication.")
|
||||
)
|
||||
return {
|
||||
**dict(result),
|
||||
"publication_id": payload.publication_id,
|
||||
"recipient": recipient,
|
||||
"output_hash": payload.output_hash,
|
||||
}
|
||||
|
||||
|
||||
def publication_target_catalog(registry: object | None) -> tuple[dict[str, object], ...]:
|
||||
files_available = isinstance(
|
||||
capability(registry, CAPABILITY_FILES_ARTIFACT_STORE), ManagedArtifactStore
|
||||
)
|
||||
mail_available = isinstance(
|
||||
capability(registry, CAPABILITY_MAIL_NOTIFICATION_DELIVERY),
|
||||
NotificationMailDeliveryProvider,
|
||||
)
|
||||
return (
|
||||
{
|
||||
"capability": CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||
"label": "Files",
|
||||
"available": files_available,
|
||||
"reason": None
|
||||
if files_available
|
||||
else "Enable Files with managed artifact storage to publish durable report files.",
|
||||
"formats": ["csv", "json", "html"],
|
||||
"target_label": "Folder",
|
||||
"target_required": False,
|
||||
"required_options": [],
|
||||
},
|
||||
{
|
||||
"capability": CAPABILITY_REPORTING_PUBLICATION_MAIL,
|
||||
"label": "Mail",
|
||||
"available": mail_available,
|
||||
"reason": None
|
||||
if mail_available
|
||||
else "Enable Mail and configure its notification-delivery capability to publish report notices.",
|
||||
"formats": ["html"],
|
||||
"target_label": "Recipient",
|
||||
"target_required": True,
|
||||
"required_options": ["mail_profile_id", "from_address"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _serialize(payload: ReportingPublicationPayload) -> tuple[bytes, str, str]:
|
||||
if payload.format == "json":
|
||||
content = json.dumps(
|
||||
{
|
||||
"report_id": payload.report_id,
|
||||
"report_revision": payload.report_revision,
|
||||
"execution_id": payload.execution_id,
|
||||
"output_hash": payload.output_hash,
|
||||
"schema": list(payload.schema),
|
||||
"rows": list(payload.rows),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return content, "application/json", "json"
|
||||
if payload.format == "csv":
|
||||
fields = _fields(payload.rows, payload.schema)
|
||||
stream = StringIO(newline="")
|
||||
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for row in payload.rows:
|
||||
writer.writerow({key: _safe_csv(row.get(key)) for key in fields})
|
||||
return (
|
||||
stream.getvalue().encode("utf-8-sig"),
|
||||
"text/csv; charset=utf-8",
|
||||
"csv",
|
||||
)
|
||||
if payload.format == "html":
|
||||
fields = _fields(payload.rows, payload.schema)
|
||||
headers = "".join(f"<th scope=\"col\">{escape(key)}</th>" for key in fields)
|
||||
body = "".join(
|
||||
"<tr>"
|
||||
+ "".join(
|
||||
f"<td>{escape(_display(row.get(key)))}</td>" for key in fields
|
||||
)
|
||||
+ "</tr>"
|
||||
for row in payload.rows
|
||||
)
|
||||
content = (
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>"
|
||||
+ escape(payload.report_id)
|
||||
+ "</title></head><body><h1>"
|
||||
+ escape(payload.report_id)
|
||||
+ f"</h1><p>Revision {payload.report_revision}; output {escape(payload.output_hash)}</p>"
|
||||
+ f"<table><thead><tr>{headers}</tr></thead><tbody>{body}</tbody></table>"
|
||||
+ "</body></html>"
|
||||
)
|
||||
return content.encode("utf-8"), "text/html; charset=utf-8", "html"
|
||||
raise ValueError(
|
||||
"This publication target supports CSV, JSON, and accessible HTML. "
|
||||
"XLSX and PDF require a renderer provider."
|
||||
)
|
||||
|
||||
|
||||
def _filename(payload: ReportingPublicationPayload, extension: str) -> str:
|
||||
configured = str(payload.options.get("filename") or "").strip()
|
||||
stem = configured.rsplit(".", 1)[0] if configured else payload.report_id
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip(".-") or "report"
|
||||
return f"{safe}-r{payload.report_revision}.{extension}"
|
||||
|
||||
|
||||
def _fields(
|
||||
rows: Sequence[Mapping[str, object]], schema: Sequence[Mapping[str, object]]
|
||||
) -> list[str]:
|
||||
fields = [str(item.get("name")) for item in schema if item.get("name")]
|
||||
if fields:
|
||||
return fields
|
||||
return list(dict.fromkeys(str(key) for row in rows for key in row))
|
||||
|
||||
|
||||
def _safe_csv(value: object) -> object:
|
||||
if isinstance(value, (dict, list, tuple)):
|
||||
value = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if isinstance(value, str) and value.startswith(("=", "+", "-", "@")):
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
|
||||
def _display(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (dict, list, tuple)):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
return str(value)
|
||||
|
||||
|
||||
def _text_preview(
|
||||
rows: Sequence[Mapping[str, object]], schema: Sequence[Mapping[str, object]]
|
||||
) -> str:
|
||||
fields = _fields(rows, schema)[:8]
|
||||
lines = [" | ".join(fields)]
|
||||
lines.extend(" | ".join(_display(row.get(key)) for key in fields) for row in rows[:10])
|
||||
if len(rows) > 10:
|
||||
lines.append(f"... {len(rows) - 10} more rows")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _required_option(options: Mapping[str, object], key: str, label: str) -> str:
|
||||
value = str(options.get(key) or "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"{label} is required for Mail publication.")
|
||||
return value
|
||||
|
||||
|
||||
def _optional(value: object) -> str | None:
|
||||
clean = str(value or "").strip()
|
||||
return clean or None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FilesReportingPublicationTarget",
|
||||
"MailReportingPublicationTarget",
|
||||
"publication_target_catalog",
|
||||
]
|
||||
@@ -0,0 +1,495 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from govoplan_reporting.backend.schemas import (
|
||||
FilterClause,
|
||||
MeasureDefinition,
|
||||
ReportQuery,
|
||||
SemanticModelDefinition,
|
||||
TypedExpression,
|
||||
VisualizationDefinition,
|
||||
)
|
||||
|
||||
|
||||
QUERY_ENGINE_VERSION = "reporting-query-v1"
|
||||
|
||||
|
||||
class ReportingQueryError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QueryResult:
|
||||
rows: tuple[dict[str, Any], ...]
|
||||
total_rows: int
|
||||
schema: tuple[dict[str, Any], ...]
|
||||
truncated: bool
|
||||
diagnostics: tuple[dict[str, Any], ...] = ()
|
||||
|
||||
|
||||
def execute_semantic_query(
|
||||
rows: Sequence[Mapping[str, object]],
|
||||
semantic_model: SemanticModelDefinition,
|
||||
query: ReportQuery,
|
||||
) -> QueryResult:
|
||||
dimensions = {item.key: item for item in semantic_model.dimensions}
|
||||
measures = {item.key: item for item in semantic_model.measures}
|
||||
selected_dimensions = tuple(query.dimensions or semantic_model.default_dimensions)
|
||||
selected_measures = tuple(query.measures or semantic_model.default_measures)
|
||||
if query.mode == "pivot" and query.pivot is not None:
|
||||
selected_dimensions = tuple(
|
||||
dict.fromkeys((*query.pivot.rows, *query.pivot.columns))
|
||||
)
|
||||
selected_measures = tuple(query.pivot.measures or selected_measures)
|
||||
_require_known(selected_dimensions, dimensions, "dimensions")
|
||||
_require_known(selected_measures, measures, "measures")
|
||||
_require_known(
|
||||
tuple(item.dimension for item in query.filters),
|
||||
dimensions,
|
||||
"filter dimensions",
|
||||
)
|
||||
filtered = tuple(
|
||||
dict(row)
|
||||
for row in rows
|
||||
if all(
|
||||
_matches_filter(row.get(dimensions[item.dimension].field), item)
|
||||
for item in query.filters
|
||||
)
|
||||
)
|
||||
if query.mode == "detail":
|
||||
result_rows = _detail_rows(filtered, selected_dimensions, dimensions)
|
||||
else:
|
||||
result_rows = _summary_rows(
|
||||
filtered,
|
||||
selected_dimensions,
|
||||
selected_measures,
|
||||
dimensions,
|
||||
measures,
|
||||
)
|
||||
if query.mode == "pivot" and query.pivot is not None:
|
||||
result_rows = _pivot_rows(
|
||||
result_rows,
|
||||
row_dimensions=tuple(query.pivot.rows),
|
||||
column_dimensions=tuple(query.pivot.columns),
|
||||
measures=selected_measures,
|
||||
include_totals=query.pivot.include_totals,
|
||||
)
|
||||
sorted_rows = _sort_rows(result_rows, query)
|
||||
total = len(sorted_rows)
|
||||
selected = sorted_rows[query.offset : query.offset + query.limit]
|
||||
return QueryResult(
|
||||
rows=tuple(selected),
|
||||
total_rows=total,
|
||||
schema=infer_query_schema(selected or sorted_rows[:1]),
|
||||
truncated=query.offset + len(selected) < total,
|
||||
)
|
||||
|
||||
|
||||
class DefaultChartRenderer:
|
||||
"""Build a provider-neutral chart model with a mandatory table fallback."""
|
||||
|
||||
def render(
|
||||
self,
|
||||
*,
|
||||
visualization: VisualizationDefinition,
|
||||
result: QueryResult,
|
||||
) -> Mapping[str, object]:
|
||||
category = visualization.category_dimension
|
||||
measure_keys = tuple(visualization.measures)
|
||||
missing: set[str] = set()
|
||||
if visualization.kind not in {"table", "pivot", "metric"}:
|
||||
if not category or not measure_keys:
|
||||
missing = {"chart category and measure configuration"}
|
||||
else:
|
||||
available = {str(item.get("name")) for item in result.schema}
|
||||
missing = {category, *measure_keys} - available
|
||||
if missing and not visualization.tabular_fallback:
|
||||
raise ReportingQueryError(
|
||||
"Chart references unavailable result fields: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
return {
|
||||
"contract_version": "1",
|
||||
"kind": "table" if missing else visualization.kind,
|
||||
"requested_kind": visualization.kind,
|
||||
"category": category,
|
||||
"series": visualization.series_dimension,
|
||||
"measures": list(measure_keys),
|
||||
"options": dict(visualization.options),
|
||||
"fallback_reason": (
|
||||
"The selected query does not expose the fields required by the "
|
||||
"saved visualization. Showing its accessible table fallback."
|
||||
if missing
|
||||
else None
|
||||
),
|
||||
"rows": list(result.rows),
|
||||
"schema": list(result.schema),
|
||||
"tabular_fallback": (
|
||||
{
|
||||
"rows": list(result.rows),
|
||||
"schema": list(result.schema),
|
||||
}
|
||||
if visualization.tabular_fallback or missing
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _detail_rows(
|
||||
rows: Sequence[Mapping[str, object]],
|
||||
selected_dimensions: Sequence[str],
|
||||
dimensions: Mapping[str, object],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not selected_dimensions:
|
||||
return [
|
||||
{str(key): _json_value(value) for key, value in row.items()} for row in rows
|
||||
]
|
||||
return [
|
||||
{
|
||||
key: _json_value(row.get(getattr(dimensions[key], "field")))
|
||||
for key in selected_dimensions
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _summary_rows(
|
||||
rows: Sequence[Mapping[str, object]],
|
||||
selected_dimensions: Sequence[str],
|
||||
selected_measures: Sequence[str],
|
||||
dimensions: Mapping[str, object],
|
||||
measures: Mapping[str, MeasureDefinition],
|
||||
) -> list[dict[str, Any]]:
|
||||
grouped: dict[tuple[object, ...], list[Mapping[str, object]]] = defaultdict(list)
|
||||
if selected_dimensions:
|
||||
for row in rows:
|
||||
key = tuple(
|
||||
row.get(getattr(dimensions[item], "field"))
|
||||
for item in selected_dimensions
|
||||
)
|
||||
grouped[key].append(row)
|
||||
else:
|
||||
grouped[()] = list(rows)
|
||||
result: list[dict[str, Any]] = []
|
||||
for group_key, group_rows in grouped.items():
|
||||
item: dict[str, Any] = {
|
||||
dimension: _json_value(value)
|
||||
for dimension, value in zip(selected_dimensions, group_key, strict=True)
|
||||
}
|
||||
pending: list[MeasureDefinition] = []
|
||||
for key in selected_measures:
|
||||
measure = measures[key]
|
||||
if measure.aggregation == "calculated":
|
||||
pending.append(measure)
|
||||
else:
|
||||
item[key] = _aggregate(group_rows, measure)
|
||||
for measure in pending:
|
||||
item[measure.key] = _evaluate_expression(
|
||||
measure.expression,
|
||||
row=None,
|
||||
measures=item,
|
||||
)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def _aggregate(
|
||||
rows: Sequence[Mapping[str, object]],
|
||||
measure: MeasureDefinition,
|
||||
) -> object:
|
||||
if measure.aggregation == "count":
|
||||
if measure.field is None:
|
||||
return len(rows)
|
||||
return sum(row.get(measure.field) is not None for row in rows)
|
||||
values = [
|
||||
row.get(measure.field or "")
|
||||
for row in rows
|
||||
if row.get(measure.field or "") is not None
|
||||
]
|
||||
if measure.aggregation == "count_distinct":
|
||||
return len({_hashable(value) for value in values})
|
||||
if not values:
|
||||
return None
|
||||
if measure.aggregation == "sum":
|
||||
return _numeric_result(sum(_number(value) for value in values))
|
||||
if measure.aggregation == "average":
|
||||
return _numeric_result(sum(_number(value) for value in values) / len(values))
|
||||
if measure.aggregation == "minimum":
|
||||
return _json_value(min(values))
|
||||
if measure.aggregation == "maximum":
|
||||
return _json_value(max(values))
|
||||
raise ReportingQueryError(
|
||||
f"Unsupported measure aggregation: {measure.aggregation!r}."
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_expression(
|
||||
expression: TypedExpression | None,
|
||||
*,
|
||||
row: Mapping[str, object] | None,
|
||||
measures: Mapping[str, object],
|
||||
) -> object:
|
||||
if expression is None:
|
||||
return None
|
||||
if expression.op == "literal":
|
||||
return expression.value
|
||||
if expression.op == "field":
|
||||
return row.get(expression.ref or "") if row is not None else None
|
||||
if expression.op == "measure":
|
||||
return measures.get(expression.ref or "")
|
||||
values = [
|
||||
_evaluate_expression(item, row=row, measures=measures)
|
||||
for item in expression.args
|
||||
]
|
||||
if expression.op == "add":
|
||||
return _numeric_result(sum(_number(item) for item in values))
|
||||
if expression.op == "subtract":
|
||||
_arity(values, 2, expression.op)
|
||||
return _numeric_result(_number(values[0]) - _number(values[1]))
|
||||
if expression.op == "multiply":
|
||||
total = Decimal(1)
|
||||
for value in values:
|
||||
total *= _number(value)
|
||||
return _numeric_result(total)
|
||||
if expression.op == "divide":
|
||||
_arity(values, 2, expression.op)
|
||||
divisor = _number(values[1])
|
||||
return None if divisor == 0 else _numeric_result(_number(values[0]) / divisor)
|
||||
if expression.op == "coalesce":
|
||||
return next((item for item in values if item is not None), None)
|
||||
if expression.op == "case":
|
||||
if len(values) < 3:
|
||||
raise ReportingQueryError(
|
||||
"Case expressions require condition, value, and default."
|
||||
)
|
||||
pairs = values[:-1]
|
||||
for index in range(0, len(pairs) - 1, 2):
|
||||
if bool(pairs[index]):
|
||||
return pairs[index + 1]
|
||||
return values[-1]
|
||||
if expression.op in {"eq", "ne", "gt", "gte", "lt", "lte"}:
|
||||
_arity(values, 2, expression.op)
|
||||
return _compare(values[0], values[1], expression.op)
|
||||
if expression.op == "and":
|
||||
return all(bool(item) for item in values)
|
||||
if expression.op == "or":
|
||||
return any(bool(item) for item in values)
|
||||
if expression.op == "not":
|
||||
_arity(values, 1, expression.op)
|
||||
return not bool(values[0])
|
||||
raise ReportingQueryError(f"Unsupported expression operator: {expression.op!r}.")
|
||||
|
||||
|
||||
def _pivot_rows(
|
||||
rows: Sequence[Mapping[str, object]],
|
||||
*,
|
||||
row_dimensions: Sequence[str],
|
||||
column_dimensions: Sequence[str],
|
||||
measures: Sequence[str],
|
||||
include_totals: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
grouped: dict[tuple[object, ...], dict[str, Any]] = {}
|
||||
totals: dict[tuple[object, ...], dict[str, Decimal]] = defaultdict(
|
||||
lambda: defaultdict(Decimal)
|
||||
)
|
||||
for source in rows:
|
||||
row_key = tuple(source.get(item) for item in row_dimensions)
|
||||
target = grouped.setdefault(
|
||||
row_key,
|
||||
{
|
||||
item: _json_value(value)
|
||||
for item, value in zip(row_dimensions, row_key, strict=True)
|
||||
},
|
||||
)
|
||||
column_key = (
|
||||
" / ".join(
|
||||
str(source.get(item) if source.get(item) is not None else "(blank)")
|
||||
for item in column_dimensions
|
||||
)
|
||||
or "value"
|
||||
)
|
||||
for measure in measures:
|
||||
field_name = f"{column_key}.{measure}"
|
||||
value = source.get(measure)
|
||||
target[field_name] = _json_value(value)
|
||||
if include_totals and value is not None:
|
||||
try:
|
||||
totals[row_key][measure] += _number(value)
|
||||
except ReportingQueryError:
|
||||
pass
|
||||
if include_totals:
|
||||
for row_key, target in grouped.items():
|
||||
for measure in measures:
|
||||
if measure in totals[row_key]:
|
||||
target[f"total.{measure}"] = _numeric_result(
|
||||
totals[row_key][measure]
|
||||
)
|
||||
return list(grouped.values())
|
||||
|
||||
|
||||
def _matches_filter(value: object, clause: FilterClause) -> bool:
|
||||
expected = clause.value
|
||||
if clause.operator == "is_null":
|
||||
return value is None
|
||||
if clause.operator == "not_null":
|
||||
return value is not None
|
||||
if clause.operator == "eq":
|
||||
return value == expected
|
||||
if clause.operator == "ne":
|
||||
return value != expected
|
||||
if clause.operator in {"in", "not_in"}:
|
||||
if not isinstance(expected, (list, tuple, set, frozenset)):
|
||||
raise ReportingQueryError("Set filters require a list value.")
|
||||
result = value in expected
|
||||
return result if clause.operator == "in" else not result
|
||||
if clause.operator == "contains":
|
||||
return str(expected or "").casefold() in str(value or "").casefold()
|
||||
if clause.operator == "starts_with":
|
||||
return str(value or "").casefold().startswith(str(expected or "").casefold())
|
||||
if clause.operator == "between":
|
||||
if not isinstance(expected, (list, tuple)) or len(expected) != 2:
|
||||
raise ReportingQueryError("Between filters require two values.")
|
||||
return value is not None and expected[0] <= value <= expected[1]
|
||||
if clause.operator in {"gt", "gte", "lt", "lte"}:
|
||||
if value is None or expected is None:
|
||||
return False
|
||||
return bool(_compare(value, expected, clause.operator))
|
||||
raise ReportingQueryError(f"Unsupported filter operator: {clause.operator!r}.")
|
||||
|
||||
|
||||
def _sort_rows(
|
||||
rows: Sequence[dict[str, Any]],
|
||||
query: ReportQuery,
|
||||
) -> list[dict[str, Any]]:
|
||||
result = list(rows)
|
||||
for clause in reversed(query.sort):
|
||||
result.sort(
|
||||
key=lambda item: _sort_key(item.get(clause.key)),
|
||||
reverse=clause.direction == "desc",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def infer_query_schema(rows: Sequence[Mapping[str, object]]) -> tuple[dict[str, Any], ...]:
|
||||
names = tuple(dict.fromkeys(str(key) for row in rows for key in row))
|
||||
return tuple(
|
||||
{
|
||||
"name": name,
|
||||
"type": _value_type(
|
||||
next((row.get(name) for row in rows if row.get(name) is not None), None)
|
||||
),
|
||||
"nullable": any(row.get(name) is None for row in rows),
|
||||
}
|
||||
for name in names
|
||||
)
|
||||
|
||||
|
||||
def _value_type(value: object) -> str:
|
||||
if value is None:
|
||||
return "string"
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if isinstance(value, int):
|
||||
return "integer"
|
||||
if isinstance(value, (float, Decimal)):
|
||||
return "number"
|
||||
if isinstance(value, datetime):
|
||||
return "datetime"
|
||||
if isinstance(value, date):
|
||||
return "date"
|
||||
if isinstance(value, (dict, list, tuple)):
|
||||
return "json"
|
||||
return "string"
|
||||
|
||||
|
||||
def _require_known(
|
||||
keys: Sequence[str],
|
||||
available: Mapping[str, object],
|
||||
label: str,
|
||||
) -> None:
|
||||
unknown = set(keys) - set(available)
|
||||
if unknown:
|
||||
raise ReportingQueryError(
|
||||
f"Report query references unknown {label}: " + ", ".join(sorted(unknown))
|
||||
)
|
||||
|
||||
|
||||
def _compare(left: object, right: object, operator: str) -> bool:
|
||||
if operator == "eq":
|
||||
return left == right
|
||||
if operator == "ne":
|
||||
return left != right
|
||||
try:
|
||||
if operator == "gt":
|
||||
return left > right # type: ignore[operator]
|
||||
if operator == "gte":
|
||||
return left >= right # type: ignore[operator]
|
||||
if operator == "lt":
|
||||
return left < right # type: ignore[operator]
|
||||
if operator == "lte":
|
||||
return left <= right # type: ignore[operator]
|
||||
except TypeError as exc:
|
||||
raise ReportingQueryError("Report comparison values are incompatible.") from exc
|
||||
raise ReportingQueryError(f"Unsupported comparison operator: {operator!r}.")
|
||||
|
||||
|
||||
def _arity(values: Sequence[object], count: int, operator: str) -> None:
|
||||
if len(values) != count:
|
||||
raise ReportingQueryError(
|
||||
f"Expression {operator} requires exactly {count} arguments."
|
||||
)
|
||||
|
||||
|
||||
def _number(value: object) -> Decimal:
|
||||
if value is None or isinstance(value, bool):
|
||||
raise ReportingQueryError("A numeric report expression received a non-number.")
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except Exception as exc:
|
||||
raise ReportingQueryError("A numeric report expression is invalid.") from exc
|
||||
|
||||
|
||||
def _numeric_result(value: Decimal) -> int | float:
|
||||
if value == value.to_integral_value():
|
||||
return int(value)
|
||||
return float(value)
|
||||
|
||||
|
||||
def _sort_key(value: object) -> tuple[bool, str, str]:
|
||||
return value is None, type(value).__name__, str(value).casefold()
|
||||
|
||||
|
||||
def _hashable(value: object) -> object:
|
||||
if isinstance(value, Mapping):
|
||||
return tuple(sorted((str(key), _hashable(item)) for key, item in value.items()))
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(_hashable(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def _json_value(value: object) -> Any:
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return _numeric_result(value)
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"QUERY_ENGINE_VERSION",
|
||||
"DefaultChartRenderer",
|
||||
"QueryResult",
|
||||
"ReportingQueryError",
|
||||
"execute_semantic_query",
|
||||
"infer_query_schema",
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from govoplan_reporting.backend.definitions import (
|
||||
create_definition,
|
||||
get_definition,
|
||||
list_definitions,
|
||||
update_definition,
|
||||
)
|
||||
|
||||
|
||||
class SqlReportingRegistry:
|
||||
def get_definition(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
definition_kind: str,
|
||||
definition_id: str,
|
||||
revision: int | None = None,
|
||||
):
|
||||
return get_definition(
|
||||
session, # type: ignore[arg-type]
|
||||
principal,
|
||||
definition_kind=definition_kind,
|
||||
definition_id=definition_id,
|
||||
revision=revision,
|
||||
)
|
||||
|
||||
def list_definitions(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
definition_kinds: Sequence[str] | None = None,
|
||||
limit: int = 100,
|
||||
):
|
||||
records, _total = list_definitions(
|
||||
session, # type: ignore[arg-type]
|
||||
principal,
|
||||
definition_kinds=definition_kinds,
|
||||
limit=limit,
|
||||
)
|
||||
return records
|
||||
|
||||
def create_definition(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
**values,
|
||||
):
|
||||
return create_definition(session, principal, **values) # type: ignore[arg-type]
|
||||
|
||||
def update_definition(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
definition_kind: str,
|
||||
definition_id: str,
|
||||
expected_revision: int,
|
||||
recorded_at,
|
||||
change_reason: str,
|
||||
idempotency_key: str,
|
||||
changes: Mapping[str, object],
|
||||
):
|
||||
return update_definition(
|
||||
session, # type: ignore[arg-type]
|
||||
principal,
|
||||
definition_kind=definition_kind,
|
||||
definition_id=definition_id,
|
||||
expected_revision=expected_revision,
|
||||
recorded_at=recorded_at,
|
||||
change_reason=change_reason,
|
||||
idempotency_key=idempotency_key,
|
||||
changes=changes,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SqlReportingRegistry"]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Retention minimization for governed provider-report results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_reporting.backend.db.models import ReportingProviderExecution
|
||||
|
||||
|
||||
class ReportingRetentionService:
|
||||
def apply_retention(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
dry_run: bool,
|
||||
now: datetime,
|
||||
limit: int = 500,
|
||||
) -> dict[str, int]:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Reporting retention requires a SQLAlchemy Session")
|
||||
observed_at = _aware(now)
|
||||
rows = (
|
||||
session.query(ReportingProviderExecution)
|
||||
.filter(
|
||||
ReportingProviderExecution.expires_at.is_not(None),
|
||||
ReportingProviderExecution.expires_at <= observed_at,
|
||||
ReportingProviderExecution.retention_redacted_at.is_(None),
|
||||
)
|
||||
.order_by(
|
||||
ReportingProviderExecution.expires_at.asc(),
|
||||
ReportingProviderExecution.id.asc(),
|
||||
)
|
||||
.limit(max(1, min(int(limit), 5_000)))
|
||||
.all()
|
||||
)
|
||||
counts = {
|
||||
"eligible": len(rows),
|
||||
"redacted": 0,
|
||||
"remaining_in_batch": 0,
|
||||
}
|
||||
if dry_run:
|
||||
return counts
|
||||
for row in rows:
|
||||
# Keep immutable request/output hashes and provenance as audit
|
||||
# evidence while removing the retained report detail itself.
|
||||
row.result_payload = {}
|
||||
row.retention_redacted_at = observed_at
|
||||
counts["redacted"] += 1
|
||||
session.flush()
|
||||
counts["remaining_in_batch"] = (
|
||||
session.query(ReportingProviderExecution.id)
|
||||
.filter(
|
||||
ReportingProviderExecution.expires_at.is_not(None),
|
||||
ReportingProviderExecution.expires_at <= observed_at,
|
||||
ReportingProviderExecution.retention_redacted_at.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
.count()
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
__all__ = ["ReportingRetentionService"]
|
||||
@@ -0,0 +1,769 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.dataflows import dataflow_dataset_output
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_reporting.backend.definitions import (
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
ReportingDefinitionError,
|
||||
create_definition,
|
||||
definition_history,
|
||||
get_definition,
|
||||
list_definitions,
|
||||
update_definition,
|
||||
)
|
||||
from govoplan_reporting.backend.drilldown import (
|
||||
ReportingDrillError,
|
||||
create_drill_context,
|
||||
resolve_drill_context,
|
||||
)
|
||||
from govoplan_reporting.backend.execution import (
|
||||
QUALITY_SCOPE,
|
||||
RUN_SCOPE,
|
||||
ReportingExecutionError,
|
||||
ReportingExecutionFailure,
|
||||
execute_report,
|
||||
get_execution,
|
||||
list_executions,
|
||||
run_quality_plan,
|
||||
)
|
||||
from govoplan_reporting.backend.operations import (
|
||||
IMPORT_SCOPE,
|
||||
PUBLISH_SCOPE,
|
||||
SCHEDULE_SCOPE,
|
||||
ReportingOperationError,
|
||||
assess_import,
|
||||
delete_saved_view,
|
||||
dispatch_due_schedules,
|
||||
export_execution,
|
||||
list_import_assessments,
|
||||
list_publications,
|
||||
list_saved_views,
|
||||
list_schedules,
|
||||
publish_execution,
|
||||
upsert_saved_view,
|
||||
upsert_schedule,
|
||||
)
|
||||
from govoplan_reporting.backend.provider_reports import (
|
||||
ProviderReportError,
|
||||
execute_provider_report,
|
||||
export_provider_execution,
|
||||
get_provider_execution,
|
||||
list_provider_exports,
|
||||
list_provider_reports,
|
||||
provider_parameter_options,
|
||||
)
|
||||
from govoplan_reporting.backend.publication_targets import publication_target_catalog
|
||||
from govoplan_reporting.backend.query_engine import ReportingQueryError
|
||||
from govoplan_reporting.backend.schemas import (
|
||||
DefinitionUpdateRequest,
|
||||
DefinitionWriteRequest,
|
||||
DrillContextCreateRequest,
|
||||
ImportAssessmentRequest,
|
||||
PublicationRequest,
|
||||
ProviderReportExecutionRequest,
|
||||
ProviderReportExportRequest,
|
||||
QualityRunRequest,
|
||||
ReportExecutionRequest,
|
||||
SavedViewWriteRequest,
|
||||
ScheduleWriteRequest,
|
||||
)
|
||||
|
||||
|
||||
def create_router(registry: object | None) -> APIRouter:
|
||||
router = APIRouter(prefix="/reporting", tags=["reporting"])
|
||||
|
||||
@router.get("/provider-reports")
|
||||
def api_list_provider_reports(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
return list_provider_reports(session, principal, registry=registry)
|
||||
except (ProviderReportError, TypeError, ValueError) as exc:
|
||||
raise _error(exc) from exc
|
||||
|
||||
@router.get(
|
||||
"/provider-reports/{provider_id}/{report_id}/parameters/{parameter_key}/options"
|
||||
)
|
||||
def api_provider_parameter_options(
|
||||
provider_id: str,
|
||||
report_id: str,
|
||||
parameter_key: str,
|
||||
query: str = "",
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
options = provider_parameter_options(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
provider_id=provider_id,
|
||||
report_id=report_id,
|
||||
parameter_key=parameter_key,
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
except (ProviderReportError, PermissionError, LookupError) as exc:
|
||||
raise _error(exc) from exc
|
||||
return {"options": [item.to_dict() for item in options]}
|
||||
|
||||
@router.post("/provider-reports/{provider_id}/{report_id}/executions")
|
||||
def api_execute_provider_report(
|
||||
provider_id: str,
|
||||
report_id: str,
|
||||
payload: ProviderReportExecutionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
result = execute_provider_report(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
provider_id=provider_id,
|
||||
report_id=report_id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
session.commit()
|
||||
except (ProviderReportError, PermissionError, LookupError, ValueError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/provider-executions/{execution_id}")
|
||||
def api_get_provider_execution(
|
||||
execution_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
result = get_provider_execution(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
execution_id=execution_id,
|
||||
)
|
||||
except (ProviderReportError, PermissionError, LookupError) as exc:
|
||||
raise _error(exc) from exc
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Provider report execution not found"
|
||||
)
|
||||
return result
|
||||
|
||||
@router.get("/provider-executions/{execution_id}/exports")
|
||||
def api_list_provider_exports(
|
||||
execution_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
return {
|
||||
"exports": list(
|
||||
list_provider_exports(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
execution_id=execution_id,
|
||||
)
|
||||
)
|
||||
}
|
||||
except (ProviderReportError, PermissionError, LookupError) as exc:
|
||||
raise _error(exc) from exc
|
||||
|
||||
@router.post("/provider-executions/{execution_id}/exports")
|
||||
def api_export_provider_execution(
|
||||
execution_id: str,
|
||||
payload: ProviderReportExportRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> Response:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
content, media_type, filename = export_provider_execution(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
execution_id=execution_id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
session.commit()
|
||||
except (ProviderReportError, PermissionError, LookupError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
@router.get("/definitions")
|
||||
def api_list_definitions(
|
||||
definition_kind: list[str] | None = Query(default=None),
|
||||
definition_status: list[str] | None = Query(default=None, alias="status"),
|
||||
query: str = "",
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
records, total = list_definitions(
|
||||
session,
|
||||
principal,
|
||||
definition_kinds=definition_kind,
|
||||
statuses=definition_status,
|
||||
query=query,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
except ReportingDefinitionError as exc:
|
||||
raise _error(exc) from exc
|
||||
return {
|
||||
"definitions": [item.to_dict() for item in records],
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
@router.post(
|
||||
"/definitions",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_create_definition(
|
||||
payload: DefinitionWriteRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
try:
|
||||
record = create_definition(
|
||||
session,
|
||||
principal,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
session.commit()
|
||||
except (ReportingDefinitionError, ValueError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return record.to_dict()
|
||||
|
||||
@router.get("/definitions/{definition_kind}/{definition_id}")
|
||||
def api_get_definition(
|
||||
definition_kind: str,
|
||||
definition_id: str,
|
||||
revision: int | None = Query(default=None, ge=1),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind=definition_kind,
|
||||
definition_id=definition_id,
|
||||
revision=revision,
|
||||
)
|
||||
except ReportingDefinitionError as exc:
|
||||
raise _error(exc) from exc
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Reporting definition not found"
|
||||
)
|
||||
return record.to_dict()
|
||||
|
||||
@router.patch("/definitions/{definition_kind}/{definition_id}")
|
||||
def api_update_definition(
|
||||
definition_kind: str,
|
||||
definition_id: str,
|
||||
payload: DefinitionUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
changes = payload.model_dump(
|
||||
exclude={
|
||||
"expected_revision",
|
||||
"recorded_at",
|
||||
"change_reason",
|
||||
"idempotency_key",
|
||||
},
|
||||
exclude_unset=True,
|
||||
)
|
||||
try:
|
||||
record = update_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind=definition_kind,
|
||||
definition_id=definition_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
changes=changes,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
ReportingDefinitionError,
|
||||
PermissionError,
|
||||
LookupError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return record.to_dict()
|
||||
|
||||
@router.get("/definitions/{definition_kind}/{definition_id}/history")
|
||||
def api_definition_history(
|
||||
definition_kind: str,
|
||||
definition_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
records = definition_history(
|
||||
session,
|
||||
principal,
|
||||
definition_kind=definition_kind,
|
||||
definition_id=definition_id,
|
||||
limit=limit,
|
||||
)
|
||||
if not records:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Reporting definition not found"
|
||||
)
|
||||
return {"revisions": [item.to_dict() for item in records]}
|
||||
|
||||
@router.get("/sources/dataflow")
|
||||
def api_dataflow_sources(
|
||||
query: str = "",
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
provider = dataflow_dataset_output(registry)
|
||||
if provider is None:
|
||||
return {
|
||||
"available": False,
|
||||
"reason": "The Dataflow dataset provider is not enabled.",
|
||||
"sources": [],
|
||||
}
|
||||
sources = provider.list_outputs(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"available": True,
|
||||
"reason": None,
|
||||
"sources": [
|
||||
{
|
||||
"pipeline_ref": item.pipeline_ref,
|
||||
"name": item.name,
|
||||
"description": item.description,
|
||||
"revision": item.revision,
|
||||
"definition_hash": item.definition_hash,
|
||||
"status": item.status,
|
||||
"updated_at": item.updated_at.isoformat()
|
||||
if item.updated_at
|
||||
else None,
|
||||
"parameters": dict(item.parameters),
|
||||
"provenance": dict(item.provenance),
|
||||
}
|
||||
for item in sources
|
||||
],
|
||||
}
|
||||
|
||||
@router.post("/reports/{report_id}/executions")
|
||||
def api_execute_report(
|
||||
report_id: str,
|
||||
payload: ReportExecutionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
result = execute_report(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
report_id=report_id,
|
||||
report_revision=payload.report_revision,
|
||||
parameters=payload.parameters,
|
||||
query=payload.query,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
session.commit()
|
||||
except ReportingExecutionFailure as exc:
|
||||
session.commit()
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"message": str(exc), "execution_id": exc.execution_id},
|
||||
) from exc
|
||||
except (
|
||||
ReportingExecutionError,
|
||||
ReportingQueryError,
|
||||
PermissionError,
|
||||
LookupError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/reports/{report_id}/executions")
|
||||
def api_list_executions(
|
||||
report_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
return {
|
||||
"executions": list(
|
||||
list_executions(
|
||||
session,
|
||||
principal,
|
||||
report_id=report_id,
|
||||
limit=limit,
|
||||
registry=registry,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@router.get("/executions/{execution_id}")
|
||||
def api_get_execution(
|
||||
execution_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
result = get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Reporting execution not found")
|
||||
return result
|
||||
|
||||
@router.post("/executions/{execution_id}/drill-contexts", status_code=201)
|
||||
def api_create_drill_context(
|
||||
execution_id: str,
|
||||
payload: DrillContextCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
result = create_drill_context(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
execution_id=execution_id,
|
||||
aggregate_row=payload.aggregate_row,
|
||||
limit=payload.limit,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
ReportingDrillError,
|
||||
ReportingExecutionError,
|
||||
PermissionError,
|
||||
LookupError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/drill-contexts/{token}")
|
||||
def api_resolve_drill_context(
|
||||
token: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
result = resolve_drill_context(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
token=token,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
ReportingDrillError,
|
||||
ReportingExecutionError,
|
||||
PermissionError,
|
||||
LookupError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/executions/{execution_id}/export")
|
||||
def api_export_execution(
|
||||
execution_id: str,
|
||||
format: str = Query(default="csv", pattern="^(csv|json)$"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> Response:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
content, media_type, filename = export_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
format=format,
|
||||
registry=registry,
|
||||
)
|
||||
except (ReportingOperationError, LookupError) as exc:
|
||||
raise _error(exc) from exc
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
@router.post("/executions/{execution_id}/publications")
|
||||
def api_publish_execution(
|
||||
execution_id: str,
|
||||
payload: PublicationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, PUBLISH_SCOPE)
|
||||
try:
|
||||
result = publish_execution(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
execution_id=execution_id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
session.commit()
|
||||
except (ReportingOperationError, PermissionError, LookupError) as exc:
|
||||
session.commit()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/publication-targets")
|
||||
def api_publication_targets(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, PUBLISH_SCOPE)
|
||||
return {"targets": list(publication_target_catalog(registry))}
|
||||
|
||||
@router.get("/publications")
|
||||
def api_list_publications(
|
||||
execution_id: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, PUBLISH_SCOPE)
|
||||
return {
|
||||
"publications": list(
|
||||
list_publications(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
limit=limit,
|
||||
registry=registry,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@router.get("/reports/{report_id}/saved-views")
|
||||
def api_list_saved_views(
|
||||
report_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
return {
|
||||
"views": list(list_saved_views(session, principal, report_id=report_id))
|
||||
}
|
||||
|
||||
@router.put("/reports/{report_id}/saved-views/{view_id}")
|
||||
def api_upsert_saved_view(
|
||||
report_id: str,
|
||||
view_id: str,
|
||||
payload: SavedViewWriteRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, READ_SCOPE)
|
||||
if view_id != payload.view_id:
|
||||
raise HTTPException(status_code=400, detail="Saved-view identifiers differ")
|
||||
try:
|
||||
result = upsert_saved_view(
|
||||
session,
|
||||
principal,
|
||||
report_id=report_id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
session.commit()
|
||||
except (ReportingOperationError, PermissionError, LookupError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.delete("/saved-views/{view_id}", status_code=204)
|
||||
def api_delete_saved_view(
|
||||
view_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> Response:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
deleted = delete_saved_view(session, principal, view_id=view_id)
|
||||
session.commit()
|
||||
except PermissionError as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Saved view not found")
|
||||
return Response(status_code=204)
|
||||
|
||||
@router.get("/schedules")
|
||||
def api_list_schedules(
|
||||
report_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, SCHEDULE_SCOPE)
|
||||
return {
|
||||
"schedules": list(list_schedules(session, principal, report_id=report_id))
|
||||
}
|
||||
|
||||
@router.put("/schedules/{schedule_id}")
|
||||
def api_upsert_schedule(
|
||||
schedule_id: str,
|
||||
payload: ScheduleWriteRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, SCHEDULE_SCOPE)
|
||||
if schedule_id != payload.schedule_id:
|
||||
raise HTTPException(status_code=400, detail="Schedule identifiers differ")
|
||||
try:
|
||||
result = upsert_schedule(session, principal, **payload.model_dump())
|
||||
session.commit()
|
||||
except (ReportingOperationError, PermissionError, LookupError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.post("/schedules/dispatch")
|
||||
def api_dispatch_schedules(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, SCHEDULE_SCOPE)
|
||||
result = dispatch_due_schedules(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
now=None,
|
||||
limit=limit,
|
||||
)
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
@router.post("/quality-plans/{quality_plan_id}/runs")
|
||||
def api_run_quality_plan(
|
||||
quality_plan_id: str,
|
||||
payload: QualityRunRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, QUALITY_SCOPE)
|
||||
try:
|
||||
result = run_quality_plan(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
quality_plan_id=quality_plan_id,
|
||||
quality_plan_revision=payload.quality_plan_revision,
|
||||
parameters=payload.parameters,
|
||||
)
|
||||
session.commit()
|
||||
except (ReportingExecutionError, PermissionError, LookupError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.post("/imports/assessments", status_code=201)
|
||||
def api_assess_import(
|
||||
payload: ImportAssessmentRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, IMPORT_SCOPE)
|
||||
try:
|
||||
result = assess_import(session, principal, **payload.model_dump())
|
||||
session.commit()
|
||||
except (ReportingOperationError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/imports/assessments")
|
||||
def api_list_import_assessments(
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, IMPORT_SCOPE)
|
||||
return {
|
||||
"assessments": list(
|
||||
list_import_assessments(session, principal, limit=limit)
|
||||
)
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _error(exc: Exception) -> HTTPException:
|
||||
message = str(exc)
|
||||
lowered = message.casefold()
|
||||
if isinstance(exc, LookupError):
|
||||
code = 404
|
||||
elif isinstance(exc, PermissionError):
|
||||
code = 403
|
||||
elif any(word in lowered for word in ("conflict", "already", "stale")):
|
||||
code = 409
|
||||
elif "unavailable" in lowered or "not enabled" in lowered:
|
||||
code = 503
|
||||
else:
|
||||
code = 400
|
||||
return HTTPException(status_code=code, detail=message)
|
||||
|
||||
|
||||
__all__ = ["create_router"]
|
||||
@@ -0,0 +1,576 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
DefinitionKind = Literal[
|
||||
"dataset",
|
||||
"semantic_model",
|
||||
"report",
|
||||
"quality_plan",
|
||||
]
|
||||
FieldType = Literal[
|
||||
"string",
|
||||
"integer",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
"datetime",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
class ReportingReference(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
owner_module: str = Field(min_length=1, max_length=100)
|
||||
resource_type: str = Field(min_length=1, max_length=100)
|
||||
resource_id: str = Field(min_length=1, max_length=255)
|
||||
revision: str | None = Field(default=None, max_length=255)
|
||||
relationship: str = Field(default="related", min_length=1, max_length=80)
|
||||
label: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class DatasetField(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
type: FieldType = "string"
|
||||
label: str | None = Field(default=None, max_length=500)
|
||||
nullable: bool = True
|
||||
description: str | None = Field(default=None, max_length=4_000)
|
||||
classification: Literal[
|
||||
"public",
|
||||
"internal",
|
||||
"confidential",
|
||||
"restricted",
|
||||
] = "internal"
|
||||
|
||||
|
||||
class FreshnessPolicy(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
max_age_seconds: int | None = Field(default=None, ge=1, le=31_536_000)
|
||||
stale_action: Literal["allow", "warn", "block"] = "warn"
|
||||
require_source_fingerprints: bool = True
|
||||
|
||||
|
||||
class DefinitionGovernance(BaseModel):
|
||||
"""Versioned scope and restrictive inheritance metadata for a definition."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
scope_type: Literal["system", "tenant", "group", "user"] = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=255)
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_run: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
source_scope: dict[str, Any] | None = None
|
||||
source_effective_limits: dict[str, bool] = Field(default_factory=dict)
|
||||
derivation_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_scope(self) -> "DefinitionGovernance":
|
||||
if self.scope_type == "system":
|
||||
if self.scope_id:
|
||||
raise ValueError("System Reporting definitions do not carry a scope ID.")
|
||||
elif self.scope_type in {"group", "user"} and not str(self.scope_id or "").strip():
|
||||
raise ValueError(
|
||||
f"{self.scope_type.capitalize()} Reporting definitions require a scope ID."
|
||||
)
|
||||
unknown = set(self.source_effective_limits) - {
|
||||
"inherit_to_lower_scopes",
|
||||
"allow_run",
|
||||
"allow_reuse",
|
||||
"allow_automation",
|
||||
}
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
"Unknown inherited Reporting limits: " + ", ".join(sorted(unknown))
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class DatasetDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
source_kind: Literal["dataflow", "read_model", "static"]
|
||||
source_ref: str = Field(min_length=1, max_length=500)
|
||||
source_revision: int | None = Field(default=None, ge=1)
|
||||
source_run_ref: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
definition_hash: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
source_parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
static_rows: list[dict[str, Any]] = Field(default_factory=list, max_length=2_000)
|
||||
expected_source_fingerprints: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
max_length=100,
|
||||
)
|
||||
fields: list[DatasetField] = Field(
|
||||
default_factory=list,
|
||||
max_length=500,
|
||||
validation_alias="schema",
|
||||
serialization_alias="schema",
|
||||
)
|
||||
freshness: FreshnessPolicy = Field(default_factory=FreshnessPolicy)
|
||||
row_policy_ref: str | None = Field(default=None, max_length=500)
|
||||
purpose: str = Field(min_length=1, max_length=4_000)
|
||||
privacy: dict[str, Any] = Field(default_factory=dict)
|
||||
retention: dict[str, Any] = Field(default_factory=dict)
|
||||
policy_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
institutional_references: list[ReportingReference] = Field(
|
||||
default_factory=list,
|
||||
max_length=200,
|
||||
)
|
||||
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_source_pin(self) -> "DatasetDefinition":
|
||||
if self.source_kind == "dataflow" and self.source_revision is None:
|
||||
raise ValueError("Dataflow datasets require a pinned source revision.")
|
||||
if self.source_run_ref is not None and self.source_kind != "dataflow":
|
||||
raise ValueError("Only Dataflow datasets can pin a source run.")
|
||||
if self.source_kind == "static" and not self.static_rows:
|
||||
raise ValueError("Static analytical datasets require static_rows.")
|
||||
names = [item.name for item in self.fields]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("Dataset schema field names must be unique.")
|
||||
return self
|
||||
|
||||
|
||||
class DimensionDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9._-]+$")
|
||||
field: str = Field(min_length=1, max_length=255)
|
||||
label: str = Field(min_length=1, max_length=500)
|
||||
type: FieldType = "string"
|
||||
format: str | None = Field(default=None, max_length=255)
|
||||
default_sort: Literal["asc", "desc"] | None = None
|
||||
|
||||
|
||||
class HierarchyLevel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
dimension: str = Field(min_length=1, max_length=120)
|
||||
label: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class HierarchyDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9._-]+$")
|
||||
label: str = Field(min_length=1, max_length=500)
|
||||
levels: list[HierarchyLevel] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class TypedExpression(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
op: Literal[
|
||||
"literal",
|
||||
"field",
|
||||
"measure",
|
||||
"add",
|
||||
"subtract",
|
||||
"multiply",
|
||||
"divide",
|
||||
"coalesce",
|
||||
"case",
|
||||
"eq",
|
||||
"ne",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"and",
|
||||
"or",
|
||||
"not",
|
||||
]
|
||||
value: Any = None
|
||||
ref: str | None = Field(default=None, max_length=255)
|
||||
args: list["TypedExpression"] = Field(default_factory=list, max_length=50)
|
||||
result_type: FieldType | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_shape(self) -> "TypedExpression":
|
||||
if self.op in {"field", "measure"} and not self.ref:
|
||||
raise ValueError(f"Expression {self.op} requires ref.")
|
||||
if self.op == "literal" and self.args:
|
||||
raise ValueError("Literal expressions cannot have arguments.")
|
||||
if self.op not in {"literal", "field", "measure"} and not self.args:
|
||||
raise ValueError(f"Expression {self.op} requires arguments.")
|
||||
return self
|
||||
|
||||
|
||||
class MeasureDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9._-]+$")
|
||||
label: str = Field(min_length=1, max_length=500)
|
||||
aggregation: Literal[
|
||||
"sum",
|
||||
"count",
|
||||
"count_distinct",
|
||||
"average",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"calculated",
|
||||
]
|
||||
field: str | None = Field(default=None, max_length=255)
|
||||
expression: TypedExpression | None = None
|
||||
format: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_measure(self) -> "MeasureDefinition":
|
||||
if self.aggregation == "calculated" and self.expression is None:
|
||||
raise ValueError("Calculated measures require an expression.")
|
||||
if self.aggregation not in {"count", "calculated"} and not self.field:
|
||||
raise ValueError(f"{self.aggregation} measures require a field.")
|
||||
return self
|
||||
|
||||
|
||||
class SemanticModelDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
dataset_id: str = Field(min_length=1, max_length=255)
|
||||
dataset_revision: int = Field(ge=1)
|
||||
dimensions: list[DimensionDefinition] = Field(default_factory=list, max_length=300)
|
||||
hierarchies: list[HierarchyDefinition] = Field(default_factory=list, max_length=100)
|
||||
measures: list[MeasureDefinition] = Field(default_factory=list, max_length=300)
|
||||
default_dimensions: list[str] = Field(default_factory=list, max_length=50)
|
||||
default_measures: list[str] = Field(default_factory=list, max_length=50)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_semantics(self) -> "SemanticModelDefinition":
|
||||
dimensions = {item.key for item in self.dimensions}
|
||||
measures = {item.key for item in self.measures}
|
||||
if len(dimensions) != len(self.dimensions):
|
||||
raise ValueError("Semantic dimensions require unique keys.")
|
||||
if len(measures) != len(self.measures):
|
||||
raise ValueError("Semantic measures require unique keys.")
|
||||
unknown_dimensions = set(self.default_dimensions) - dimensions
|
||||
unknown_measures = set(self.default_measures) - measures
|
||||
hierarchy_dimensions = {
|
||||
level.dimension
|
||||
for hierarchy in self.hierarchies
|
||||
for level in hierarchy.levels
|
||||
}
|
||||
unknown_dimensions |= hierarchy_dimensions - dimensions
|
||||
if unknown_dimensions or unknown_measures:
|
||||
raise ValueError(
|
||||
"Semantic model references unknown dimensions or measures: "
|
||||
+ ", ".join(sorted(unknown_dimensions | unknown_measures))
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ParameterDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9._-]+$")
|
||||
label: str = Field(min_length=1, max_length=500)
|
||||
type: FieldType = "string"
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
allowed_values: list[Any] = Field(default_factory=list, max_length=500)
|
||||
|
||||
|
||||
class FilterClause(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
dimension: str = Field(min_length=1, max_length=120)
|
||||
operator: Literal[
|
||||
"eq",
|
||||
"ne",
|
||||
"in",
|
||||
"not_in",
|
||||
"contains",
|
||||
"starts_with",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"between",
|
||||
"is_null",
|
||||
"not_null",
|
||||
] = "eq"
|
||||
value: Any = None
|
||||
|
||||
|
||||
class SortClause(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str = Field(min_length=1, max_length=120)
|
||||
direction: Literal["asc", "desc"] = "asc"
|
||||
|
||||
|
||||
class PivotDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
rows: list[str] = Field(default_factory=list, max_length=10)
|
||||
columns: list[str] = Field(default_factory=list, max_length=5)
|
||||
measures: list[str] = Field(default_factory=list, max_length=20)
|
||||
include_totals: bool = True
|
||||
|
||||
|
||||
class ReportQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: Literal["summary", "detail", "pivot"] = "summary"
|
||||
dimensions: list[str] = Field(default_factory=list, max_length=50)
|
||||
measures: list[str] = Field(default_factory=list, max_length=50)
|
||||
filters: list[FilterClause] = Field(default_factory=list, max_length=100)
|
||||
sort: list[SortClause] = Field(default_factory=list, max_length=20)
|
||||
pivot: PivotDefinition | None = None
|
||||
offset: int = Field(default=0, ge=0)
|
||||
limit: int = Field(default=200, ge=1, le=2_000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_pivot(self) -> "ReportQuery":
|
||||
if self.mode == "pivot" and self.pivot is None:
|
||||
raise ValueError("Pivot mode requires a pivot definition.")
|
||||
return self
|
||||
|
||||
|
||||
class VisualizationDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: Literal[
|
||||
"table",
|
||||
"pivot",
|
||||
"bar",
|
||||
"line",
|
||||
"area",
|
||||
"column",
|
||||
"pie",
|
||||
"donut",
|
||||
"metric",
|
||||
] = "table"
|
||||
category_dimension: str | None = Field(default=None, max_length=120)
|
||||
series_dimension: str | None = Field(default=None, max_length=120)
|
||||
measures: list[str] = Field(default_factory=list, max_length=20)
|
||||
options: dict[str, Any] = Field(default_factory=dict)
|
||||
tabular_fallback: bool = True
|
||||
|
||||
|
||||
class ReportDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
semantic_model_id: str = Field(min_length=1, max_length=255)
|
||||
semantic_model_revision: int = Field(ge=1)
|
||||
parameters: list[ParameterDefinition] = Field(default_factory=list, max_length=100)
|
||||
default_query: ReportQuery = Field(default_factory=ReportQuery)
|
||||
visualization: VisualizationDefinition = Field(
|
||||
default_factory=VisualizationDefinition
|
||||
)
|
||||
layout: dict[str, Any] = Field(default_factory=dict)
|
||||
access_policy: dict[str, Any] = Field(default_factory=dict)
|
||||
publication_defaults: dict[str, Any] = Field(default_factory=dict)
|
||||
institutional_references: list[ReportingReference] = Field(
|
||||
default_factory=list,
|
||||
max_length=200,
|
||||
)
|
||||
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||
|
||||
|
||||
class QualityAssertion(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9._-]+$")
|
||||
kind: Literal[
|
||||
"not_null",
|
||||
"unique",
|
||||
"range",
|
||||
"accepted_values",
|
||||
"row_count",
|
||||
"comparison",
|
||||
]
|
||||
field: str | None = Field(default=None, max_length=255)
|
||||
severity: Literal["info", "warning", "error", "blocker"] = "error"
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class QualityPlanDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
dataset_id: str = Field(min_length=1, max_length=255)
|
||||
dataset_revision: int = Field(ge=1)
|
||||
assertions: list[QualityAssertion] = Field(min_length=1, max_length=200)
|
||||
block_report_execution: bool = True
|
||||
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||
|
||||
|
||||
DEFINITION_PAYLOAD_TYPES = {
|
||||
"dataset": DatasetDefinition,
|
||||
"semantic_model": SemanticModelDefinition,
|
||||
"report": ReportDefinition,
|
||||
"quality_plan": QualityPlanDefinition,
|
||||
}
|
||||
|
||||
|
||||
def validate_definition_payload(
|
||||
kind: str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
model = DEFINITION_PAYLOAD_TYPES.get(kind)
|
||||
if model is None:
|
||||
raise ValueError(f"Unsupported Reporting definition kind: {kind!r}.")
|
||||
return model.model_validate(payload).model_dump(mode="json", by_alias=True)
|
||||
|
||||
|
||||
class DefinitionWriteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
definition_kind: DefinitionKind
|
||||
definition_id: str = Field(min_length=1, max_length=255)
|
||||
definition_key: str = Field(
|
||||
min_length=1,
|
||||
max_length=120,
|
||||
pattern=r"^[a-z0-9._-]+$",
|
||||
)
|
||||
name: str = Field(min_length=1, max_length=500)
|
||||
description: str | None = Field(default=None, max_length=100_000)
|
||||
status: Literal["draft", "active", "retired"] = "draft"
|
||||
visibility: Literal["tenant", "restricted"] = "tenant"
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
payload: dict[str, Any]
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class DefinitionUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
description: str | None = Field(default=None, max_length=100_000)
|
||||
status: Literal["draft", "active", "retired"] | None = None
|
||||
visibility: Literal["tenant", "restricted"] | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ReportExecutionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
report_revision: int | None = Field(default=None, ge=1)
|
||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
query: ReportQuery | None = None
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class ProviderReportExecutionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
purpose: str = Field(min_length=1, max_length=1_000)
|
||||
audience_scope: dict[str, Any] = Field(min_length=1, max_length=50)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class ProviderReportExportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
format: Literal["json", "csv"] = "json"
|
||||
purpose: str = Field(min_length=1, max_length=1_000)
|
||||
audience_scope: dict[str, Any] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class SavedViewWriteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
view_id: str = Field(min_length=1, max_length=36)
|
||||
report_revision: int = Field(ge=1)
|
||||
name: str = Field(min_length=1, max_length=500)
|
||||
state: dict[str, Any]
|
||||
shared: bool = False
|
||||
access: dict[str, Any] = Field(default_factory=dict)
|
||||
expected_revision: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class ScheduleWriteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
schedule_id: str = Field(min_length=1, max_length=36)
|
||||
report_id: str = Field(min_length=1, max_length=255)
|
||||
report_revision: int = Field(ge=1)
|
||||
name: str = Field(min_length=1, max_length=500)
|
||||
trigger_kind: Literal["scheduled", "interval"]
|
||||
trigger_config: dict[str, Any]
|
||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
query: ReportQuery = Field(default_factory=ReportQuery)
|
||||
publication_target: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
next_run_at: datetime | None = None
|
||||
expected_revision: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class PublicationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
target_capability: str = Field(min_length=1, max_length=255)
|
||||
target_ref: str | None = Field(default=None, max_length=1_000)
|
||||
format: Literal["json", "csv", "xlsx", "html", "pdf"] = "csv"
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
options: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DrillContextCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
aggregate_row: dict[str, Any] = Field(max_length=500)
|
||||
limit: int = Field(default=200, ge=1, le=500)
|
||||
|
||||
|
||||
class QualityRunRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
quality_plan_revision: int | None = Field(default=None, ge=1)
|
||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ImportAssessmentRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
source_system: str = Field(min_length=1, max_length=255)
|
||||
source_id: str = Field(min_length=1, max_length=500)
|
||||
metadata: dict[str, Any]
|
||||
accepted_approximations: list[str] = Field(default_factory=list, max_length=500)
|
||||
|
||||
|
||||
TypedExpression.model_rebuild()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DatasetDefinition",
|
||||
"DefinitionGovernance",
|
||||
"DefinitionUpdateRequest",
|
||||
"DefinitionWriteRequest",
|
||||
"DimensionDefinition",
|
||||
"DrillContextCreateRequest",
|
||||
"FilterClause",
|
||||
"ImportAssessmentRequest",
|
||||
"MeasureDefinition",
|
||||
"PublicationRequest",
|
||||
"ProviderReportExecutionRequest",
|
||||
"ProviderReportExportRequest",
|
||||
"QualityPlanDefinition",
|
||||
"QualityRunRequest",
|
||||
"ReportDefinition",
|
||||
"ReportExecutionRequest",
|
||||
"ReportQuery",
|
||||
"SavedViewWriteRequest",
|
||||
"ScheduleWriteRequest",
|
||||
"SemanticModelDefinition",
|
||||
"TypedExpression",
|
||||
"VisualizationDefinition",
|
||||
"validate_definition_payload",
|
||||
]
|
||||
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillPage,
|
||||
SearchBackfillRequest,
|
||||
SearchDocument,
|
||||
SearchResourceType,
|
||||
)
|
||||
from govoplan_reporting.backend.db.models import (
|
||||
ReportingDefinitionGrant,
|
||||
ReportingDefinitionIdentity,
|
||||
ReportingDefinitionRevision,
|
||||
)
|
||||
from govoplan_reporting.backend.definitions import can_read_definition
|
||||
|
||||
|
||||
PROVIDER_ID = "reporting.reports"
|
||||
RESOURCE_TYPE = "report"
|
||||
|
||||
|
||||
class ReportingSearchSource:
|
||||
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||
return (
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="reporting",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
label="Reports",
|
||||
requires_authorization_recheck=True,
|
||||
),
|
||||
)
|
||||
|
||||
def backfill(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SearchBackfillRequest,
|
||||
) -> SearchBackfillPage:
|
||||
if request.provider_id != PROVIDER_ID or request.resource_type != RESOURCE_TYPE:
|
||||
raise ValueError("Unsupported Reporting search source.")
|
||||
db = _session(session)
|
||||
query = db.query(ReportingDefinitionRevision).filter(
|
||||
ReportingDefinitionRevision.tenant_id == request.tenant_id,
|
||||
ReportingDefinitionRevision.definition_kind == "report",
|
||||
ReportingDefinitionRevision.status != "retired",
|
||||
ReportingDefinitionRevision.superseded_at.is_(None),
|
||||
)
|
||||
if request.cursor:
|
||||
query = query.filter(ReportingDefinitionRevision.id > request.cursor)
|
||||
rows = (
|
||||
query.order_by(ReportingDefinitionRevision.id.asc())
|
||||
.limit(request.limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows) > request.limit
|
||||
selected = rows[: request.limit]
|
||||
tokens = _acl_tokens(db, selected)
|
||||
high_watermark = (
|
||||
db.query(func.max(ReportingDefinitionRevision.updated_at))
|
||||
.filter(
|
||||
ReportingDefinitionRevision.tenant_id == request.tenant_id,
|
||||
ReportingDefinitionRevision.definition_kind == "report",
|
||||
ReportingDefinitionRevision.superseded_at.is_(None),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
return SearchBackfillPage(
|
||||
documents=tuple(
|
||||
_document(row, tokens[row.definition_id]) for row in selected
|
||||
),
|
||||
next_cursor=selected[-1].id if has_more and selected else None,
|
||||
complete=not has_more,
|
||||
high_watermark=(
|
||||
high_watermark.isoformat() if high_watermark is not None else None
|
||||
),
|
||||
)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
requests: Sequence[SearchAuthorizationRequest],
|
||||
) -> Mapping[str, bool]:
|
||||
db = _session(session)
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
||||
decisions = {request.reference.key: False for request in requests}
|
||||
for request in requests:
|
||||
reference = request.reference
|
||||
if (
|
||||
reference.tenant_id != tenant_id
|
||||
or reference.module_id != "reporting"
|
||||
or reference.resource_type != RESOURCE_TYPE
|
||||
):
|
||||
continue
|
||||
decisions[reference.key] = can_read_definition(
|
||||
db,
|
||||
principal,
|
||||
definition_kind="report",
|
||||
definition_id=reference.resource_id,
|
||||
)
|
||||
return decisions
|
||||
|
||||
|
||||
def create_reporting_search_source(
|
||||
context: ModuleContext,
|
||||
) -> ReportingSearchSource:
|
||||
del context
|
||||
return ReportingSearchSource()
|
||||
|
||||
|
||||
def _document(
|
||||
row: ReportingDefinitionRevision,
|
||||
tokens: tuple[str, ...],
|
||||
) -> SearchDocument:
|
||||
restricted_tokens = tuple(
|
||||
dict.fromkeys((*tokens, "scope:reporting:definition:admin"))
|
||||
)
|
||||
return SearchDocument(
|
||||
tenant_id=row.tenant_id,
|
||||
module_id="reporting",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=row.definition_id,
|
||||
title=row.name,
|
||||
url=f"/reporting?reportId={quote(row.definition_id, safe='')}",
|
||||
summary=row.description,
|
||||
body=row.description,
|
||||
keywords=(row.definition_key, row.status),
|
||||
visibility=row.visibility,
|
||||
acl_tokens=restricted_tokens if row.visibility == "restricted" else (),
|
||||
source_revision=str(row.revision),
|
||||
source_updated_at=row.updated_at or row.recorded_at,
|
||||
metadata={
|
||||
"definition_key": row.definition_key,
|
||||
"status": row.status,
|
||||
"content_hash": row.content_hash,
|
||||
},
|
||||
requires_authorization_recheck=True,
|
||||
)
|
||||
|
||||
|
||||
def _acl_tokens(
|
||||
session: Session,
|
||||
rows: Sequence[ReportingDefinitionRevision],
|
||||
) -> Mapping[str, tuple[str, ...]]:
|
||||
result: dict[str, list[str]] = defaultdict(list)
|
||||
if not rows:
|
||||
return result
|
||||
identity_ids = {row.identity_id for row in rows}
|
||||
for identity in (
|
||||
session.query(ReportingDefinitionIdentity)
|
||||
.filter(ReportingDefinitionIdentity.id.in_(identity_ids))
|
||||
.all()
|
||||
):
|
||||
if identity.created_by:
|
||||
result[identity.definition_id].append(f"account:{identity.created_by}")
|
||||
definition_ids = {row.definition_id for row in rows}
|
||||
grants = (
|
||||
session.query(ReportingDefinitionGrant)
|
||||
.filter(
|
||||
ReportingDefinitionGrant.tenant_id == rows[0].tenant_id,
|
||||
ReportingDefinitionGrant.definition_kind == "report",
|
||||
ReportingDefinitionGrant.definition_id.in_(definition_ids),
|
||||
ReportingDefinitionGrant.active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for grant in grants:
|
||||
prefix = (
|
||||
"function"
|
||||
if grant.subject_kind == "function_assignment"
|
||||
else grant.subject_kind
|
||||
)
|
||||
result[grant.definition_id].append(f"{prefix}:{grant.subject_id}")
|
||||
return {
|
||||
definition_id: tuple(dict.fromkeys(values))
|
||||
for definition_id, values in result.items()
|
||||
}
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Reporting search requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROVIDER_ID",
|
||||
"RESOURCE_TYPE",
|
||||
"ReportingSearchSource",
|
||||
"create_reporting_search_source",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_reporting.backend.manifest import get_manifest
|
||||
|
||||
|
||||
class ReportingManifestTests(unittest.TestCase):
|
||||
def test_manifest_exposes_the_governed_reporting_vertical(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertEqual("reporting", manifest.id)
|
||||
self.assertEqual("vertical_slice", manifest.architecture.maturity)
|
||||
self.assertEqual("@govoplan/reporting-webui", manifest.frontend.package_name)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertEqual(7, len(manifest.provides_interfaces))
|
||||
self.assertEqual(1, len(manifest.search_sources))
|
||||
self.assertIn("dataflow", manifest.optional_dependencies)
|
||||
self.assertIn("policy", manifest.optional_dependencies)
|
||||
self.assertEqual("/reports", manifest.nav_items[0].path)
|
||||
self.assertEqual(
|
||||
{"/reports", "/reporting"},
|
||||
{route.path for route in manifest.frontend.routes},
|
||||
)
|
||||
self.assertIn(
|
||||
"report execution and publication evidence",
|
||||
manifest.architecture.owned_concepts,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from govoplan_core.db.migrations import migrate_database
|
||||
from govoplan_reporting.backend.manifest import get_manifest
|
||||
|
||||
|
||||
def test_fresh_migration_creates_provider_evidence_tables_and_current_head() -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-reporting-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'reporting.db'}"
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("reporting",),
|
||||
manifest_factories=(get_manifest,),
|
||||
)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
assert {
|
||||
"reporting_provider_executions",
|
||||
"reporting_drill_contexts",
|
||||
"reporting_provider_exports",
|
||||
}.issubset(tables)
|
||||
assert "b7c4e1a9d2f6" in set(
|
||||
MigrationContext.configure(connection).get_current_heads()
|
||||
)
|
||||
columns = {
|
||||
item["name"]
|
||||
for item in inspect(connection).get_columns(
|
||||
"reporting_provider_executions"
|
||||
)
|
||||
}
|
||||
assert "retention_redacted_at" in columns
|
||||
finally:
|
||||
engine.dispose()
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_campaign_starts_and_contributes_its_provider_without_reporting() -> None:
|
||||
script = """
|
||||
import importlib.abc
|
||||
import sys
|
||||
|
||||
class Blocker(importlib.abc.MetaPathFinder):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname == 'govoplan_reporting' or fullname.startswith('govoplan_reporting.'):
|
||||
raise ModuleNotFoundError("Reporting is physically absent", name=fullname)
|
||||
return None
|
||||
|
||||
sys.meta_path.insert(0, Blocker())
|
||||
from govoplan_campaign.backend.manifest import get_manifest
|
||||
manifest = get_manifest()
|
||||
assert 'reporting' not in manifest.dependencies
|
||||
assert 'reporting' in manifest.optional_dependencies
|
||||
assert 'reporting.report_provider.campaigns' in manifest.capability_factories
|
||||
"""
|
||||
_run_probe(script)
|
||||
|
||||
|
||||
def test_reporting_starts_without_campaign_and_owns_the_global_route() -> None:
|
||||
script = """
|
||||
import importlib.abc
|
||||
import sys
|
||||
|
||||
class Blocker(importlib.abc.MetaPathFinder):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname == 'govoplan_campaign' or fullname.startswith('govoplan_campaign.'):
|
||||
raise ModuleNotFoundError("Campaign is physically absent", name=fullname)
|
||||
return None
|
||||
|
||||
sys.meta_path.insert(0, Blocker())
|
||||
from govoplan_reporting.backend.manifest import get_manifest
|
||||
manifest = get_manifest()
|
||||
assert 'campaigns' not in manifest.dependencies
|
||||
assert manifest.nav_items[0].path == '/reports'
|
||||
assert {item.path for item in manifest.frontend.routes} == {'/reports', '/reporting'}
|
||||
"""
|
||||
_run_probe(script)
|
||||
|
||||
|
||||
def test_reporting_with_campaign_uses_only_the_provider_contract() -> None:
|
||||
script = """
|
||||
from govoplan_campaign.backend.manifest import get_manifest as campaign_manifest
|
||||
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
|
||||
from govoplan_reporting.backend.manifest import get_manifest as reporting_manifest
|
||||
|
||||
reporting = reporting_manifest()
|
||||
campaign = campaign_manifest()
|
||||
provider_name = REPORT_PROVIDER_CAPABILITY_PREFIX + 'campaigns'
|
||||
assert provider_name in campaign.capability_factories
|
||||
assert provider_name in {item.name for item in campaign.provides_interfaces}
|
||||
assert 'campaigns' not in reporting.dependencies
|
||||
assert '/reports' not in {route.path for route in campaign.frontend.routes}
|
||||
provider = campaign.capability_factories[provider_name](None)
|
||||
assert provider.provider_id == 'campaigns'
|
||||
assert provider.contract_version == '1.0'
|
||||
"""
|
||||
_run_probe(script)
|
||||
|
||||
|
||||
def test_reporting_starts_without_files_or_mail_and_keeps_targets_optional() -> None:
|
||||
script = """
|
||||
import importlib.abc
|
||||
import sys
|
||||
|
||||
class Blocker(importlib.abc.MetaPathFinder):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname == 'govoplan_files' or fullname.startswith('govoplan_files.'):
|
||||
raise ModuleNotFoundError("Files is physically absent", name=fullname)
|
||||
if fullname == 'govoplan_mail' or fullname.startswith('govoplan_mail.'):
|
||||
raise ModuleNotFoundError("Mail is physically absent", name=fullname)
|
||||
return None
|
||||
|
||||
sys.meta_path.insert(0, Blocker())
|
||||
from govoplan_reporting.backend.contracts import (
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||
CAPABILITY_REPORTING_PUBLICATION_MAIL,
|
||||
)
|
||||
from govoplan_reporting.backend.manifest import get_manifest
|
||||
manifest = get_manifest()
|
||||
assert 'files' in manifest.optional_dependencies
|
||||
assert 'mail' in manifest.optional_dependencies
|
||||
assert CAPABILITY_REPORTING_PUBLICATION_FILES in manifest.capability_factories
|
||||
assert CAPABILITY_REPORTING_PUBLICATION_MAIL in manifest.capability_factories
|
||||
"""
|
||||
_run_probe(script)
|
||||
|
||||
|
||||
def _run_probe(source: str) -> None:
|
||||
environment = dict(os.environ)
|
||||
environment["PYTHONPATH"] = os.pathsep.join(
|
||||
(
|
||||
str(_ROOT / "src"),
|
||||
str(_ROOT.parent / "govoplan-campaign" / "src"),
|
||||
str(_ROOT.parent / "govoplan-core" / "src"),
|
||||
environment.get("PYTHONPATH", ""),
|
||||
)
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", source],
|
||||
cwd=_ROOT,
|
||||
env=environment,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
@@ -0,0 +1,336 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.reporting import (
|
||||
REPORT_PROVIDER_CAPABILITY_PREFIX,
|
||||
REPORT_PROVIDER_CONTRACT_VERSION,
|
||||
ReportDescriptor,
|
||||
ReportParameterDescriptor,
|
||||
ReportParameterOption,
|
||||
ReportPrivacyTransform,
|
||||
ReportProviderRequest,
|
||||
ReportProviderResult,
|
||||
ReportResultField,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_reporting.backend.db.models import (
|
||||
ReportingProviderExecution,
|
||||
ReportingProviderExport,
|
||||
)
|
||||
from govoplan_reporting.backend.provider_reports import (
|
||||
ProviderReportError,
|
||||
execute_provider_report,
|
||||
export_provider_execution,
|
||||
list_provider_reports,
|
||||
)
|
||||
from govoplan_reporting.backend.retention import ReportingRetentionService
|
||||
|
||||
|
||||
class _Principal:
|
||||
tenant_id = "tenant-1"
|
||||
user = SimpleNamespace(id="analyst-1")
|
||||
api_key = None
|
||||
|
||||
|
||||
class _Provider:
|
||||
provider_id = "example"
|
||||
contract_version = REPORT_PROVIDER_CONTRACT_VERSION
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.result_access = True
|
||||
|
||||
def list_reports(self, session, principal):
|
||||
del session, principal
|
||||
return (
|
||||
ReportDescriptor(
|
||||
provider_id=self.provider_id,
|
||||
report_id="summary",
|
||||
revision="example.summary.v1",
|
||||
title="Example summary",
|
||||
summary="A minimized example report.",
|
||||
parameters=(
|
||||
ReportParameterDescriptor(
|
||||
key="source_id",
|
||||
label="Source",
|
||||
type="reference",
|
||||
required=True,
|
||||
options_from_provider=True,
|
||||
),
|
||||
),
|
||||
result_schema=(
|
||||
ReportResultField(
|
||||
path="metric",
|
||||
label="Metric",
|
||||
type="integer",
|
||||
group="Outcome",
|
||||
),
|
||||
),
|
||||
privacy_transforms=(
|
||||
ReportPrivacyTransform(id="aggregate", label="Aggregate"),
|
||||
),
|
||||
export_formats=("json",),
|
||||
reidentification_risk="low",
|
||||
),
|
||||
)
|
||||
|
||||
def parameter_options(self, session, principal, **kwargs):
|
||||
del session, principal, kwargs
|
||||
return (ReportParameterOption(value="source-1", label="Source 1"),)
|
||||
|
||||
def execute_report(
|
||||
self,
|
||||
session,
|
||||
principal,
|
||||
*,
|
||||
request: ReportProviderRequest,
|
||||
):
|
||||
del session
|
||||
return ReportProviderResult(
|
||||
report_id=request.report_id,
|
||||
generated_at=datetime(2026, 8, 2, 10, 0, tzinfo=UTC),
|
||||
payload={"metric": 12},
|
||||
source_revisions=(
|
||||
{
|
||||
"module_id": "example",
|
||||
"resource_id": request.parameters["source_id"],
|
||||
"revision_id": "7",
|
||||
},
|
||||
),
|
||||
effective_scope={
|
||||
"tenant_id": principal.tenant_id,
|
||||
"source_id": request.parameters["source_id"],
|
||||
},
|
||||
applied_privacy_transforms=("aggregate",),
|
||||
provenance={"executor": "example-v1"},
|
||||
)
|
||||
|
||||
def authorize_result(self, session, principal, **kwargs):
|
||||
del session, principal, kwargs
|
||||
return self.result_access
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider=None):
|
||||
self.provider = provider or _Provider()
|
||||
|
||||
def capability_names(self):
|
||||
return (REPORT_PROVIDER_CAPABILITY_PREFIX + "example",)
|
||||
|
||||
def has_capability(self, name):
|
||||
return name == REPORT_PROVIDER_CAPABILITY_PREFIX + "example"
|
||||
|
||||
def capability(self, name):
|
||||
return self.provider if self.has_capability(name) else None
|
||||
|
||||
|
||||
def test_provider_report_execution_is_governed_replayable_and_audited() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
principal = _Principal()
|
||||
registry = _Registry()
|
||||
with Session(engine, expire_on_commit=False) as session:
|
||||
catalogue = list_provider_reports(session, principal, registry=registry)
|
||||
assert catalogue["reports"][0]["available"] is True
|
||||
|
||||
execution = execute_provider_report(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
provider_id="example",
|
||||
report_id="summary",
|
||||
parameters={"source_id": "source-1"},
|
||||
purpose="Operational overview",
|
||||
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
|
||||
idempotency_key="provider-run-1",
|
||||
)
|
||||
replay = execute_provider_report(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
provider_id="example",
|
||||
report_id="summary",
|
||||
parameters={"source_id": "source-1"},
|
||||
purpose="Operational overview",
|
||||
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
|
||||
idempotency_key="provider-run-1",
|
||||
)
|
||||
assert replay["execution_id"] == execution["execution_id"]
|
||||
assert execution["source_revisions"][0]["revision_id"] == "7"
|
||||
assert execution["privacy_transforms"] == ["aggregate"]
|
||||
assert execution["retention_days"] == 30
|
||||
|
||||
content, media_type, filename = export_provider_execution(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
execution_id=str(execution["execution_id"]),
|
||||
format="json",
|
||||
purpose="Archive approved aggregate",
|
||||
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
|
||||
)
|
||||
assert b'"metric": 12' in content
|
||||
assert media_type == "application/json"
|
||||
assert filename.endswith(".json")
|
||||
assert session.query(ReportingProviderExport).count() == 1
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_provider_result_missing_a_required_transform_is_rejected() -> None:
|
||||
class _UnsafeProvider(_Provider):
|
||||
def execute_report(self, session, principal, *, request):
|
||||
result = super().execute_report(
|
||||
session,
|
||||
principal,
|
||||
request=request,
|
||||
)
|
||||
return ReportProviderResult(
|
||||
report_id=result.report_id,
|
||||
generated_at=result.generated_at,
|
||||
payload=result.payload,
|
||||
source_revisions=result.source_revisions,
|
||||
effective_scope=result.effective_scope,
|
||||
applied_privacy_transforms=(),
|
||||
provenance=result.provenance,
|
||||
)
|
||||
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
try:
|
||||
execute_provider_report(
|
||||
session,
|
||||
_Principal(),
|
||||
registry=_Registry(_UnsafeProvider()),
|
||||
provider_id="example",
|
||||
report_id="summary",
|
||||
parameters={"source_id": "source-1"},
|
||||
purpose="Operational overview",
|
||||
audience_scope={"scope_type": "tenant"},
|
||||
idempotency_key="unsafe-provider-run",
|
||||
)
|
||||
except ProviderReportError as exc:
|
||||
assert "privacy transformations" in str(exc)
|
||||
else:
|
||||
raise AssertionError("unsafe provider report was accepted")
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_expired_provider_result_is_minimized_but_evidence_is_retained() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine, expire_on_commit=False) as session:
|
||||
execution = execute_provider_report(
|
||||
session,
|
||||
_Principal(),
|
||||
registry=_Registry(),
|
||||
provider_id="example",
|
||||
report_id="summary",
|
||||
parameters={"source_id": "source-1"},
|
||||
purpose="Operational overview",
|
||||
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
|
||||
idempotency_key="expired-provider-run",
|
||||
)
|
||||
row = session.query(ReportingProviderExecution).one()
|
||||
original_hash = row.output_hash
|
||||
row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||
session.flush()
|
||||
|
||||
preview = ReportingRetentionService().apply_retention(
|
||||
session,
|
||||
dry_run=True,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
assert preview["eligible"] == 1
|
||||
assert row.result_payload == {"metric": 12}
|
||||
|
||||
applied = ReportingRetentionService().apply_retention(
|
||||
session,
|
||||
dry_run=False,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
assert applied["redacted"] == 1
|
||||
assert row.result_payload == {}
|
||||
assert row.retention_redacted_at is not None
|
||||
assert row.output_hash == original_hash == execution["output_hash"]
|
||||
assert row.source_revisions[0]["revision_id"] == "7"
|
||||
try:
|
||||
execute_provider_report(
|
||||
session,
|
||||
_Principal(),
|
||||
registry=_Registry(),
|
||||
provider_id="example",
|
||||
report_id="summary",
|
||||
parameters={"source_id": "source-1"},
|
||||
purpose="Operational overview",
|
||||
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
|
||||
idempotency_key="expired-provider-run",
|
||||
)
|
||||
except ProviderReportError as exc:
|
||||
assert "expired" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expired provider-report detail was replayed")
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_provider_parameters_are_checked_against_the_declared_type() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
try:
|
||||
execute_provider_report(
|
||||
session,
|
||||
_Principal(),
|
||||
registry=_Registry(),
|
||||
provider_id="example",
|
||||
report_id="summary",
|
||||
parameters={"source_id": 123},
|
||||
purpose="Operational overview",
|
||||
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
|
||||
idempotency_key="invalid-provider-parameters",
|
||||
)
|
||||
except ProviderReportError as exc:
|
||||
assert "parameter types" in str(exc)
|
||||
else:
|
||||
raise AssertionError("invalid provider-report parameters were accepted")
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_source_access_is_rechecked_before_retained_result_export() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
provider = _Provider()
|
||||
registry = _Registry(provider)
|
||||
with Session(engine) as session:
|
||||
execution = execute_provider_report(
|
||||
session,
|
||||
_Principal(),
|
||||
registry=registry,
|
||||
provider_id="example",
|
||||
report_id="summary",
|
||||
parameters={"source_id": "source-1"},
|
||||
purpose="Operational overview",
|
||||
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
|
||||
idempotency_key="source-access-provider-run",
|
||||
)
|
||||
provider.result_access = False
|
||||
|
||||
try:
|
||||
export_provider_execution(
|
||||
session,
|
||||
_Principal(),
|
||||
registry=registry,
|
||||
execution_id=str(execution["execution_id"]),
|
||||
format="json",
|
||||
purpose="Archive aggregate",
|
||||
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
|
||||
)
|
||||
except PermissionError as exc:
|
||||
assert "source module" in str(exc)
|
||||
else:
|
||||
raise AssertionError("revoked source result was exported")
|
||||
engine.dispose()
|
||||
@@ -0,0 +1,902 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.dataflows import (
|
||||
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
|
||||
DataflowDatasetResult,
|
||||
)
|
||||
from govoplan_core.core.files import (
|
||||
CAPABILITY_FILES_ARTIFACT_STORE,
|
||||
ManagedArtifactRef,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_reporting.backend.definitions import (
|
||||
ADMIN_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
ReportingDefinitionError,
|
||||
create_definition,
|
||||
definition_history,
|
||||
get_definition,
|
||||
list_definitions,
|
||||
update_definition,
|
||||
)
|
||||
from govoplan_reporting.backend.execution import (
|
||||
QUALITY_SCOPE,
|
||||
RUN_SCOPE,
|
||||
ReportingExecutionFailure,
|
||||
execute_report,
|
||||
run_quality_plan,
|
||||
)
|
||||
from govoplan_reporting.backend.drilldown import (
|
||||
ReportingDrillError,
|
||||
create_drill_context,
|
||||
resolve_drill_context,
|
||||
)
|
||||
from govoplan_reporting.backend.operations import (
|
||||
IMPORT_SCOPE,
|
||||
PUBLISH_SCOPE,
|
||||
SCHEDULE_SCOPE,
|
||||
ReportingOperationError,
|
||||
assess_import,
|
||||
dispatch_due_schedules,
|
||||
export_execution,
|
||||
list_publications,
|
||||
publish_execution,
|
||||
upsert_saved_view,
|
||||
upsert_schedule,
|
||||
)
|
||||
from govoplan_reporting.backend.schemas import ReportQuery
|
||||
from govoplan_reporting.backend.postgres_planner import compile_postgres_query
|
||||
from govoplan_reporting.backend.contracts import (
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||
)
|
||||
from govoplan_reporting.backend.publication_targets import (
|
||||
FilesReportingPublicationTarget,
|
||||
publication_target_catalog,
|
||||
)
|
||||
from govoplan_reporting.backend.schemas import (
|
||||
DatasetDefinition,
|
||||
SemanticModelDefinition,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||
ALL_SCOPES = (
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
ADMIN_SCOPE,
|
||||
RUN_SCOPE,
|
||||
QUALITY_SCOPE,
|
||||
PUBLISH_SCOPE,
|
||||
SCHEDULE_SCOPE,
|
||||
IMPORT_SCOPE,
|
||||
)
|
||||
|
||||
|
||||
class Principal:
|
||||
def __init__(
|
||||
self,
|
||||
tenant_id: str = "tenant-1",
|
||||
account_id: str = "analyst-1",
|
||||
*,
|
||||
scopes: tuple[str, ...] = ALL_SCOPES,
|
||||
group_ids: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
self.tenant_id = tenant_id
|
||||
self.account_id = account_id
|
||||
self.identity_id = f"identity-{account_id}"
|
||||
self.membership_id = f"membership-{account_id}"
|
||||
self.group_ids = frozenset(group_ids)
|
||||
self.role_ids = frozenset()
|
||||
self.function_assignment_ids = frozenset()
|
||||
self.acting_assignment_id = None
|
||||
self.service_account_id = None
|
||||
self.scopes = frozenset(scopes)
|
||||
self.user = SimpleNamespace(id=f"user-{account_id}")
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scopes_grant_compatible(self.scopes, scope)
|
||||
|
||||
|
||||
class CapabilityRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.providers: dict[str, object] = {}
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self.providers
|
||||
|
||||
def capability(self, name: str) -> object | None:
|
||||
return self.providers.get(name)
|
||||
|
||||
|
||||
class ArtifactStore:
|
||||
def __init__(self) -> None:
|
||||
self.requests: list[object] = []
|
||||
|
||||
def store_artifact(self, session, principal, *, request):
|
||||
del session, principal
|
||||
self.requests.append(request)
|
||||
return ManagedArtifactRef(
|
||||
file_asset_id="asset-1",
|
||||
file_version_id="version-1",
|
||||
filename=request.filename,
|
||||
display_path=f"{request.folder}/{request.filename}",
|
||||
content_type=request.content_type,
|
||||
size_bytes=len(request.payload),
|
||||
sha256="a" * 64,
|
||||
provenance={"stored": True},
|
||||
)
|
||||
|
||||
|
||||
class DataflowOutput:
|
||||
def __init__(self, rows: list[dict[str, object]]) -> None:
|
||||
self.rows = tuple(dict(item) for item in rows)
|
||||
self.last_request = None
|
||||
|
||||
def list_outputs(self, *_args, **_kwargs):
|
||||
return ()
|
||||
|
||||
def read_output(self, _session, _principal, *, request):
|
||||
self.last_request = request
|
||||
return DataflowDatasetResult(
|
||||
pipeline_ref=request.pipeline_ref,
|
||||
revision=request.revision,
|
||||
definition_hash=request.expected_definition_hash or "pipeline-hash",
|
||||
rows=self.rows,
|
||||
total_rows=len(self.rows),
|
||||
truncated=False,
|
||||
output_hash="d" * 64,
|
||||
executor_version="duckdb-v1",
|
||||
run_ref=request.run_ref,
|
||||
source_fingerprints=(
|
||||
{"node_id": "source", "fingerprint": "source-v1"},
|
||||
),
|
||||
generated_at=NOW,
|
||||
provenance={"immutable_run": bool(request.run_ref)},
|
||||
)
|
||||
|
||||
|
||||
class ReportingServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine, expire_on_commit=False)
|
||||
self.principal = Principal()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_definition_graph_is_pinned_versioned_and_occ_guarded(self) -> None:
|
||||
dataset, semantic, report = self._create_report_graph()
|
||||
self.assertEqual(
|
||||
("dataset", dataset.definition_id, 1),
|
||||
(
|
||||
semantic.parent_kind,
|
||||
semantic.parent_id,
|
||||
semantic.parent_revision,
|
||||
),
|
||||
)
|
||||
replay = self._create(
|
||||
"report",
|
||||
"report-1",
|
||||
report_payload(),
|
||||
idempotency_key="create-report",
|
||||
)
|
||||
self.assertEqual(report.content_hash, replay.content_hash)
|
||||
|
||||
updated = update_definition(
|
||||
self.session,
|
||||
self.principal,
|
||||
definition_kind="report",
|
||||
definition_id="report-1",
|
||||
expected_revision=1,
|
||||
recorded_at=NOW.replace(hour=11),
|
||||
change_reason="Clarify the institutional question.",
|
||||
idempotency_key="update-report",
|
||||
changes={"description": "Regional workload and value."},
|
||||
)
|
||||
self.assertEqual(2, updated.revision)
|
||||
self.assertEqual(
|
||||
[2, 1],
|
||||
[
|
||||
item.revision
|
||||
for item in definition_history(
|
||||
self.session,
|
||||
self.principal,
|
||||
definition_kind="report",
|
||||
definition_id="report-1",
|
||||
)
|
||||
],
|
||||
)
|
||||
with self.assertRaisesRegex(ReportingDefinitionError, "stale"):
|
||||
update_definition(
|
||||
self.session,
|
||||
self.principal,
|
||||
definition_kind="report",
|
||||
definition_id="report-1",
|
||||
expected_revision=1,
|
||||
recorded_at=NOW.replace(hour=12),
|
||||
change_reason="Stale edit.",
|
||||
idempotency_key="stale-report",
|
||||
changes={"name": "Stale report"},
|
||||
)
|
||||
|
||||
def test_active_children_require_exact_active_parent_revision(self) -> None:
|
||||
self._create("dataset", "dataset-draft", dataset_payload(), status="draft")
|
||||
payload = semantic_payload(dataset_id="dataset-draft")
|
||||
with self.assertRaisesRegex(ReportingDefinitionError, "active dataset"):
|
||||
self._create("semantic_model", "semantic-invalid", payload)
|
||||
payload["dataset_id"] = "missing"
|
||||
with self.assertRaisesRegex(ReportingDefinitionError, "missing dataset"):
|
||||
self._create("semantic_model", "semantic-missing", payload)
|
||||
|
||||
def test_semantic_execution_replay_pivot_saved_view_and_schedule(self) -> None:
|
||||
self._create_report_graph()
|
||||
result = execute_report(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=None,
|
||||
report_id="report-1",
|
||||
report_revision=1,
|
||||
parameters={},
|
||||
query=None,
|
||||
idempotency_key="run-report-1",
|
||||
)
|
||||
self.assertEqual("succeeded", result["status"])
|
||||
self.assertEqual(
|
||||
[
|
||||
{"region": "North", "amount": 150, "cases": 15, "value_per_case": 10},
|
||||
{"region": "South", "amount": 40, "cases": 8, "value_per_case": 5},
|
||||
],
|
||||
result["rows"],
|
||||
)
|
||||
self.assertEqual(
|
||||
result["execution_id"],
|
||||
execute_report(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=None,
|
||||
report_id="report-1",
|
||||
report_revision=1,
|
||||
parameters={},
|
||||
query=None,
|
||||
idempotency_key="run-report-1",
|
||||
)["execution_id"],
|
||||
)
|
||||
self.assertEqual("bar", result["visualization"]["kind"])
|
||||
self.assertIsNotNone(result["visualization"]["tabular_fallback"])
|
||||
|
||||
pivot = execute_report(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=None,
|
||||
report_id="report-1",
|
||||
report_revision=1,
|
||||
parameters={},
|
||||
query=ReportQuery.model_validate(
|
||||
{
|
||||
"mode": "pivot",
|
||||
"pivot": {
|
||||
"rows": ["region"],
|
||||
"columns": ["category"],
|
||||
"measures": ["amount"],
|
||||
},
|
||||
}
|
||||
),
|
||||
idempotency_key="run-report-pivot",
|
||||
)
|
||||
self.assertEqual(2, pivot["total_rows"])
|
||||
self.assertEqual(100, pivot["rows"][0]["A.amount"])
|
||||
|
||||
view = upsert_saved_view(
|
||||
self.session,
|
||||
self.principal,
|
||||
view_id="view-1",
|
||||
report_id="report-1",
|
||||
report_revision=1,
|
||||
name="Regional pivot",
|
||||
state={"query": pivot["query"]},
|
||||
shared=True,
|
||||
access={},
|
||||
expected_revision=None,
|
||||
)
|
||||
self.assertEqual(1, view["revision"])
|
||||
schedule = upsert_schedule(
|
||||
self.session,
|
||||
self.principal,
|
||||
schedule_id="schedule-1",
|
||||
report_id="report-1",
|
||||
report_revision=1,
|
||||
name="Daily regional report",
|
||||
trigger_kind="interval",
|
||||
trigger_config={"seconds": 86_400},
|
||||
parameters={},
|
||||
query=ReportQuery(),
|
||||
publication_target={},
|
||||
enabled=True,
|
||||
next_run_at=NOW,
|
||||
expected_revision=None,
|
||||
)
|
||||
self.assertEqual(1, schedule["revision"])
|
||||
dispatched = dispatch_due_schedules(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=None,
|
||||
now=NOW,
|
||||
limit=10,
|
||||
)
|
||||
self.assertEqual(
|
||||
(1, 1, 0),
|
||||
(
|
||||
dispatched["claimed"],
|
||||
dispatched["succeeded"],
|
||||
dispatched["failed"],
|
||||
),
|
||||
)
|
||||
|
||||
def test_quality_gate_blocks_and_preserves_failure_evidence(self) -> None:
|
||||
self._create_report_graph()
|
||||
self._create(
|
||||
"quality_plan",
|
||||
"quality-1",
|
||||
{
|
||||
"dataset_id": "dataset-1",
|
||||
"dataset_revision": 1,
|
||||
"block_report_execution": True,
|
||||
"assertions": [
|
||||
{
|
||||
"key": "region-unique",
|
||||
"kind": "unique",
|
||||
"field": "region",
|
||||
"severity": "blocker",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
quality = run_quality_plan(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=None,
|
||||
quality_plan_id="quality-1",
|
||||
quality_plan_revision=1,
|
||||
parameters={},
|
||||
)
|
||||
self.assertEqual("failed", quality["status"])
|
||||
with self.assertRaises(ReportingExecutionFailure) as raised:
|
||||
execute_report(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=None,
|
||||
report_id="report-1",
|
||||
report_revision=1,
|
||||
parameters={},
|
||||
query=None,
|
||||
idempotency_key="quality-blocked-run",
|
||||
)
|
||||
self.assertTrue(raised.exception.execution_id)
|
||||
|
||||
def test_dataflow_dataset_can_pin_an_exact_published_run(self) -> None:
|
||||
payload = dataset_payload()
|
||||
rows = list(payload.pop("static_rows"))
|
||||
payload.update(
|
||||
{
|
||||
"source_kind": "dataflow",
|
||||
"source_ref": "pipeline:monthly-comparison",
|
||||
"source_revision": 4,
|
||||
"source_run_ref": "dataflow-run:published-july",
|
||||
"definition_hash": "pipeline-hash",
|
||||
}
|
||||
)
|
||||
self._create("dataset", "dataset-1", payload)
|
||||
self._create("semantic_model", "semantic-1", semantic_payload())
|
||||
self._create("report", "report-1", report_payload())
|
||||
provider = DataflowOutput(rows)
|
||||
registry = CapabilityRegistry()
|
||||
registry.providers[CAPABILITY_DATAFLOW_DATASET_OUTPUT] = provider
|
||||
|
||||
result = execute_report(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=registry,
|
||||
report_id="report-1",
|
||||
report_revision=1,
|
||||
parameters={},
|
||||
query=None,
|
||||
idempotency_key="published-dataflow-run",
|
||||
)
|
||||
|
||||
self.assertEqual("succeeded", result["status"])
|
||||
self.assertIsNotNone(provider.last_request)
|
||||
self.assertEqual(
|
||||
"dataflow-run:published-july",
|
||||
provider.last_request.run_ref,
|
||||
)
|
||||
self.assertTrue(result["provenance"]["source"]["immutable_run"])
|
||||
|
||||
def test_restricted_access_and_service_scope_guards(self) -> None:
|
||||
self._create_report_graph(
|
||||
report_access={
|
||||
"subjects": [
|
||||
{
|
||||
"kind": "group",
|
||||
"id": "group-auditors",
|
||||
"permissions": ["read"],
|
||||
}
|
||||
]
|
||||
},
|
||||
report_visibility="restricted",
|
||||
)
|
||||
regular_scopes = (READ_SCOPE, WRITE_SCOPE, RUN_SCOPE)
|
||||
outsider = Principal(account_id="outsider", scopes=regular_scopes)
|
||||
auditor = Principal(
|
||||
account_id="auditor",
|
||||
scopes=regular_scopes,
|
||||
group_ids=("group-auditors",),
|
||||
)
|
||||
self.assertIsNone(
|
||||
get_definition(
|
||||
self.session,
|
||||
outsider,
|
||||
definition_kind="report",
|
||||
definition_id="report-1",
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
get_definition(
|
||||
self.session,
|
||||
auditor,
|
||||
definition_kind="report",
|
||||
definition_id="report-1",
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
list_definitions(
|
||||
self.session,
|
||||
outsider,
|
||||
definition_kinds=("report",),
|
||||
)[1],
|
||||
)
|
||||
without_permissions = Principal(account_id="none", scopes=())
|
||||
with self.assertRaises(PermissionError):
|
||||
list_definitions(self.session, without_permissions)
|
||||
with self.assertRaises(PermissionError):
|
||||
self._create(
|
||||
"dataset",
|
||||
"unauthorized",
|
||||
dataset_payload(),
|
||||
principal=without_permissions,
|
||||
)
|
||||
|
||||
def test_safe_export_and_import_activation_assessment(self) -> None:
|
||||
self._create_report_graph()
|
||||
detail = execute_report(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=None,
|
||||
report_id="report-1",
|
||||
report_revision=1,
|
||||
parameters={},
|
||||
query=ReportQuery(mode="detail", dimensions=["note"]),
|
||||
idempotency_key="detail-export",
|
||||
)
|
||||
content, content_type, _filename = export_execution(
|
||||
self.session,
|
||||
self.principal,
|
||||
execution_id=str(detail["execution_id"]),
|
||||
format="csv",
|
||||
)
|
||||
self.assertEqual("text/csv; charset=utf-8", content_type)
|
||||
self.assertIn("'=cmd", content.decode("utf-8-sig"))
|
||||
|
||||
blocked = assess_import(
|
||||
self.session,
|
||||
self.principal,
|
||||
source_system="SuperX",
|
||||
source_id="legacy-report",
|
||||
metadata={"features": ["dataset", "raw_sql", "dashboard_layout"]},
|
||||
accepted_approximations=[],
|
||||
)
|
||||
self.assertEqual("blocked", blocked["status"])
|
||||
self.assertFalse(blocked["mapping_report"]["activation_allowed"])
|
||||
ready = assess_import(
|
||||
self.session,
|
||||
self.principal,
|
||||
source_system="SuperX",
|
||||
source_id="mapped-report",
|
||||
metadata={"features": ["dataset", "dashboard_layout"]},
|
||||
accepted_approximations=["dashboard_layout"],
|
||||
)
|
||||
self.assertEqual("ready", ready["status"])
|
||||
with self.assertRaisesRegex(ReportingOperationError, "must be a list"):
|
||||
assess_import(
|
||||
self.session,
|
||||
self.principal,
|
||||
source_system="invalid",
|
||||
source_id="invalid",
|
||||
metadata={"features": "raw_sql"},
|
||||
accepted_approximations=[],
|
||||
)
|
||||
|
||||
def test_drill_context_is_bounded_actor_bound_and_reauthorized(self) -> None:
|
||||
self._create_report_graph()
|
||||
execution = execute_report(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=None,
|
||||
report_id="report-1",
|
||||
report_revision=1,
|
||||
parameters={},
|
||||
query=None,
|
||||
idempotency_key="drill-source",
|
||||
)
|
||||
north = next(row for row in execution["rows"] if row["region"] == "North")
|
||||
context = create_drill_context(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=None,
|
||||
execution_id=str(execution["execution_id"]),
|
||||
aggregate_row=north,
|
||||
limit=50,
|
||||
)
|
||||
detail = resolve_drill_context(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=None,
|
||||
token=str(context["token"]),
|
||||
)
|
||||
self.assertEqual(2, detail["total_rows"])
|
||||
self.assertEqual({"North"}, {row["region"] for row in detail["rows"]})
|
||||
self.assertEqual("region", detail["dimension_path"][0]["dimension"])
|
||||
with self.assertRaises(PermissionError):
|
||||
resolve_drill_context(
|
||||
self.session,
|
||||
Principal(account_id="another-analyst"),
|
||||
registry=None,
|
||||
token=str(context["token"]),
|
||||
)
|
||||
with self.assertRaises(ReportingDrillError):
|
||||
create_drill_context(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=None,
|
||||
execution_id=str(execution["execution_id"]),
|
||||
aggregate_row={"region": "Not an execution row"},
|
||||
limit=50,
|
||||
)
|
||||
|
||||
def test_governance_scope_inheritance_never_broadens_parent_limits(self) -> None:
|
||||
system = Principal(
|
||||
scopes=(*ALL_SCOPES, "system:governance:write"),
|
||||
group_ids=("group-reporters",),
|
||||
)
|
||||
dataset = dataset_payload()
|
||||
dataset["governance"] = {
|
||||
"scope_type": "system",
|
||||
"inherit_to_lower_scopes": True,
|
||||
"allow_run": True,
|
||||
"allow_reuse": True,
|
||||
"allow_automation": False,
|
||||
}
|
||||
self._create("dataset", "dataset-governed", dataset, principal=system)
|
||||
semantic = semantic_payload(dataset_id="dataset-governed")
|
||||
semantic["governance"] = {
|
||||
"scope_type": "tenant",
|
||||
"inherit_to_lower_scopes": True,
|
||||
"allow_run": True,
|
||||
"allow_reuse": True,
|
||||
"allow_automation": True,
|
||||
}
|
||||
with self.assertRaisesRegex(ValueError, "cannot broaden inherited limits"):
|
||||
self._create(
|
||||
"semantic_model",
|
||||
"semantic-broadened",
|
||||
semantic,
|
||||
principal=system,
|
||||
)
|
||||
semantic["governance"]["allow_automation"] = False
|
||||
semantic_record = self._create(
|
||||
"semantic_model",
|
||||
"semantic-governed",
|
||||
semantic,
|
||||
principal=system,
|
||||
)
|
||||
semantic_governance = semantic_record.payload["governance"]
|
||||
self.assertEqual("system", semantic_governance["source_scope"]["scope_type"])
|
||||
self.assertFalse(
|
||||
semantic_governance["source_effective_limits"]["allow_automation"]
|
||||
)
|
||||
report = report_payload()
|
||||
report["semantic_model_id"] = "semantic-governed"
|
||||
report["governance"] = {
|
||||
"scope_type": "group",
|
||||
"scope_id": "group-reporters",
|
||||
"inherit_to_lower_scopes": False,
|
||||
"allow_run": True,
|
||||
"allow_reuse": False,
|
||||
"allow_automation": False,
|
||||
}
|
||||
self._create("report", "report-governed", report, principal=system)
|
||||
self.assertIsNotNone(
|
||||
get_definition(
|
||||
self.session,
|
||||
system,
|
||||
definition_kind="report",
|
||||
definition_id="report-governed",
|
||||
)
|
||||
)
|
||||
self.assertIsNone(
|
||||
get_definition(
|
||||
self.session,
|
||||
Principal(account_id="outsider"),
|
||||
definition_kind="report",
|
||||
definition_id="report-governed",
|
||||
)
|
||||
)
|
||||
|
||||
def test_postgres_plan_is_bounded_and_parameterized(self) -> None:
|
||||
dataset = DatasetDefinition.model_validate(dataset_payload())
|
||||
semantic = SemanticModelDefinition.model_validate(semantic_payload())
|
||||
query = ReportQuery.model_validate(
|
||||
{
|
||||
"mode": "summary",
|
||||
"dimensions": ["region"],
|
||||
"measures": ["amount", "value_per_case"],
|
||||
"filters": [
|
||||
{
|
||||
"dimension": "region",
|
||||
"operator": "contains",
|
||||
"value": "North%' OR TRUE --",
|
||||
}
|
||||
],
|
||||
"sort": [{"key": "amount", "direction": "desc"}],
|
||||
"limit": 25,
|
||||
}
|
||||
)
|
||||
plan = compile_postgres_query(dataset, semantic, query)
|
||||
self.assertIn("GROUP BY", plan.sql)
|
||||
self.assertIn("LIMIT :result_limit OFFSET :result_offset", plan.sql)
|
||||
self.assertNotIn("North%' OR TRUE --", plan.sql)
|
||||
self.assertIn("North", str(plan.parameters["filter_0"]))
|
||||
calculated_only = compile_postgres_query(
|
||||
dataset,
|
||||
semantic,
|
||||
ReportQuery(
|
||||
mode="summary",
|
||||
dimensions=["region"],
|
||||
measures=["value_per_case"],
|
||||
),
|
||||
)
|
||||
self.assertIn('SUM(NULLIF(source_row ->> :measure_0, \'\')::numeric)', calculated_only.sql)
|
||||
self.assertIn('AS "value_per_case"', calculated_only.sql)
|
||||
|
||||
def test_files_publication_is_idempotent_and_retains_evidence(self) -> None:
|
||||
self._create_report_graph()
|
||||
registry = CapabilityRegistry()
|
||||
store = ArtifactStore()
|
||||
registry.providers[CAPABILITY_FILES_ARTIFACT_STORE] = store
|
||||
registry.providers[CAPABILITY_REPORTING_PUBLICATION_FILES] = (
|
||||
FilesReportingPublicationTarget(registry)
|
||||
)
|
||||
execution = execute_report(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=registry,
|
||||
report_id="report-1",
|
||||
report_revision=1,
|
||||
parameters={},
|
||||
query=None,
|
||||
idempotency_key="publish-source",
|
||||
)
|
||||
first = publish_execution(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=registry,
|
||||
execution_id=str(execution["execution_id"]),
|
||||
target_capability=CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||
target_ref="Reports/Monthly",
|
||||
format="csv",
|
||||
idempotency_key="publish-files-once",
|
||||
options={"filename": "regional workload.csv"},
|
||||
)
|
||||
replay = publish_execution(
|
||||
self.session,
|
||||
self.principal,
|
||||
registry=registry,
|
||||
execution_id=str(execution["execution_id"]),
|
||||
target_capability=CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||
target_ref="Reports/Monthly",
|
||||
format="csv",
|
||||
idempotency_key="publish-files-once",
|
||||
options={"filename": "regional workload.csv"},
|
||||
)
|
||||
self.assertEqual(first["publication_id"], replay["publication_id"])
|
||||
self.assertEqual(1, len(store.requests))
|
||||
self.assertEqual("version-1", first["evidence"]["file_version_id"])
|
||||
self.assertEqual(
|
||||
1,
|
||||
len(
|
||||
list_publications(
|
||||
self.session,
|
||||
self.principal,
|
||||
execution_id=str(execution["execution_id"]),
|
||||
)
|
||||
),
|
||||
)
|
||||
targets = publication_target_catalog(registry)
|
||||
self.assertTrue(targets[0]["available"])
|
||||
self.assertFalse(targets[1]["available"])
|
||||
self.assertIn("Enable Mail", str(targets[1]["reason"]))
|
||||
|
||||
def _create_report_graph(
|
||||
self,
|
||||
*,
|
||||
report_access: dict[str, object] | None = None,
|
||||
report_visibility: str = "tenant",
|
||||
):
|
||||
dataset = self._create("dataset", "dataset-1", dataset_payload())
|
||||
semantic = self._create(
|
||||
"semantic_model",
|
||||
"semantic-1",
|
||||
semantic_payload(),
|
||||
)
|
||||
report = self._create(
|
||||
"report",
|
||||
"report-1",
|
||||
report_payload(access_policy=report_access),
|
||||
visibility=report_visibility,
|
||||
idempotency_key="create-report",
|
||||
)
|
||||
return dataset, semantic, report
|
||||
|
||||
def _create(
|
||||
self,
|
||||
kind: str,
|
||||
definition_id: str,
|
||||
payload: dict[str, object],
|
||||
*,
|
||||
status: str = "active",
|
||||
visibility: str = "tenant",
|
||||
idempotency_key: str | None = None,
|
||||
principal: Principal | None = None,
|
||||
):
|
||||
return create_definition(
|
||||
self.session,
|
||||
principal or self.principal,
|
||||
definition_kind=kind,
|
||||
definition_id=definition_id,
|
||||
definition_key=definition_id,
|
||||
name=definition_id.replace("-", " ").title(),
|
||||
description="Governed reporting test definition.",
|
||||
status=status,
|
||||
visibility=visibility,
|
||||
recorded_at=NOW,
|
||||
change_reason="Initial governed baseline.",
|
||||
payload=payload,
|
||||
idempotency_key=idempotency_key or f"create-{definition_id}",
|
||||
)
|
||||
|
||||
|
||||
def dataset_payload() -> dict[str, object]:
|
||||
return {
|
||||
"source_kind": "static",
|
||||
"source_ref": "fixture.regional-workload",
|
||||
"purpose": "Verify governed institutional reporting.",
|
||||
"schema": [
|
||||
{"name": "region", "type": "string", "nullable": False},
|
||||
{"name": "category", "type": "string", "nullable": False},
|
||||
{"name": "amount", "type": "number", "nullable": False},
|
||||
{"name": "cases", "type": "integer", "nullable": False},
|
||||
{"name": "note", "type": "string", "nullable": False},
|
||||
],
|
||||
"static_rows": [
|
||||
{
|
||||
"region": "North",
|
||||
"category": "A",
|
||||
"amount": 100,
|
||||
"cases": 10,
|
||||
"note": "=cmd",
|
||||
},
|
||||
{
|
||||
"region": "North",
|
||||
"category": "B",
|
||||
"amount": 50,
|
||||
"cases": 5,
|
||||
"note": "ordinary",
|
||||
},
|
||||
{
|
||||
"region": "South",
|
||||
"category": "A",
|
||||
"amount": 40,
|
||||
"cases": 8,
|
||||
"note": "ordinary",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def semantic_payload(*, dataset_id: str = "dataset-1") -> dict[str, object]:
|
||||
return {
|
||||
"dataset_id": dataset_id,
|
||||
"dataset_revision": 1,
|
||||
"dimensions": [
|
||||
{"key": "region", "field": "region", "label": "Region"},
|
||||
{"key": "category", "field": "category", "label": "Category"},
|
||||
{"key": "note", "field": "note", "label": "Note"},
|
||||
],
|
||||
"hierarchies": [
|
||||
{
|
||||
"key": "regional-category",
|
||||
"label": "Region and category",
|
||||
"levels": [{"dimension": "region"}, {"dimension": "category"}],
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
{
|
||||
"key": "amount",
|
||||
"label": "Amount",
|
||||
"aggregation": "sum",
|
||||
"field": "amount",
|
||||
},
|
||||
{"key": "cases", "label": "Cases", "aggregation": "sum", "field": "cases"},
|
||||
{
|
||||
"key": "value_per_case",
|
||||
"label": "Value per case",
|
||||
"aggregation": "calculated",
|
||||
"expression": {
|
||||
"op": "divide",
|
||||
"args": [
|
||||
{"op": "measure", "ref": "amount"},
|
||||
{"op": "measure", "ref": "cases"},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"default_dimensions": ["region"],
|
||||
"default_measures": ["amount", "cases", "value_per_case"],
|
||||
}
|
||||
|
||||
|
||||
def report_payload(
|
||||
*,
|
||||
access_policy: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"semantic_model_id": "semantic-1",
|
||||
"semantic_model_revision": 1,
|
||||
"default_query": {
|
||||
"mode": "summary",
|
||||
"dimensions": ["region"],
|
||||
"measures": ["amount", "cases", "value_per_case"],
|
||||
"sort": [{"key": "region", "direction": "asc"}],
|
||||
},
|
||||
"visualization": {
|
||||
"kind": "bar",
|
||||
"category_dimension": "region",
|
||||
"measures": ["amount"],
|
||||
"tabular_fallback": True,
|
||||
},
|
||||
"access_policy": access_policy or {},
|
||||
"institutional_references": [
|
||||
{
|
||||
"owner_module": "projects",
|
||||
"resource_type": "outcome",
|
||||
"resource_id": "faster-decisions",
|
||||
"revision": "1",
|
||||
"relationship": "measures",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@govoplan/reporting-webui",
|
||||
"version": "0.1.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/reporting.css": "./src/styles/reporting.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
|
||||
const page = fs.readFileSync("src/features/reporting/ReportingPage.tsx", "utf8");
|
||||
const provider = fs.readFileSync("src/features/reporting/ProviderReportWorkspace.tsx", "utf8");
|
||||
const styles = fs.readFileSync("src/styles/reporting.css", "utf8");
|
||||
|
||||
assert.ok(page.includes("DocumentationHelpLink"), "Reporting exposes configured-system help");
|
||||
assert.ok(page.includes("PageScrollViewport"), "Reporting owns bounded catalogue and inspector scrolling");
|
||||
assert.ok(page.includes("DataGrid"), "Tabular report results use the shared grid");
|
||||
assert.ok(page.includes("<Dialog"), "Save and schedule operations use shared dialogs");
|
||||
assert.ok(page.includes("createDrillContext"), "Aggregate detail uses an actor-bound drill context");
|
||||
assert.ok(page.includes("AccessExplanation"), "Policy-hidden fields, rows, and actions are explained");
|
||||
assert.ok(page.includes("PublishDialog"), "Publication targets use the shared dialog surface");
|
||||
assert.ok(provider.includes("disabledReason={runDisabledReason}"), "Governed report blockers remain keyboard-explainable");
|
||||
assert.ok(provider.includes("DismissibleAlert"), "Provider failures and unavailable states use shared alerts");
|
||||
assert.ok(!page.includes("window.alert("), "Reporting must not use browser alerts");
|
||||
assert.ok(styles.includes("@media (max-width: 760px)"), "Reporting retains a narrow-viewport task order");
|
||||
|
||||
console.log("Reporting interface pattern contract passed.");
|
||||
@@ -0,0 +1,543 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiPath,
|
||||
apiUrl,
|
||||
authHeaders,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
|
||||
export type ReportingDefinitionKind = "dataset" | "semantic_model" | "report" | "quality_plan";
|
||||
export type ReportingQueryMode = "summary" | "detail" | "pivot";
|
||||
|
||||
export type ReportingQuery = {
|
||||
mode: ReportingQueryMode;
|
||||
dimensions: string[];
|
||||
measures: string[];
|
||||
filters: Array<Record<string, unknown>>;
|
||||
sort: Array<{ key: string; direction: "asc" | "desc" }>;
|
||||
pivot?: {
|
||||
rows: string[];
|
||||
columns: string[];
|
||||
measures: string[];
|
||||
include_totals: boolean;
|
||||
} | null;
|
||||
offset: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type ReportingDefinition = {
|
||||
tenant_id: string;
|
||||
definition_kind: ReportingDefinitionKind;
|
||||
definition_id: string;
|
||||
definition_key: string;
|
||||
revision: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status: "draft" | "active" | "retired";
|
||||
visibility: "tenant" | "restricted";
|
||||
content_hash: string;
|
||||
parent_kind?: ReportingDefinitionKind | null;
|
||||
parent_id?: string | null;
|
||||
parent_revision?: number | null;
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SemanticModelPayload = {
|
||||
dataset_id: string;
|
||||
dataset_revision: number;
|
||||
dimensions: Array<{ key: string; field: string; label: string }>;
|
||||
measures: Array<{ key: string; label: string; aggregation: string }>;
|
||||
default_dimensions: string[];
|
||||
default_measures: string[];
|
||||
};
|
||||
|
||||
export type ReportPayload = {
|
||||
semantic_model_id: string;
|
||||
semantic_model_revision: number;
|
||||
parameters: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
default?: unknown;
|
||||
allowed_values: unknown[];
|
||||
}>;
|
||||
default_query: ReportingQuery;
|
||||
visualization: {
|
||||
kind: string;
|
||||
category_dimension?: string | null;
|
||||
series_dimension?: string | null;
|
||||
measures: string[];
|
||||
tabular_fallback: boolean;
|
||||
};
|
||||
institutional_references: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type ReportExecution = {
|
||||
execution_id: string;
|
||||
report_id: string;
|
||||
report_revision: number;
|
||||
semantic_model_id: string;
|
||||
semantic_model_revision: number;
|
||||
dataset_id: string;
|
||||
dataset_revision: number;
|
||||
status: "running" | "succeeded" | "failed";
|
||||
parameters: Record<string, unknown>;
|
||||
query: ReportingQuery;
|
||||
definition_hashes: Record<string, string>;
|
||||
source_fingerprints: Array<Record<string, unknown>>;
|
||||
output_hash?: string | null;
|
||||
executor_version?: string | null;
|
||||
schema: Array<{ name: string; type: string }>;
|
||||
rows: Array<Record<string, unknown>>;
|
||||
total_rows: number;
|
||||
truncated: boolean;
|
||||
diagnostics: Array<{ severity?: string; code?: string; message?: string }>;
|
||||
provenance: Record<string, unknown>;
|
||||
started_at: string;
|
||||
finished_at?: string | null;
|
||||
visualization?: {
|
||||
kind: string;
|
||||
requested_kind?: string;
|
||||
category?: string | null;
|
||||
series?: string | null;
|
||||
measures?: string[];
|
||||
options?: Record<string, unknown>;
|
||||
fallback_reason?: string | null;
|
||||
};
|
||||
delivery_authorization?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ReportingDrillContext = {
|
||||
token: string;
|
||||
drill_context_id: string;
|
||||
execution_id: string;
|
||||
dimension_path: Array<{ dimension: string; label: string; value: unknown }>;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
export type ReportingDrillResult = Omit<ReportingDrillContext, "token"> & {
|
||||
rows: Array<Record<string, unknown>>;
|
||||
schema: Array<{ name: string; type: string }>;
|
||||
total_rows: number;
|
||||
truncated: boolean;
|
||||
source_fingerprints: Array<Record<string, unknown>>;
|
||||
policy_provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ReportingSchedule = {
|
||||
schedule_id: string;
|
||||
report_id: string;
|
||||
report_revision: number;
|
||||
name: string;
|
||||
revision: number;
|
||||
trigger_kind: "scheduled" | "interval";
|
||||
trigger_config: Record<string, unknown>;
|
||||
parameters: Record<string, unknown>;
|
||||
query: ReportingQuery;
|
||||
publication_target: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
next_run_at?: string | null;
|
||||
last_run_at?: string | null;
|
||||
last_execution_id?: string | null;
|
||||
};
|
||||
|
||||
export type ReportingPublicationTarget = {
|
||||
capability: string;
|
||||
label: string;
|
||||
available: boolean;
|
||||
reason?: string | null;
|
||||
formats: string[];
|
||||
target_label: string;
|
||||
target_required: boolean;
|
||||
required_options: string[];
|
||||
};
|
||||
|
||||
export type ReportingPublication = {
|
||||
publication_id: string;
|
||||
execution_id: string;
|
||||
target_capability: string;
|
||||
target_ref?: string | null;
|
||||
format: string;
|
||||
status: string;
|
||||
evidence: Record<string, unknown>;
|
||||
error?: string | null;
|
||||
completed_at?: string | null;
|
||||
};
|
||||
|
||||
export type ReportingSavedView = {
|
||||
view_id: string;
|
||||
report_id: string;
|
||||
report_revision: number;
|
||||
name: string;
|
||||
revision: number;
|
||||
state: { query?: ReportingQuery };
|
||||
shared: boolean;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ProviderReportParameter = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
description?: string | null;
|
||||
options_from_provider: boolean;
|
||||
};
|
||||
|
||||
export type ProviderReportField = {
|
||||
path: string;
|
||||
label: string;
|
||||
type: string;
|
||||
group: string;
|
||||
nullable: boolean;
|
||||
sensitive: boolean;
|
||||
};
|
||||
|
||||
export type ProviderReportDescriptor = {
|
||||
contract_version: string;
|
||||
provider_id: string;
|
||||
report_id: string;
|
||||
revision: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
parameters: ProviderReportParameter[];
|
||||
result_schema: ProviderReportField[];
|
||||
privacy_transforms: Array<{ id: string; label: string; required: boolean }>;
|
||||
purpose_required: boolean;
|
||||
audience_scope_required: boolean;
|
||||
retention_class: string;
|
||||
export_formats: Array<"json" | "csv">;
|
||||
reidentification_risk: "low" | "moderate" | "high";
|
||||
presentation: { kind?: string };
|
||||
available: boolean;
|
||||
unavailable_reason?: string | null;
|
||||
governance: {
|
||||
retention_days?: number | null;
|
||||
export_formats: string[];
|
||||
required_privacy_transforms: string[];
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ProviderReportExecution = {
|
||||
execution_id: string;
|
||||
provider_id: string;
|
||||
report_id: string;
|
||||
report_revision: string;
|
||||
contract_version: string;
|
||||
purpose: string;
|
||||
audience_scope: Record<string, unknown>;
|
||||
parameters: Record<string, unknown>;
|
||||
result_schema: ProviderReportField[];
|
||||
result: Record<string, unknown>;
|
||||
source_revisions: Array<Record<string, unknown>>;
|
||||
effective_scope: Record<string, unknown>;
|
||||
privacy_transforms: string[];
|
||||
provenance: Record<string, unknown>;
|
||||
governance_provenance: Record<string, unknown>;
|
||||
retention_class: string;
|
||||
retention_days?: number | null;
|
||||
expires_at?: string | null;
|
||||
output_hash: string;
|
||||
generated_at: string;
|
||||
actor_id?: string | null;
|
||||
};
|
||||
|
||||
export function listDefinitions(
|
||||
settings: ApiSettings,
|
||||
options: { kinds?: ReportingDefinitionKind[]; status?: string[]; query?: string; limit?: number },
|
||||
signal?: AbortSignal
|
||||
): Promise<{ definitions: ReportingDefinition[]; total: number }> {
|
||||
return apiFetch(settings, apiPath("/api/v1/reporting/definitions", {
|
||||
definition_kind: options.kinds,
|
||||
status: options.status,
|
||||
query: options.query,
|
||||
limit: options.limit ?? 200
|
||||
}), { signal });
|
||||
}
|
||||
|
||||
export function listProviderReports(
|
||||
settings: ApiSettings,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ reports: ProviderReportDescriptor[]; diagnostics: Array<Record<string, string>> }> {
|
||||
return apiFetch(settings, "/api/v1/reporting/provider-reports", { signal });
|
||||
}
|
||||
|
||||
export function listProviderParameterOptions(
|
||||
settings: ApiSettings,
|
||||
report: ProviderReportDescriptor,
|
||||
parameterKey: string,
|
||||
query = "",
|
||||
signal?: AbortSignal
|
||||
): Promise<{ options: Array<{ value: string; label: string; description?: string | null }> }> {
|
||||
return apiFetch(settings, apiPath(
|
||||
`/api/v1/reporting/provider-reports/${encodeURIComponent(report.provider_id)}/${encodeURIComponent(report.report_id)}/parameters/${encodeURIComponent(parameterKey)}/options`,
|
||||
{ query, limit: 200 }
|
||||
), { signal });
|
||||
}
|
||||
|
||||
export function runProviderReport(
|
||||
settings: ApiSettings,
|
||||
report: ProviderReportDescriptor,
|
||||
parameters: Record<string, unknown>,
|
||||
purpose: string,
|
||||
audienceScope: Record<string, unknown>
|
||||
): Promise<ProviderReportExecution> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/reporting/provider-reports/${encodeURIComponent(report.provider_id)}/${encodeURIComponent(report.report_id)}/executions`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
parameters,
|
||||
purpose,
|
||||
audience_scope: audienceScope,
|
||||
idempotency_key: crypto.randomUUID()
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadProviderExecution(
|
||||
settings: ApiSettings,
|
||||
execution: ProviderReportExecution,
|
||||
format: "json" | "csv",
|
||||
purpose: string,
|
||||
audienceScope: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
apiUrl(settings, `/api/v1/reporting/provider-executions/${encodeURIComponent(execution.execution_id)}/exports`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...authHeaders(settings), "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ format, purpose, audience_scope: audienceScope })
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const blob = await response.blob();
|
||||
const disposition = response.headers.get("Content-Disposition") ?? "";
|
||||
const filename = disposition.match(/filename="?([^";]+)"?/)?.[1] ?? `provider-report.${format}`;
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
export function getDefinition(
|
||||
settings: ApiSettings,
|
||||
kind: ReportingDefinitionKind,
|
||||
id: string,
|
||||
revision?: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<ReportingDefinition> {
|
||||
return apiFetch(settings, apiPath(
|
||||
`/api/v1/reporting/definitions/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`,
|
||||
{ revision }
|
||||
), { signal });
|
||||
}
|
||||
|
||||
export function runReport(
|
||||
settings: ApiSettings,
|
||||
report: ReportingDefinition,
|
||||
query: ReportingQuery,
|
||||
parameters: Record<string, unknown>
|
||||
): Promise<ReportExecution> {
|
||||
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(report.definition_id)}/executions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
report_revision: report.revision,
|
||||
parameters,
|
||||
query,
|
||||
idempotency_key: crypto.randomUUID()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function listExecutions(
|
||||
settings: ApiSettings,
|
||||
reportId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ executions: ReportExecution[] }> {
|
||||
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(reportId)}/executions?limit=30`, { signal });
|
||||
}
|
||||
|
||||
export function createDrillContext(
|
||||
settings: ApiSettings,
|
||||
executionId: string,
|
||||
aggregateRow: Record<string, unknown>,
|
||||
limit = 200
|
||||
): Promise<ReportingDrillContext> {
|
||||
return apiFetch(settings, `/api/v1/reporting/executions/${encodeURIComponent(executionId)}/drill-contexts`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ aggregate_row: aggregateRow, limit })
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDrillContext(
|
||||
settings: ApiSettings,
|
||||
token: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ReportingDrillResult> {
|
||||
return apiFetch(settings, `/api/v1/reporting/drill-contexts/${encodeURIComponent(token)}`, { signal });
|
||||
}
|
||||
|
||||
export function listSavedViews(
|
||||
settings: ApiSettings,
|
||||
reportId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ views: ReportingSavedView[] }> {
|
||||
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(reportId)}/saved-views`, { signal });
|
||||
}
|
||||
|
||||
export function saveView(
|
||||
settings: ApiSettings,
|
||||
report: ReportingDefinition,
|
||||
name: string,
|
||||
query: ReportingQuery
|
||||
): Promise<ReportingSavedView> {
|
||||
const viewId = crypto.randomUUID();
|
||||
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(report.definition_id)}/saved-views/${viewId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
view_id: viewId,
|
||||
report_revision: report.revision,
|
||||
name,
|
||||
state: { query },
|
||||
shared: false,
|
||||
access: {},
|
||||
expected_revision: null
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function createIntervalSchedule(
|
||||
settings: ApiSettings,
|
||||
report: ReportingDefinition,
|
||||
name: string,
|
||||
seconds: number,
|
||||
query: ReportingQuery,
|
||||
parameters: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const scheduleId = crypto.randomUUID();
|
||||
return apiFetch(settings, `/api/v1/reporting/schedules/${scheduleId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
schedule_id: scheduleId,
|
||||
report_id: report.definition_id,
|
||||
report_revision: report.revision,
|
||||
name,
|
||||
trigger_kind: "interval",
|
||||
trigger_config: { seconds },
|
||||
parameters,
|
||||
query,
|
||||
publication_target: {},
|
||||
enabled: true,
|
||||
next_run_at: null,
|
||||
expected_revision: null
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function listSchedules(
|
||||
settings: ApiSettings,
|
||||
reportId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ schedules: ReportingSchedule[] }> {
|
||||
return apiFetch(settings, apiPath("/api/v1/reporting/schedules", { report_id: reportId }), { signal });
|
||||
}
|
||||
|
||||
export function updateSchedule(
|
||||
settings: ApiSettings,
|
||||
schedule: ReportingSchedule,
|
||||
changes: Partial<Pick<ReportingSchedule, "enabled" | "name">>
|
||||
): Promise<ReportingSchedule> {
|
||||
return apiFetch(settings, `/api/v1/reporting/schedules/${encodeURIComponent(schedule.schedule_id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
schedule_id: schedule.schedule_id,
|
||||
report_id: schedule.report_id,
|
||||
report_revision: schedule.report_revision,
|
||||
name: changes.name ?? schedule.name,
|
||||
trigger_kind: schedule.trigger_kind,
|
||||
trigger_config: schedule.trigger_config,
|
||||
parameters: schedule.parameters,
|
||||
query: schedule.query,
|
||||
publication_target: schedule.publication_target,
|
||||
enabled: changes.enabled ?? schedule.enabled,
|
||||
next_run_at: schedule.next_run_at ?? null,
|
||||
expected_revision: schedule.revision
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function listPublicationTargets(
|
||||
settings: ApiSettings,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ targets: ReportingPublicationTarget[] }> {
|
||||
return apiFetch(settings, "/api/v1/reporting/publication-targets", { signal });
|
||||
}
|
||||
|
||||
export function listPublications(
|
||||
settings: ApiSettings,
|
||||
executionId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ publications: ReportingPublication[] }> {
|
||||
return apiFetch(settings, apiPath("/api/v1/reporting/publications", { execution_id: executionId }), { signal });
|
||||
}
|
||||
|
||||
export function publishExecution(
|
||||
settings: ApiSettings,
|
||||
executionId: string,
|
||||
request: {
|
||||
target_capability: string;
|
||||
target_ref?: string | null;
|
||||
format: string;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
): Promise<ReportingPublication> {
|
||||
return apiFetch(settings, `/api/v1/reporting/executions/${encodeURIComponent(executionId)}/publications`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...request,
|
||||
idempotency_key: crypto.randomUUID()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadExecution(
|
||||
settings: ApiSettings,
|
||||
executionId: string,
|
||||
format: "csv" | "json"
|
||||
): Promise<void> {
|
||||
const response = await fetch(apiUrl(settings, apiPath(
|
||||
`/api/v1/reporting/executions/${encodeURIComponent(executionId)}/export`,
|
||||
{ format }
|
||||
)), {
|
||||
headers: authHeaders(settings),
|
||||
credentials: "include"
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text() || "The report export failed.");
|
||||
const blob = await response.blob();
|
||||
const disposition = response.headers.get("content-disposition") ?? "";
|
||||
const filename = disposition.match(/filename="?([^";]+)"?/i)?.[1] ?? `report.${format}`;
|
||||
const href = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = href;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(href);
|
||||
}
|
||||
|
||||
export function reportPayload(definition: ReportingDefinition): ReportPayload {
|
||||
return definition.payload as unknown as ReportPayload;
|
||||
}
|
||||
|
||||
export function semanticPayload(definition: ReportingDefinition): SemanticModelPayload {
|
||||
return definition.payload as unknown as SemanticModelPayload;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { Download, FileJson, Play, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DismissibleAlert,
|
||||
IconButton,
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
downloadProviderExecution,
|
||||
listProviderParameterOptions,
|
||||
runProviderReport,
|
||||
type ProviderReportDescriptor,
|
||||
type ProviderReportExecution,
|
||||
type ProviderReportField
|
||||
} from "../../api/reporting";
|
||||
|
||||
|
||||
export function ProviderReportWorkspace({ settings, auth, report }: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
report: ProviderReportDescriptor;
|
||||
}) {
|
||||
const [parameters, setParameters] = useState<Record<string, unknown>>({});
|
||||
const [options, setOptions] = useState<Record<string, Array<{ value: string; label: string; description?: string | null }>>>({});
|
||||
const [purpose, setPurpose] = useState("");
|
||||
const [execution, setExecution] = useState<ProviderReportExecution | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const tenant = auth.active_tenant ?? auth.tenant;
|
||||
const canRun = hasScope(auth, "reporting:report:run");
|
||||
const audienceScope = useMemo(() => ({
|
||||
scope_type: "tenant",
|
||||
scope_id: tenant.id,
|
||||
label: tenant.name
|
||||
}), [tenant.id, tenant.name]);
|
||||
|
||||
useEffect(() => {
|
||||
setParameters({});
|
||||
setPurpose("");
|
||||
setExecution(null);
|
||||
setError("");
|
||||
const controller = new AbortController();
|
||||
const optionParameters = report.parameters.filter((item) => item.options_from_provider);
|
||||
void Promise.all(optionParameters.map(async (parameter) => {
|
||||
const result = await listProviderParameterOptions(
|
||||
settings,
|
||||
report,
|
||||
parameter.key,
|
||||
"",
|
||||
controller.signal
|
||||
);
|
||||
return [parameter.key, result.options] as const;
|
||||
})).then((entries) => {
|
||||
setOptions(Object.fromEntries(entries));
|
||||
setParameters(Object.fromEntries(entries.flatMap(([key, values]) =>
|
||||
values[0] ? [[key, values[0].value]] : []
|
||||
)));
|
||||
}).catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") setError(message(reason));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [settings, report.provider_id, report.report_id, report.revision]);
|
||||
|
||||
async function run() {
|
||||
setRunning(true);
|
||||
setError("");
|
||||
try {
|
||||
setExecution(await runProviderReport(
|
||||
settings,
|
||||
report,
|
||||
parameters,
|
||||
purpose.trim(),
|
||||
audienceScope
|
||||
));
|
||||
} catch (reason) {
|
||||
setError(message(reason));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function download(format: "json" | "csv") {
|
||||
if (!execution) return;
|
||||
try {
|
||||
await downloadProviderExecution(
|
||||
settings,
|
||||
execution,
|
||||
format,
|
||||
purpose.trim(),
|
||||
audienceScope
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(message(reason));
|
||||
}
|
||||
}
|
||||
|
||||
const missingRequired = report.parameters.some((item) =>
|
||||
item.required && (parameters[item.key] === undefined || parameters[item.key] === "")
|
||||
);
|
||||
const runDisabledReason = !canRun
|
||||
? "Report run permission is required."
|
||||
: !report.available
|
||||
? report.unavailable_reason ?? "Policy does not allow this report."
|
||||
: missingRequired
|
||||
? "Complete the required report parameters."
|
||||
: !purpose.trim()
|
||||
? "Record the purpose for this governed report run."
|
||||
: undefined;
|
||||
return (
|
||||
<>
|
||||
<header className="reporting-result-header">
|
||||
<div className="reporting-title">
|
||||
<span>{report.provider_id} · {report.revision}</span>
|
||||
<h1>{report.title}</h1>
|
||||
<p>{report.summary}</p>
|
||||
</div>
|
||||
<div className="reporting-run-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void run()}
|
||||
disabled={running || Boolean(runDisabledReason)}
|
||||
disabledReason={runDisabledReason}>
|
||||
<Play size={16} aria-hidden="true" /> {running ? "Running" : "Run"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{!report.available &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
{report.unavailable_reason ?? "Policy does not allow this report."}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
<div className="reporting-query-controls reporting-provider-controls">
|
||||
{report.parameters.map((parameter) =>
|
||||
<label className="reporting-parameter" key={parameter.key}>
|
||||
<span>{parameter.label}{parameter.required ? " *" : ""}</span>
|
||||
{parameter.options_from_provider ?
|
||||
<select
|
||||
value={String(parameters[parameter.key] ?? "")}
|
||||
onChange={(event) => setParameters((current) => ({ ...current, [parameter.key]: event.target.value }))}>
|
||||
{!parameter.required && <option value="">Current/default</option>}
|
||||
{(options[parameter.key] ?? []).map((item) =>
|
||||
<option value={item.value} key={item.value}>{item.label}{item.description ? ` · ${item.description}` : ""}</option>
|
||||
)}
|
||||
</select> :
|
||||
<input
|
||||
type={parameter.type === "integer" || parameter.type === "number" ? "number" : parameter.type === "date" ? "date" : "text"}
|
||||
value={String(parameters[parameter.key] ?? "")}
|
||||
onChange={(event) => setParameters((current) => ({ ...current, [parameter.key]: event.target.value }))}
|
||||
/>
|
||||
}
|
||||
{parameter.description && <small>{parameter.description}</small>}
|
||||
</label>
|
||||
)}
|
||||
<label className="reporting-parameter reporting-provider-purpose">
|
||||
<span>Purpose *</span>
|
||||
<input
|
||||
value={purpose}
|
||||
maxLength={1000}
|
||||
onChange={(event) => setPurpose(event.target.value)}
|
||||
placeholder="Purpose recorded with this execution"
|
||||
/>
|
||||
</label>
|
||||
<label className="reporting-parameter">
|
||||
<span>Effective audience</span>
|
||||
<input value={tenant.name} readOnly />
|
||||
</label>
|
||||
</div>
|
||||
{execution ?
|
||||
<>
|
||||
<div className="reporting-output-toolbar">
|
||||
<span>Generated {formatDateTime(execution.generated_at)}</span>
|
||||
{report.export_formats.includes("csv") &&
|
||||
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void download("csv")} />
|
||||
}
|
||||
{report.export_formats.includes("json") &&
|
||||
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void download("json")} />
|
||||
}
|
||||
</div>
|
||||
<div className="reporting-provider-output">
|
||||
<ProviderResult execution={execution} />
|
||||
</div>
|
||||
</> :
|
||||
<div className="reporting-empty">Select the parameters and run this governed report.</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function ProviderReportInspector({ report }: { report: ProviderReportDescriptor }) {
|
||||
return (
|
||||
<div className="reporting-inspector-content">
|
||||
<section className="reporting-provenance">
|
||||
<h2><ShieldCheck size={16} /> Governance</h2>
|
||||
<dl>
|
||||
<dt>Risk</dt><dd><StatusBadge status={report.reidentification_risk === "high" ? "warning" : "active"} label={humanize(report.reidentification_risk)} /></dd>
|
||||
<dt>Retention</dt><dd>{report.governance.retention_days == null ? "Policy managed" : `${report.governance.retention_days} days`}</dd>
|
||||
<dt>Exports</dt><dd>{report.governance.export_formats.join(", ") || "Disabled"}</dd>
|
||||
<dt>Contract</dt><dd>{report.contract_version}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Privacy transforms</h2>
|
||||
{report.privacy_transforms.map((item) =>
|
||||
<p key={item.id}>{item.label}{item.required ? " · required" : ""}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ProviderResult({ execution }: { execution: ProviderReportExecution }) {
|
||||
const groups = groupFields(execution.result_schema);
|
||||
return (
|
||||
<>
|
||||
{[...groups].map(([group, fields]) => {
|
||||
const metrics = fields.filter((field) => field.type === "suppressed_count");
|
||||
const details = fields.filter((field) => field.type !== "suppressed_count");
|
||||
return (
|
||||
<Card title={group} key={group}>
|
||||
{metrics.length > 0 &&
|
||||
<div className="dashboard-grid reporting-provider-metrics">
|
||||
{metrics.map((field) =>
|
||||
<MetricCard key={field.path} label={field.label} value={displayValue(pathValue(execution.result, field.path))} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
{details.length > 0 &&
|
||||
<dl className="detail-list">
|
||||
{details.map((field) =>
|
||||
<div key={field.path}>
|
||||
<dt>{field.label}</dt>
|
||||
<dd>{displayValue(pathValue(execution.result, field.path), field)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<Card title="Provenance">
|
||||
<dl className="detail-list">
|
||||
<div><dt>Purpose</dt><dd>{execution.purpose}</dd></div>
|
||||
<div><dt>Output hash</dt><dd title={execution.output_hash}>{shortHash(execution.output_hash)}</dd></div>
|
||||
<div><dt>Source revisions</dt><dd>{execution.source_revisions.length}</dd></div>
|
||||
<div><dt>Privacy transforms</dt><dd>{execution.privacy_transforms.join(", ")}</dd></div>
|
||||
<div><dt>Expires</dt><dd>{execution.expires_at ? formatDateTime(execution.expires_at) : "Policy managed"}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function groupFields(fields: ProviderReportField[]): Map<string, ProviderReportField[]> {
|
||||
const groups = new Map<string, ProviderReportField[]>();
|
||||
for (const field of fields) groups.set(field.group, [...(groups.get(field.group) ?? []), field]);
|
||||
return groups;
|
||||
}
|
||||
|
||||
function pathValue(payload: Record<string, unknown>, path: string): unknown {
|
||||
let value: unknown = payload;
|
||||
for (const part of path.split(".")) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
value = (value as Record<string, unknown>)[part];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function displayValue(value: unknown, field?: ProviderReportField): string | number {
|
||||
if (value && typeof value === "object") {
|
||||
const count = value as { value?: unknown; suppressed?: boolean };
|
||||
if (count.suppressed) return "Suppressed";
|
||||
if ("value" in count) return displayValue(count.value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
if (field?.type === "datetime" || field?.type === "date") return formatDateTime(String(value));
|
||||
if (typeof value === "number") return new Intl.NumberFormat().format(value);
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.valueOf()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return `${value.slice(0, 10)}…${value.slice(-6)}`;
|
||||
}
|
||||
|
||||
function message(reason: unknown): string {
|
||||
return reason instanceof Error ? reason.message : "The provider report could not be loaded.";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
import { useCallback } from "react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
DashboardWidgetList,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
StatusBadge,
|
||||
useDashboardWidgetData,
|
||||
type ApiSettings,
|
||||
type DashboardWidgetConfiguration
|
||||
} from "@govoplan/core-webui";
|
||||
import { listDefinitions } from "../../api/reporting";
|
||||
|
||||
|
||||
export default function ReportingReportsWidget({ settings, refreshKey, configuration }: {
|
||||
settings: ApiSettings;
|
||||
refreshKey: number;
|
||||
configuration: DashboardWidgetConfiguration;
|
||||
}) {
|
||||
const maxItems = boundedNumber(configuration.maxItems, 5, 1, 12);
|
||||
const load = useCallback(async () => {
|
||||
const result = await listDefinitions(settings, {
|
||||
kinds: ["report"],
|
||||
status: ["active"],
|
||||
limit: maxItems
|
||||
});
|
||||
return result.definitions.slice(0, maxItems);
|
||||
}, [maxItems, settings]);
|
||||
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
|
||||
return (
|
||||
<LoadingFrame loading={loading} label="Loading reports">
|
||||
{error && <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<DashboardWidgetList
|
||||
emptyText="No active reports are available."
|
||||
items={(data ?? []).map((report) => ({
|
||||
id: report.definition_id,
|
||||
title: report.name,
|
||||
detail: report.description || report.definition_key,
|
||||
meta: `Revision ${report.revision}`,
|
||||
leading: <BarChart3 size={17} aria-hidden="true" />,
|
||||
trailing: <StatusBadge status={report.status} label={report.status} />,
|
||||
to: "/reports"
|
||||
}))}
|
||||
/>
|
||||
<div className="dashboard-contribution-footer">
|
||||
<Link className="btn btn-secondary" to="/reports">Open reporting</Link>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function boundedNumber(value: unknown, fallback: number, minimum: number, maximum: number): number {
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numeric) ? Math.max(minimum, Math.min(maximum, Math.round(numeric))) : fallback;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, reportingModule } from "./module";
|
||||
export * from "./api/reporting";
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { DashboardWidgetsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import ReportingReportsWidget from "./features/reporting/ReportingReportsWidget";
|
||||
import "./styles/reporting.css";
|
||||
|
||||
|
||||
const ReportingPage = lazy(() => import("./features/reporting/ReportingPage"));
|
||||
|
||||
const reportingDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
widgets: [
|
||||
{
|
||||
id: "reporting.reports",
|
||||
surfaceId: "reporting.widget.reports",
|
||||
title: "Reports",
|
||||
description: "Active governed reports available in the current scope.",
|
||||
moduleId: "reporting",
|
||||
category: "Analysis",
|
||||
order: 75,
|
||||
defaultVisible: false,
|
||||
defaultSize: "medium",
|
||||
supportedSizes: ["medium", "wide"],
|
||||
anyOf: ["reporting:definition:read"],
|
||||
refreshIntervalMs: 60_000,
|
||||
defaultConfiguration: { maxItems: 5 },
|
||||
configurationFields: [
|
||||
{
|
||||
id: "maxItems",
|
||||
label: "Maximum reports",
|
||||
kind: "number",
|
||||
min: 1,
|
||||
max: 12,
|
||||
step: 1,
|
||||
required: true
|
||||
}
|
||||
],
|
||||
render: ({ settings, refreshKey, configuration }) => createElement(
|
||||
ReportingReportsWidget,
|
||||
{ settings, refreshKey, configuration }
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const reportingModule: PlatformWebModule = {
|
||||
id: "reporting",
|
||||
label: "Reporting",
|
||||
version: "0.1.14",
|
||||
optionalDependencies: [
|
||||
"dataflow",
|
||||
"datasources",
|
||||
"connectors",
|
||||
"dashboard",
|
||||
"files",
|
||||
"mail",
|
||||
"templates",
|
||||
"workflow_engine",
|
||||
"policy",
|
||||
"search",
|
||||
"notifications"
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: "/reports",
|
||||
anyOf: ["reporting:definition:read"],
|
||||
order: 74,
|
||||
surfaceId: "reporting.workspace",
|
||||
render: (context) => createElement(ReportingPage, context)
|
||||
},
|
||||
{
|
||||
path: "/reporting",
|
||||
anyOf: ["reporting:definition:read"],
|
||||
order: 175,
|
||||
surfaceId: "reporting.compatibility",
|
||||
render: (context) => createElement(ReportingPage, context)
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/reports",
|
||||
label: "Reporting",
|
||||
iconName: "clipboard-pen-line",
|
||||
anyOf: ["reporting:definition:read"],
|
||||
order: 74,
|
||||
surfaceId: "reporting.navigation"
|
||||
}
|
||||
],
|
||||
viewSurfaces: [
|
||||
{ id: "reporting.navigation", moduleId: "reporting", kind: "navigation", label: "Reporting navigation", order: 10 },
|
||||
{ id: "reporting.workspace", moduleId: "reporting", kind: "route", label: "Reporting workspace", order: 20 },
|
||||
{ id: "reporting.parameters", moduleId: "reporting", kind: "section", label: "Report parameters and filters", parentId: "reporting.workspace", order: 30 },
|
||||
{ id: "reporting.results", moduleId: "reporting", kind: "section", label: "Authorized report results", parentId: "reporting.workspace", order: 40 },
|
||||
{ id: "reporting.widget.reports", moduleId: "reporting", kind: "section", label: "Reports dashboard widget", order: 75 }
|
||||
],
|
||||
uiCapabilities: {
|
||||
"dashboard.widgets": reportingDashboardWidgets
|
||||
}
|
||||
};
|
||||
|
||||
export default reportingModule;
|
||||
@@ -0,0 +1,653 @@
|
||||
.reporting-page,
|
||||
.reporting-shell {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reporting-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.reporting-toolbar,
|
||||
.reporting-result-header,
|
||||
.reporting-output-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.reporting-toolbar {
|
||||
min-height: 56px;
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
.reporting-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(440px, 46vw);
|
||||
}
|
||||
|
||||
.reporting-search input {
|
||||
min-width: 140px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reporting-count {
|
||||
margin-left: auto;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.reporting-workspace {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(250px, 20%) minmax(460px, 1fr) minmax(260px, 20%);
|
||||
}
|
||||
|
||||
.reporting-catalogue,
|
||||
.reporting-inspector {
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
background: var(--surface-subtle, var(--surface));
|
||||
}
|
||||
|
||||
.reporting-catalogue {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.reporting-inspector {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.reporting-report-list,
|
||||
.reporting-inspector-content section {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.reporting-list-heading {
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-subtle, var(--surface));
|
||||
color: var(--text-soft);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.reporting-report-row,
|
||||
.reporting-inspector-content section > button {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reporting-report-row {
|
||||
grid-template-columns: 22px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
min-height: 58px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.reporting-report-row:last-child,
|
||||
.reporting-inspector-content section > button:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.reporting-report-row:hover,
|
||||
.reporting-report-row:focus-visible,
|
||||
.reporting-report-row.is-selected,
|
||||
.reporting-inspector-content section > button:hover,
|
||||
.reporting-inspector-content section > button.is-selected {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.reporting-report-row.is-selected {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.reporting-report-row > span:nth-child(2) {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.reporting-report-row strong,
|
||||
.reporting-report-row small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reporting-report-row small,
|
||||
.reporting-inspector-content small,
|
||||
.reporting-inspector-content p {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.reporting-result-region {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.reporting-result-header {
|
||||
min-height: 62px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.reporting-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reporting-title span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.reporting-title h1 {
|
||||
overflow: hidden;
|
||||
margin: 2px 0 0;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reporting-title p {
|
||||
max-width: 70ch;
|
||||
margin: 3px 0 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.76rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reporting-run-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.reporting-query-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 66px;
|
||||
padding: 8px 14px;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-subtle, var(--surface));
|
||||
}
|
||||
|
||||
.reporting-query-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.reporting-query-controls fieldset {
|
||||
min-width: 170px;
|
||||
margin: 0;
|
||||
padding: 4px 8px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.reporting-query-controls legend,
|
||||
.reporting-parameter > span,
|
||||
.reporting-dialog-field > span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.reporting-option-list {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
max-width: 340px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.reporting-option-list label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reporting-parameter,
|
||||
.reporting-dialog-field {
|
||||
display: flex;
|
||||
min-width: 150px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reporting-provider-controls {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.reporting-provider-purpose {
|
||||
min-width: min(320px, 35vw);
|
||||
}
|
||||
|
||||
.reporting-output-toolbar {
|
||||
min-height: 48px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.reporting-output-toolbar > span {
|
||||
margin-left: auto;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.reporting-output {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.reporting-provider-output {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.reporting-provider-metrics {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reporting-output .data-grid-shell {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.reporting-empty {
|
||||
padding: 38px 12px;
|
||||
color: var(--text-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reporting-inspector-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reporting-inspector-content section h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.reporting-inspector-content section > p {
|
||||
margin: 0;
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.reporting-inspector-content section > button {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 5px 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.reporting-inspector-content section > button small {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.reporting-provenance {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.reporting-provenance dl {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, auto) minmax(0, 1fr);
|
||||
gap: 6px 8px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.reporting-provenance dt {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.reporting-provenance dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.reporting-provenance .alert {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.reporting-bar-chart {
|
||||
display: flex;
|
||||
min-width: 420px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.reporting-chart-stack,
|
||||
.reporting-line-chart,
|
||||
.reporting-metric-grid,
|
||||
.reporting-pie-layout {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reporting-column-chart {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
min-height: 280px;
|
||||
padding: 18px 12px 0;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.reporting-column {
|
||||
display: grid;
|
||||
grid-template-rows: 24px minmax(180px, 1fr) 32px;
|
||||
align-items: end;
|
||||
min-width: 54px;
|
||||
flex: 1 0 54px;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reporting-column strong,
|
||||
.reporting-column span {
|
||||
overflow: hidden;
|
||||
font-size: 0.72rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reporting-column i {
|
||||
display: block;
|
||||
width: 72%;
|
||||
margin: 0 auto;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.reporting-line-chart svg {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
overflow: visible;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.reporting-chart-line {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 3;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.reporting-chart-area {
|
||||
fill: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
}
|
||||
|
||||
.reporting-chart-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.reporting-metric-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.reporting-metric {
|
||||
display: flex;
|
||||
min-height: 92px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.reporting-metric span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.reporting-metric strong {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.reporting-pie-layout {
|
||||
grid-template-columns: minmax(180px, 300px) minmax(240px, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reporting-pie {
|
||||
width: min(100%, 280px);
|
||||
aspect-ratio: 1;
|
||||
margin: 0 auto;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
}
|
||||
|
||||
.reporting-pie.is-donut {
|
||||
border: 58px solid var(--surface-raised);
|
||||
}
|
||||
|
||||
.reporting-pie-layout ol {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.reporting-pie-layout li {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reporting-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: #2f7d6e;
|
||||
}
|
||||
|
||||
.reporting-swatch-1 { background: #3366a8; }
|
||||
.reporting-swatch-2 { background: #c28b2c; }
|
||||
.reporting-swatch-3 { background: #9a4f71; }
|
||||
.reporting-swatch-4 { background: #5f7f3a; }
|
||||
.reporting-swatch-5 { background: #b85c3b; }
|
||||
.reporting-swatch-6 { background: #586176; }
|
||||
.reporting-swatch-7 { background: #2e8b9a; }
|
||||
|
||||
.reporting-bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 22%) minmax(180px, 1fr) minmax(80px, auto);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.reporting-bar-row > div {
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-subtle, var(--surface));
|
||||
}
|
||||
|
||||
.reporting-bar-row i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.reporting-chart-table {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.reporting-inspector-toggle,
|
||||
.reporting-inspector-record {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.reporting-inspector-toggle > span,
|
||||
.reporting-inspector-record > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.reporting-inspector-toggle small,
|
||||
.reporting-inspector-record small {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.reporting-inspector-toggle .toggle-switch-copy {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reporting-access-explanation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 8px;
|
||||
padding: 9px 10px;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--hover-bg);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.reporting-dialog-span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.reporting-drill-dialog {
|
||||
width: min(1100px, calc(100vw - 32px));
|
||||
height: min(760px, calc(100vh - 32px));
|
||||
}
|
||||
|
||||
.reporting-drill-dialog .dialog-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reporting-drill-path {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.reporting-drill-path span {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-subtle, var(--surface));
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.reporting-drill-grid {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.reporting-dialog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.reporting-workspace {
|
||||
grid-template-columns: minmax(220px, 28%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.reporting-inspector {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.reporting-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.reporting-catalogue {
|
||||
max-height: 30vh;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.reporting-result-header,
|
||||
.reporting-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reporting-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.reporting-dialog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user