Compare commits
8
Commits
v0.1.7
...
aebe94ecc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aebe94ecc2 | ||
|
|
72e86fa87d | ||
|
|
8337aec19a | ||
|
|
a81151391b | ||
|
|
0abcc2455e | ||
|
|
eba35edd3c | ||
|
|
720959536b | ||
|
|
858f9831ad |
@@ -0,0 +1,209 @@
|
|||||||
|
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
|
||||||
|
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 }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
python - "$tag" <<'PY'
|
||||||
|
import fnmatch
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
tag = sys.argv[1]
|
||||||
|
repository = os.environ["GITEA_REPOSITORY"]
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{os.environ['GITEA_API_URL']}/repos/{repository}/tag_protections",
|
||||||
|
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
protections = json.load(response)
|
||||||
|
if not any(fnmatch.fnmatchcase(tag, item.get("name_pattern", "")) for item in protections):
|
||||||
|
raise SystemExit(f"Release tag {tag!r} is not covered by repository tag protection")
|
||||||
|
PY
|
||||||
|
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/${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: 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"
|
||||||
|
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
|
||||||
|
shopt -s nullglob
|
||||||
|
webui_packages=(dist/*.tgz)
|
||||||
|
if (( ${#webui_packages[@]} )); 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/
|
||||||
|
fi
|
||||||
@@ -1,12 +1,52 @@
|
|||||||
# GovOPlaN Reporting
|
# GovOPlaN Reporting
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (domain).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`govoplan-reporting` owns report definitions, BI-style views, dashboards,
|
`govoplan-reporting` owns report definitions, BI-style views, dashboards,
|
||||||
scheduled outputs, and report publication/export behavior. It is intentionally
|
scheduled outputs, and report publication/export behavior. It is intentionally
|
||||||
separate from reusable template rendering and from generic dataflow pipelines.
|
separate from reusable template rendering and from generic dataflow pipelines.
|
||||||
|
|
||||||
This repository is currently a tag-only scaffold. It should gain package
|
The module now provides an executable governed semantic-reporting vertical:
|
||||||
metadata and module manifests only after the first backend or WebUI slice is
|
|
||||||
designed.
|
- immutable, optimistic-concurrency guarded dataset, semantic-model, report,
|
||||||
|
and quality-plan revisions;
|
||||||
|
- exact source and definition pins, tenant isolation, normalized access
|
||||||
|
grants, row-policy handoff, freshness checks, and reconstructable run
|
||||||
|
provenance;
|
||||||
|
- safe dimensions, hierarchies, measures, typed calculations, filters,
|
||||||
|
detail/summary/pivot queries, parameterized PostgreSQL semantic plans, and
|
||||||
|
accessible chart models without executing arbitrary report SQL;
|
||||||
|
- quality gates, saved views, interval/scheduled runs, CSV/JSON export,
|
||||||
|
provider-neutral publication targets, and import activation assessments;
|
||||||
|
- a versioned, provider-neutral cross-module report contract with source-owned
|
||||||
|
authorization, declared result schemas, privacy transforms, effective scope,
|
||||||
|
source revisions, purpose, retention, export history, and audit provenance;
|
||||||
|
- a full-height Reporting workspace for running, inspecting, saving,
|
||||||
|
scheduling, visualizing, drilling into reauthorized contributors, publishing
|
||||||
|
through Files/Mail, and exporting authorized reports;
|
||||||
|
- a configurable Dashboard widget and explicit policy explanations for hidden
|
||||||
|
fields, rows, and actions.
|
||||||
|
|
||||||
|
Reporting consumes Dataflow outputs or provider-owned read models. It does not
|
||||||
|
read another module's ORM tables or take ownership of ingestion and
|
||||||
|
transformation.
|
||||||
|
|
||||||
|
The canonical global route is `/reports`. `/reporting` remains a
|
||||||
|
Reporting-owned compatibility route for saved links. Domain modules may keep
|
||||||
|
their own operational report routes, but do not register `/reports`.
|
||||||
|
|
||||||
See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary
|
See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary
|
||||||
decision.
|
decision. The behavior-level comparison with the supplied SuperX module set is
|
||||||
|
recorded in
|
||||||
|
[docs/SUPERX_CAPABILITY_ASSESSMENT.md](docs/SUPERX_CAPABILITY_ASSESSMENT.md).
|
||||||
|
|
||||||
|
Operational and recovery behavior is documented in
|
||||||
|
[docs/OPERATIONS.md](docs/OPERATIONS.md), while user and administrator tasks
|
||||||
|
are covered by [docs/USER_GUIDE.md](docs/USER_GUIDE.md) and
|
||||||
|
[docs/ADMIN_GUIDE.md](docs/ADMIN_GUIDE.md).
|
||||||
|
|
||||||
|
The Reporting route, workspace, state, consequence, and accessibility mapping
|
||||||
|
is recorded in
|
||||||
|
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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
|
- curated query/view inputs from modules, connectors, datasources, or
|
||||||
configuration packages
|
configuration packages
|
||||||
- report execution history, generated output evidence, and scheduled runs
|
- report execution history, generated output evidence, and scheduled runs
|
||||||
|
- institutional question, goal/obligation, owner, calculation-version,
|
||||||
|
freshness/quality, and decision/action-consumption references for each
|
||||||
|
material report or indicator
|
||||||
- report permissions and sharing rules
|
- report permissions and sharing rules
|
||||||
- export targets such as file, DMS, mail, API, RSS/Atom publication, and
|
- export targets such as file, DMS, mail, API, RSS/Atom publication, and
|
||||||
downstream connector handoff
|
downstream connector handoff
|
||||||
@@ -28,13 +31,27 @@ Initial source categories:
|
|||||||
- curated SQL/query views exposed through a controlled capability
|
- curated SQL/query views exposed through a controlled capability
|
||||||
- connector-staged datasets and external references
|
- connector-staged datasets and external references
|
||||||
- generated file/csv/xlsx inputs from files/connectors
|
- generated file/csv/xlsx inputs from files/connectors
|
||||||
- future `govoplan-datasources` catalog entries, if that module is created
|
- governed `govoplan-datasources` catalogue entries and immutable
|
||||||
- future `govoplan-dataflow` outputs, if that module is created
|
materializations
|
||||||
|
- versioned `govoplan-dataflow` outputs and run lineage
|
||||||
|
|
||||||
Reporting should not reach into module ORM internals directly. Module-owned
|
Reporting should not reach into module ORM internals directly. Module-owned
|
||||||
data must be exposed through capabilities, DTOs, events, read models, or
|
data must be exposed through capabilities, DTOs, events, read models, or
|
||||||
controlled query views.
|
controlled query views.
|
||||||
|
|
||||||
|
Every reproducible report definition should retain or resolve:
|
||||||
|
|
||||||
|
- the institutional question, obligation, goal, or service measure it serves;
|
||||||
|
- owner and responsible organization/function;
|
||||||
|
- datasource/materialization and Dataflow definition/run revisions;
|
||||||
|
- source freshness, quality state, known limitations, and calculation version;
|
||||||
|
- purpose, visibility, privacy, retention, and publication decisions;
|
||||||
|
- reports, decisions, controls, projects, or actions that consumed the result.
|
||||||
|
|
||||||
|
Reporting owns execution and presentation of the measure. Mandates, Services,
|
||||||
|
Projects, Risk Compliance, Decisions, and other domains retain ownership of
|
||||||
|
the referenced institutional concepts.
|
||||||
|
|
||||||
## Output Targets
|
## Output Targets
|
||||||
|
|
||||||
- dashboard/widget view
|
- dashboard/widget view
|
||||||
@@ -53,24 +70,104 @@ Reporting does not own:
|
|||||||
- reusable template rendering; that belongs to `govoplan-templates`
|
- reusable template rendering; that belongs to `govoplan-templates`
|
||||||
- DMS lifecycle, collaborative editing, legal hold, or records management
|
- DMS lifecycle, collaborative editing, legal hold, or records management
|
||||||
- raw file/blob storage and provider connectors
|
- raw file/blob storage and provider connectors
|
||||||
- general-purpose ingestion/transformation pipeline ownership unless a future
|
- ingestion, governed source identity, staging, or transformation pipeline
|
||||||
`govoplan-dataflow` module is justified
|
ownership; those belong to Connectors, Datasources, and Dataflow
|
||||||
- cross-module search indexing; that belongs to `govoplan-search`
|
- cross-module search indexing; that belongs to `govoplan-search`
|
||||||
|
|
||||||
## Candidate Capabilities
|
## Capability Contracts
|
||||||
|
|
||||||
- `reporting.catalog`
|
- `reporting.registry` owns immutable definition registration and lookup.
|
||||||
- `reporting.run`
|
- `reporting.runner` executes exact report graphs and returns governed result
|
||||||
- `reporting.schedules`
|
evidence.
|
||||||
- `reporting.exports`
|
- `reporting.scheduler` claims due schedules and preserves run/publication
|
||||||
- `reporting.readModels`
|
outcomes.
|
||||||
- `reporting.dashboardContributions`
|
- `reporting.chart_renderer` renders provider-neutral visual models with an
|
||||||
|
accessible table fallback.
|
||||||
|
- `reporting.publication.files` adapts immutable results to Core's
|
||||||
|
`files.artifact_store` boundary without importing Files internals.
|
||||||
|
- `reporting.publication.mail` adapts report notices to Core's
|
||||||
|
`mail.notificationDelivery` boundary without importing Mail internals.
|
||||||
|
- `reporting.read_model:*` capabilities can expose bounded source-owned rows.
|
||||||
|
- `reporting.publication_target:*` capabilities can accept immutable result
|
||||||
|
payloads without Reporting importing the target module.
|
||||||
|
|
||||||
## First Implementation Slice
|
### Cross-module report providers
|
||||||
|
|
||||||
1. Define manifest metadata, permissions, and capability names.
|
Source modules contribute aggregate or otherwise minimized reports through
|
||||||
2. Add report definition and parameter DTOs.
|
`reporting.report_provider.<provider-id>`. Core owns contract version `1.0` and
|
||||||
3. Add one read-only report source contract over module-provided summary DTOs.
|
the provider-neutral DTOs. A provider must declare stable report and revision
|
||||||
4. Add export target DTOs without implementing every target.
|
IDs, typed parameters, the complete result schema, privacy transforms,
|
||||||
5. Add tests that report consumers/producers use capability lookup rather than
|
retention class, export formats, and re-identification risk. It must enforce
|
||||||
direct imports.
|
source access before listing options or producing a result and must return
|
||||||
|
source revisions, effective tenant scope, applied transforms, generation time,
|
||||||
|
and bounded provenance.
|
||||||
|
|
||||||
|
Reporting discovers these capabilities by prefix. It validates descriptors,
|
||||||
|
rejects undeclared output fields, requires every mandatory transform, records
|
||||||
|
purpose and effective audience, and persists immutable execution and export
|
||||||
|
evidence. The optional `policy.reporting_governance` capability can tighten
|
||||||
|
retention, export formats, required transforms, and high-risk handling. If
|
||||||
|
Policy is absent, Reporting applies its restrictive built-in baseline and
|
||||||
|
denies high-risk provider reports.
|
||||||
|
|
||||||
|
This direction is intentionally one-way: source modules import only Core's
|
||||||
|
contract and remain usable when Reporting is absent. Reporting never imports
|
||||||
|
the source module or reads its ORM tables.
|
||||||
|
|
||||||
|
## Implemented Vertical
|
||||||
|
|
||||||
|
The first complete vertical persists datasets, semantic models, reports,
|
||||||
|
quality plans, grants, executions, saved views, schedules, publications,
|
||||||
|
quality results, and import assessments. Each definition update creates an
|
||||||
|
immutable revision and requires optimistic concurrency. Active child
|
||||||
|
definitions require the exact pinned parent revision to be active.
|
||||||
|
|
||||||
|
Execution resolves the report, semantic model, and dataset graph before
|
||||||
|
reading data. Static fixtures, Dataflow output, and provider-owned read models
|
||||||
|
share one bounded read contract. Source fingerprints, freshness, schema,
|
||||||
|
row-policy provenance, blocking quality plans, definition hashes, executor
|
||||||
|
version, output hash, diagnostics, and authorized rows are retained with the
|
||||||
|
execution. Failed runs also retain evidence.
|
||||||
|
|
||||||
|
The query layer deliberately implements a typed expression and semantic
|
||||||
|
query language rather than `eval`, arbitrary SQL, stored procedures, or
|
||||||
|
runtime scripts. PostgreSQL installations receive parameterized semantic plans
|
||||||
|
for filters, grouping, measures, calculated aggregates, sorting, and bounds;
|
||||||
|
other engines and pivots use the equivalent bounded runtime evaluator. It
|
||||||
|
supports detail, grouped summary, pivot, dimensions,
|
||||||
|
hierarchies, common aggregates, calculated measures, filters, sorting,
|
||||||
|
pagination, totals, and a provider-neutral visualization model. A saved chart
|
||||||
|
that is incompatible with an ad-hoc query degrades to its mandatory table
|
||||||
|
fallback instead of failing a valid report run.
|
||||||
|
|
||||||
|
Aggregate drill-through uses an expiring actor-bound context hash. Resolution
|
||||||
|
rechecks all definition and row-policy decisions, verifies the source
|
||||||
|
fingerprints against the original execution, preserves the complete dimension
|
||||||
|
path, and returns only authorized contributors.
|
||||||
|
|
||||||
|
Direct export supports UTF-8 CSV and JSON. CSV cells that spreadsheet software
|
||||||
|
could interpret as formulas are escaped. Additional formats and delivery
|
||||||
|
destinations use an optional publication capability and preserve idempotent
|
||||||
|
evidence. Import assessments classify exact, approximated, and unsupported
|
||||||
|
semantics and block activation until every approximation is accepted and no
|
||||||
|
unsupported executable behavior remains.
|
||||||
|
|
||||||
|
The WebUI uses the platform module loader and common controls. It exposes a
|
||||||
|
report catalogue, parameter and semantic-query controls, result visualization
|
||||||
|
and table views, accessible bar/column/line/area/pie/donut/metric charts,
|
||||||
|
drill-through, access explanations, history/provenance, saved views, schedule
|
||||||
|
management, Files/Mail publication management, downloads, and a Dashboard
|
||||||
|
widget contribution.
|
||||||
|
The global `/reports` route is owned only by Reporting. `/reporting` is a
|
||||||
|
documented compatibility path. Campaign's module-local aggregate view remains
|
||||||
|
at `/campaigns/reports`; when both modules are enabled, the same safe aggregate
|
||||||
|
projection is also contributed to the global catalogue through the provider
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## Remaining Product Depth
|
||||||
|
|
||||||
|
The architecture boundary and first operational vertical are implemented.
|
||||||
|
Further work is additive product depth: packaged domain report catalogues,
|
||||||
|
XLSX/PDF formatting through optional renderer providers, selector-backed Mail
|
||||||
|
profile configuration, external publication connectors, and target-environment
|
||||||
|
evidence for a maturity claim above `vertical_slice`.
|
||||||
|
|||||||
@@ -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,70 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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.14"
|
||||||
|
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.14",
|
||||||
|
"govoplan-access>=0.1.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[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.14"
|
||||||
@@ -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.14"
|
||||||
|
|
||||||
|
|
||||||
|
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. 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,573 @@
|
|||||||
|
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)
|
||||||
|
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_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,832 @@
|
|||||||
|
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.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 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_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.14",
|
||||||
|
"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.14",
|
||||||
|
"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