Compare commits
10
Commits
v0.1.5
...
aebe94ecc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aebe94ecc2 | ||
|
|
72e86fa87d | ||
|
|
8337aec19a | ||
|
|
a81151391b | ||
|
|
0abcc2455e | ||
|
|
eba35edd3c | ||
|
|
720959536b | ||
|
|
858f9831ad | ||
|
|
35a991f9a4 | ||
|
|
b8f385a374 |
@@ -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
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Local WebUI test/build scratch directories
|
||||
.component-test-build/
|
||||
.module-test-build/
|
||||
.policy-test-build/
|
||||
.template-preview-test-build/
|
||||
.import-test-build/
|
||||
webui/.component-test-build/
|
||||
webui/.module-test-build/
|
||||
webui/.policy-test-build/
|
||||
webui/.template-preview-test-build/
|
||||
webui/.import-test-build/
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# ---> VisualStudioCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
*.db
|
||||
|
||||
# GovOPlaN local runtime state
|
||||
runtime/
|
||||
|
||||
# GovOPlaN WebUI test output
|
||||
webui/.module-test-build/
|
||||
webui/.component-test-build/
|
||||
@@ -0,0 +1,52 @@
|
||||
# GovOPlaN Reporting
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-reporting` owns report definitions, BI-style views, dashboards,
|
||||
scheduled outputs, and report publication/export behavior. It is intentionally
|
||||
separate from reusable template rendering and from generic dataflow pipelines.
|
||||
|
||||
The module now provides an executable governed semantic-reporting vertical:
|
||||
|
||||
- immutable, optimistic-concurrency guarded dataset, semantic-model, report,
|
||||
and quality-plan revisions;
|
||||
- exact source and definition pins, tenant isolation, normalized access
|
||||
grants, row-policy handoff, freshness checks, and reconstructable run
|
||||
provenance;
|
||||
- safe dimensions, hierarchies, measures, typed calculations, filters,
|
||||
detail/summary/pivot queries, parameterized PostgreSQL semantic plans, and
|
||||
accessible chart models without executing arbitrary report SQL;
|
||||
- quality gates, saved views, interval/scheduled runs, CSV/JSON export,
|
||||
provider-neutral publication targets, and import activation assessments;
|
||||
- a versioned, provider-neutral cross-module report contract with source-owned
|
||||
authorization, declared result schemas, privacy transforms, effective scope,
|
||||
source revisions, purpose, retention, export history, and audit provenance;
|
||||
- a full-height Reporting workspace for running, inspecting, saving,
|
||||
scheduling, visualizing, drilling into reauthorized contributors, publishing
|
||||
through Files/Mail, and exporting authorized reports;
|
||||
- a configurable Dashboard widget and explicit policy explanations for hidden
|
||||
fields, rows, and actions.
|
||||
|
||||
Reporting consumes Dataflow outputs or provider-owned read models. It does not
|
||||
read another module's ORM tables or take ownership of ingestion and
|
||||
transformation.
|
||||
|
||||
The canonical global route is `/reports`. `/reporting` remains a
|
||||
Reporting-owned compatibility route for saved links. Domain modules may keep
|
||||
their own operational report routes, but do not register `/reports`.
|
||||
|
||||
See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary
|
||||
decision. 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.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Reporting Module Boundary
|
||||
|
||||
`govoplan-reporting` owns report definitions, execution, dashboards, BI views,
|
||||
scheduled outputs, and report exports. It may use templates for formatted
|
||||
outputs, but it owns the report data and publication semantics.
|
||||
|
||||
The core boundary decision register is in
|
||||
`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`.
|
||||
|
||||
## Ownership
|
||||
|
||||
Reporting owns:
|
||||
|
||||
- report definitions, parameters, saved views, and dashboard cards
|
||||
- curated query/view inputs from modules, connectors, datasources, or
|
||||
configuration packages
|
||||
- report execution history, generated output evidence, and scheduled runs
|
||||
- institutional question, goal/obligation, owner, calculation-version,
|
||||
freshness/quality, and decision/action-consumption references for each
|
||||
material report or indicator
|
||||
- report permissions and sharing rules
|
||||
- export targets such as file, DMS, mail, API, RSS/Atom publication, and
|
||||
downstream connector handoff
|
||||
- BI-style summaries where GovOPlaN owns the underlying process evidence
|
||||
|
||||
## Source Inputs
|
||||
|
||||
Initial source categories:
|
||||
|
||||
- module-owned read models and summary DTOs
|
||||
- curated SQL/query views exposed through a controlled capability
|
||||
- connector-staged datasets and external references
|
||||
- generated file/csv/xlsx inputs from files/connectors
|
||||
- governed `govoplan-datasources` catalogue entries and immutable
|
||||
materializations
|
||||
- versioned `govoplan-dataflow` outputs and run lineage
|
||||
|
||||
Reporting should not reach into module ORM internals directly. Module-owned
|
||||
data must be exposed through capabilities, DTOs, events, read models, or
|
||||
controlled query views.
|
||||
|
||||
Every reproducible report definition should retain or resolve:
|
||||
|
||||
- the institutional question, obligation, goal, or service measure it serves;
|
||||
- owner and responsible organization/function;
|
||||
- datasource/materialization and Dataflow definition/run revisions;
|
||||
- source freshness, quality state, known limitations, and calculation version;
|
||||
- purpose, visibility, privacy, retention, and publication decisions;
|
||||
- reports, decisions, controls, projects, or actions that consumed the result.
|
||||
|
||||
Reporting owns execution and presentation of the measure. Mandates, Services,
|
||||
Projects, Risk Compliance, Decisions, and other domains retain ownership of
|
||||
the referenced institutional concepts.
|
||||
|
||||
## Output Targets
|
||||
|
||||
- dashboard/widget view
|
||||
- API result
|
||||
- downloadable file
|
||||
- stored file/DMS evidence
|
||||
- email attachment or link through mail
|
||||
- scheduled export
|
||||
- RSS/Atom/open-data publication through connectors
|
||||
- workflow/task notification when a report run needs review
|
||||
|
||||
## Boundaries
|
||||
|
||||
Reporting does not own:
|
||||
|
||||
- reusable template rendering; that belongs to `govoplan-templates`
|
||||
- DMS lifecycle, collaborative editing, legal hold, or records management
|
||||
- raw file/blob storage and provider connectors
|
||||
- ingestion, governed source identity, staging, or transformation pipeline
|
||||
ownership; those belong to Connectors, Datasources, and Dataflow
|
||||
- cross-module search indexing; that belongs to `govoplan-search`
|
||||
|
||||
## Capability Contracts
|
||||
|
||||
- `reporting.registry` owns immutable definition registration and lookup.
|
||||
- `reporting.runner` executes exact report graphs and returns governed result
|
||||
evidence.
|
||||
- `reporting.scheduler` claims due schedules and preserves run/publication
|
||||
outcomes.
|
||||
- `reporting.chart_renderer` renders provider-neutral visual models with an
|
||||
accessible table fallback.
|
||||
- `reporting.publication.files` adapts immutable results to Core's
|
||||
`files.artifact_store` boundary without importing Files internals.
|
||||
- `reporting.publication.mail` adapts report notices to Core's
|
||||
`mail.notificationDelivery` boundary without importing Mail internals.
|
||||
- `reporting.read_model:*` capabilities can expose bounded source-owned rows.
|
||||
- `reporting.publication_target:*` capabilities can accept immutable result
|
||||
payloads without Reporting importing the target module.
|
||||
|
||||
### Cross-module report providers
|
||||
|
||||
Source modules contribute aggregate or otherwise minimized reports through
|
||||
`reporting.report_provider.<provider-id>`. Core owns contract version `1.0` and
|
||||
the provider-neutral DTOs. A provider must declare stable report and revision
|
||||
IDs, typed parameters, the complete result schema, privacy transforms,
|
||||
retention class, export formats, and re-identification risk. It must enforce
|
||||
source access before listing options or producing a result and must return
|
||||
source revisions, effective tenant scope, applied transforms, generation time,
|
||||
and bounded provenance.
|
||||
|
||||
Reporting discovers these capabilities by prefix. It validates descriptors,
|
||||
rejects undeclared output fields, requires every mandatory transform, records
|
||||
purpose and effective audience, and persists immutable execution and export
|
||||
evidence. The optional `policy.reporting_governance` capability can tighten
|
||||
retention, export formats, required transforms, and high-risk handling. If
|
||||
Policy is absent, Reporting applies its restrictive built-in baseline and
|
||||
denies high-risk provider reports.
|
||||
|
||||
This direction is intentionally one-way: source modules import only Core's
|
||||
contract and remain usable when Reporting is absent. Reporting never imports
|
||||
the source module or reads its ORM tables.
|
||||
|
||||
## Implemented Vertical
|
||||
|
||||
The first complete vertical persists datasets, semantic models, reports,
|
||||
quality plans, grants, executions, saved views, schedules, publications,
|
||||
quality results, and import assessments. Each definition update creates an
|
||||
immutable revision and requires optimistic concurrency. Active child
|
||||
definitions require the exact pinned parent revision to be active.
|
||||
|
||||
Execution resolves the report, semantic model, and dataset graph before
|
||||
reading data. Static fixtures, Dataflow output, and provider-owned read models
|
||||
share one bounded read contract. Source fingerprints, freshness, schema,
|
||||
row-policy provenance, blocking quality plans, definition hashes, executor
|
||||
version, output hash, diagnostics, and authorized rows are retained with the
|
||||
execution. Failed runs also retain evidence.
|
||||
|
||||
The query layer deliberately implements a typed expression and semantic
|
||||
query language rather than `eval`, arbitrary SQL, stored procedures, or
|
||||
runtime scripts. PostgreSQL installations receive parameterized semantic plans
|
||||
for filters, grouping, measures, calculated aggregates, sorting, and bounds;
|
||||
other engines and pivots use the equivalent bounded runtime evaluator. It
|
||||
supports detail, grouped summary, pivot, dimensions,
|
||||
hierarchies, common aggregates, calculated measures, filters, sorting,
|
||||
pagination, totals, and a provider-neutral visualization model. A saved chart
|
||||
that is incompatible with an ad-hoc query degrades to its mandatory table
|
||||
fallback instead of failing a valid report run.
|
||||
|
||||
Aggregate drill-through uses an expiring actor-bound context hash. Resolution
|
||||
rechecks all definition and row-policy decisions, verifies the source
|
||||
fingerprints against the original execution, preserves the complete dimension
|
||||
path, and returns only authorized contributors.
|
||||
|
||||
Direct export supports UTF-8 CSV and JSON. CSV cells that spreadsheet software
|
||||
could interpret as formulas are escaped. Additional formats and delivery
|
||||
destinations use an optional publication capability and preserve idempotent
|
||||
evidence. Import assessments classify exact, approximated, and unsupported
|
||||
semantics and block activation until every approximation is accepted and no
|
||||
unsupported executable behavior remains.
|
||||
|
||||
The WebUI uses the platform module loader and common controls. It exposes a
|
||||
report catalogue, parameter and semantic-query controls, result visualization
|
||||
and table views, accessible bar/column/line/area/pie/donut/metric charts,
|
||||
drill-through, access explanations, history/provenance, saved views, schedule
|
||||
management, Files/Mail publication management, downloads, and a Dashboard
|
||||
widget contribution.
|
||||
The global `/reports` route is owned only by Reporting. `/reporting` is a
|
||||
documented compatibility path. Campaign's module-local aggregate view remains
|
||||
at `/campaigns/reports`; when both modules are enabled, the same safe aggregate
|
||||
projection is also contributed to the global catalogue through the provider
|
||||
contract.
|
||||
|
||||
## Remaining Product Depth
|
||||
|
||||
The architecture boundary and first operational vertical are implemented.
|
||||
Further work is additive product depth: packaged domain report catalogues,
|
||||
XLSX/PDF formatting through optional renderer providers, selector-backed Mail
|
||||
profile configuration, external publication connectors, and target-environment
|
||||
evidence for a maturity claim above `vertical_slice`.
|
||||
@@ -0,0 +1,150 @@
|
||||
# SuperX Capability Assessment
|
||||
|
||||
## Scope
|
||||
|
||||
The supplied SuperX directory contains 22 PostgreSQL module archives:
|
||||
|
||||
`bau`, `cob`, `costage`, `erfolg`, `etl`, `fin`, `gang`, `gxstage`, `ivs`,
|
||||
`kenn`, `kern`, `lm`, `man`, `msg`, `qa`, `rpta`, `sos`, `sva`, `sxc`, `viz`,
|
||||
`xcube`, and `zul`.
|
||||
|
||||
This is a behavior and architecture assessment. It uses archive metadata and
|
||||
representative definitions to identify product capabilities; no SuperX source
|
||||
code or visual assets are copied.
|
||||
|
||||
## Functional Model
|
||||
|
||||
The supplied modules collectively provide five relevant layers:
|
||||
|
||||
1. **ETL orchestration:** parameterized jobs, ordered steps, dependencies,
|
||||
load/select/native actions, continuation policy, and execution metadata.
|
||||
2. **Domain data marts:** higher-education, finance, personnel, buildings,
|
||||
applications, courses, success, and benchmark datasets with curated
|
||||
reports.
|
||||
3. **Semantic BI model:** cubes, dimensions, measures, hierarchies, totals,
|
||||
labels, virtual calculated columns, and row/column arrangements.
|
||||
4. **Presentation:** parameter masks, tables, pivots, charts, saved
|
||||
visualizations, dashboard tabs, and formatted report products.
|
||||
5. **Quality and operations:** assertions, comparison profiles, grouped
|
||||
controls, execution logs, projects/issues, installation, and updates.
|
||||
|
||||
## GovOPlaN Coverage
|
||||
|
||||
| Capability | GovOPlaN state | Primary owner |
|
||||
| --- | --- | --- |
|
||||
| Governed source identity and frozen snapshots | Strong foundation | Datasources |
|
||||
| External acquisition and credentials | Strong foundation | Connectors |
|
||||
| Graph and SQL transformations | Strong foundation | Dataflow |
|
||||
| Calculations, joins, ranking, aggregates | Available | Dataflow |
|
||||
| Quality rules and keyed reconciliation | Available foundation | Dataflow |
|
||||
| Durable scheduled/event/user runs | Available foundation | Dataflow/Core |
|
||||
| Resumable approval and correction steps | Available foundation | Workflow |
|
||||
| Scope inheritance and provenance | Available foundation | Policy/Core |
|
||||
| Configurable dashboard widgets | Available foundation | Dashboard |
|
||||
| Reusable parameterized subflows | Available foundation | Dataflow |
|
||||
| BI dimensions, measures, and hierarchies | Available | Reporting |
|
||||
| Pivot analytical view | Available; drill-through is product depth | Reporting |
|
||||
| Parameterized report masks and saved views | Available | Reporting |
|
||||
| Chart renderer/provider contract | Available with tabular fallback | Reporting |
|
||||
| Scheduled report publication/export | Available through provider contract | Reporting |
|
||||
| Report-specific access enforcement | Available; richer explanations are product depth | Reporting/Policy |
|
||||
| Data-quality test plans and evidence | Available foundation | Dataflow/Reporting |
|
||||
| Packaged domain report catalogues | Missing | Reporting plus domain modules |
|
||||
|
||||
GovOPlaN already has most platform primitives below the BI layer. The largest
|
||||
gap is not another execution engine; it is a governed Reporting semantic layer
|
||||
that turns curated Dataflow outputs and module read models into dimensions,
|
||||
measures, parameterized views, pivots, charts, and publishable reports.
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
Reporting should consume only capability-published datasets or module read
|
||||
models. It must not query another module's ORM tables directly.
|
||||
|
||||
The minimum durable contracts are:
|
||||
|
||||
- **Analytical dataset:** stable reference, schema, freshness, provenance,
|
||||
scope, row-level policy hook, and supported query operations.
|
||||
- **Semantic model:** dimensions, levels/hierarchies, measures, calculations,
|
||||
labels, formats, totals, and default filters.
|
||||
- **Report definition:** semantic-model revision, parameters, layout,
|
||||
visualization, sort/filter state, and access policy.
|
||||
- **Execution:** immutable definition revision, bound parameters, source
|
||||
fingerprints, effective authorization, result summary, and diagnostics.
|
||||
- **Publication:** target capability, format profile, retention, schedule,
|
||||
delivery evidence, and idempotency key.
|
||||
- **Contribution:** modules may announce report sources, semantic fragments,
|
||||
templates, and dashboard widgets without Reporting importing them.
|
||||
|
||||
PostgreSQL should remain the first execution target. Optional analytical or
|
||||
search engines can be adapters later; report definitions must not depend on
|
||||
one engine's private syntax.
|
||||
|
||||
## Import Boundary
|
||||
|
||||
A future SuperX importer may read declarative metadata for sources,
|
||||
parameters, dimensions, measures, hierarchies, layouts, and report catalogues.
|
||||
It must produce native GovOPlaN definitions, never retain executable SQL or
|
||||
stored procedures as an unchecked runtime escape hatch.
|
||||
|
||||
Every import result needs a machine-readable mapping report:
|
||||
|
||||
- source object and stable source identifier
|
||||
- generated GovOPlaN object and revision
|
||||
- exact, approximated, omitted, and unsupported semantics
|
||||
- required manual bindings or Policy decisions
|
||||
- source dialect/provider assumptions
|
||||
- warnings for custom functions, procedural code, implicit authorization, and
|
||||
presentation behavior that has no native equivalent
|
||||
|
||||
Unsupported semantics must block activation unless an administrator explicitly
|
||||
accepts a documented approximation. Imported definitions remain ordinary
|
||||
versioned GovOPlaN definitions after migration; the runtime must not depend on
|
||||
the original SuperX installation.
|
||||
|
||||
## Delivery State
|
||||
|
||||
### Phase 1: Semantic Core - complete
|
||||
|
||||
- scaffold the Reporting module and manifest
|
||||
- define analytical dataset, dimension, hierarchy, measure, parameter, and
|
||||
report-definition schemas
|
||||
- consume one Dataflow-published dataset through a capability
|
||||
- enforce scope, Policy provenance, and source freshness
|
||||
|
||||
### Phase 2: Interactive BI - vertical complete
|
||||
|
||||
- parameter panel and saved views
|
||||
- table, pivot, drill-down, totals, calculated measures, and export of the
|
||||
currently authorized result
|
||||
- stable URL/reference contract for report state
|
||||
- dashboard contribution for saved reports
|
||||
|
||||
### Phase 3: Visualization And Publication - vertical complete
|
||||
|
||||
- chart renderer provider contract with an initial bounded chart catalogue
|
||||
- scheduled runs and publication through Files, Mail, or another declared
|
||||
target capability
|
||||
- accessible tabular fallback and report-specific access explanations
|
||||
|
||||
### Phase 4: Quality And Domain Packs - foundation complete
|
||||
|
||||
- reusable report assertions and dataset comparison plans
|
||||
- execution evidence and trend views
|
||||
- versioned domain report packages owned by the relevant domain modules
|
||||
- import tooling for metadata where licensing and semantic mapping permit it
|
||||
|
||||
Packaged domain catalogues and an importer that translates actual source
|
||||
metadata remain product depth. The native contracts, quality evidence, and
|
||||
blocking import assessment required to implement them are present.
|
||||
|
||||
## Product Boundary
|
||||
|
||||
Dataflow remains responsible for producing governed tabular datasets.
|
||||
Reporting owns analytical semantics and presentation. Dashboard arranges
|
||||
widgets but does not become the report engine. Workflow coordinates human
|
||||
handoffs but does not own report execution. Templates format documents but do
|
||||
not own report data or query semantics.
|
||||
|
||||
The assessment and its implementation split are tracked by
|
||||
[`govoplan-reporting#3`](https://git.add-ideas.de/GovOPlaN/govoplan-reporting/issues/3).
|
||||
@@ -0,0 +1,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