feat: add governed cross-module reporting

This commit is contained in:
2026-08-02 05:29:38 +02:00
parent eba35edd3c
commit 0abcc2455e
21 changed files with 2497 additions and 38 deletions
+7 -1
View File
@@ -14,9 +14,15 @@ class ReportingManifestTests(unittest.TestCase):
self.assertEqual("@govoplan/reporting-webui", manifest.frontend.package_name)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertEqual(4, len(manifest.provides_interfaces))
self.assertEqual(5, 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,
+42
View File
@@ -0,0 +1,42 @@
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_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()
+91
View File
@@ -0,0 +1,91 @@
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 _run_probe(source: str) -> None:
environment = dict(os.environ)
environment["PYTHONPATH"] = os.pathsep.join(
(
str(_ROOT / "src"),
str(_ROOT.parent / "govoplan-campaign" / "src"),
str(_ROOT.parent / "govoplan-core" / "src"),
environment.get("PYTHONPATH", ""),
)
)
result = subprocess.run(
[sys.executable, "-c", source],
cwd=_ROOT,
env=environment,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout
+336
View File
@@ -0,0 +1,336 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.reporting import (
REPORT_PROVIDER_CAPABILITY_PREFIX,
REPORT_PROVIDER_CONTRACT_VERSION,
ReportDescriptor,
ReportParameterDescriptor,
ReportParameterOption,
ReportPrivacyTransform,
ReportProviderRequest,
ReportProviderResult,
ReportResultField,
)
from govoplan_core.db.base import Base
from govoplan_reporting.backend.db.models import (
ReportingProviderExecution,
ReportingProviderExport,
)
from govoplan_reporting.backend.provider_reports import (
ProviderReportError,
execute_provider_report,
export_provider_execution,
list_provider_reports,
)
from govoplan_reporting.backend.retention import ReportingRetentionService
class _Principal:
tenant_id = "tenant-1"
user = SimpleNamespace(id="analyst-1")
api_key = None
class _Provider:
provider_id = "example"
contract_version = REPORT_PROVIDER_CONTRACT_VERSION
def __init__(self) -> None:
self.result_access = True
def list_reports(self, session, principal):
del session, principal
return (
ReportDescriptor(
provider_id=self.provider_id,
report_id="summary",
revision="example.summary.v1",
title="Example summary",
summary="A minimized example report.",
parameters=(
ReportParameterDescriptor(
key="source_id",
label="Source",
type="reference",
required=True,
options_from_provider=True,
),
),
result_schema=(
ReportResultField(
path="metric",
label="Metric",
type="integer",
group="Outcome",
),
),
privacy_transforms=(
ReportPrivacyTransform(id="aggregate", label="Aggregate"),
),
export_formats=("json",),
reidentification_risk="low",
),
)
def parameter_options(self, session, principal, **kwargs):
del session, principal, kwargs
return (ReportParameterOption(value="source-1", label="Source 1"),)
def execute_report(
self,
session,
principal,
*,
request: ReportProviderRequest,
):
del session
return ReportProviderResult(
report_id=request.report_id,
generated_at=datetime(2026, 8, 2, 10, 0, tzinfo=UTC),
payload={"metric": 12},
source_revisions=(
{
"module_id": "example",
"resource_id": request.parameters["source_id"],
"revision_id": "7",
},
),
effective_scope={
"tenant_id": principal.tenant_id,
"source_id": request.parameters["source_id"],
},
applied_privacy_transforms=("aggregate",),
provenance={"executor": "example-v1"},
)
def authorize_result(self, session, principal, **kwargs):
del session, principal, kwargs
return self.result_access
class _Registry:
def __init__(self, provider=None):
self.provider = provider or _Provider()
def capability_names(self):
return (REPORT_PROVIDER_CAPABILITY_PREFIX + "example",)
def has_capability(self, name):
return name == REPORT_PROVIDER_CAPABILITY_PREFIX + "example"
def capability(self, name):
return self.provider if self.has_capability(name) else None
def test_provider_report_execution_is_governed_replayable_and_audited() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
principal = _Principal()
registry = _Registry()
with Session(engine, expire_on_commit=False) as session:
catalogue = list_provider_reports(session, principal, registry=registry)
assert catalogue["reports"][0]["available"] is True
execution = execute_provider_report(
session,
principal,
registry=registry,
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="provider-run-1",
)
replay = execute_provider_report(
session,
principal,
registry=registry,
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="provider-run-1",
)
assert replay["execution_id"] == execution["execution_id"]
assert execution["source_revisions"][0]["revision_id"] == "7"
assert execution["privacy_transforms"] == ["aggregate"]
assert execution["retention_days"] == 30
content, media_type, filename = export_provider_execution(
session,
principal,
registry=registry,
execution_id=str(execution["execution_id"]),
format="json",
purpose="Archive approved aggregate",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
)
assert b'"metric": 12' in content
assert media_type == "application/json"
assert filename.endswith(".json")
assert session.query(ReportingProviderExport).count() == 1
engine.dispose()
def test_provider_result_missing_a_required_transform_is_rejected() -> None:
class _UnsafeProvider(_Provider):
def execute_report(self, session, principal, *, request):
result = super().execute_report(
session,
principal,
request=request,
)
return ReportProviderResult(
report_id=result.report_id,
generated_at=result.generated_at,
payload=result.payload,
source_revisions=result.source_revisions,
effective_scope=result.effective_scope,
applied_privacy_transforms=(),
provenance=result.provenance,
)
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
try:
execute_provider_report(
session,
_Principal(),
registry=_Registry(_UnsafeProvider()),
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant"},
idempotency_key="unsafe-provider-run",
)
except ProviderReportError as exc:
assert "privacy transformations" in str(exc)
else:
raise AssertionError("unsafe provider report was accepted")
engine.dispose()
def test_expired_provider_result_is_minimized_but_evidence_is_retained() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine, expire_on_commit=False) as session:
execution = execute_provider_report(
session,
_Principal(),
registry=_Registry(),
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="expired-provider-run",
)
row = session.query(ReportingProviderExecution).one()
original_hash = row.output_hash
row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
session.flush()
preview = ReportingRetentionService().apply_retention(
session,
dry_run=True,
now=datetime.now(UTC),
)
assert preview["eligible"] == 1
assert row.result_payload == {"metric": 12}
applied = ReportingRetentionService().apply_retention(
session,
dry_run=False,
now=datetime.now(UTC),
)
assert applied["redacted"] == 1
assert row.result_payload == {}
assert row.retention_redacted_at is not None
assert row.output_hash == original_hash == execution["output_hash"]
assert row.source_revisions[0]["revision_id"] == "7"
try:
execute_provider_report(
session,
_Principal(),
registry=_Registry(),
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="expired-provider-run",
)
except ProviderReportError as exc:
assert "expired" in str(exc)
else:
raise AssertionError("expired provider-report detail was replayed")
engine.dispose()
def test_provider_parameters_are_checked_against_the_declared_type() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
try:
execute_provider_report(
session,
_Principal(),
registry=_Registry(),
provider_id="example",
report_id="summary",
parameters={"source_id": 123},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="invalid-provider-parameters",
)
except ProviderReportError as exc:
assert "parameter types" in str(exc)
else:
raise AssertionError("invalid provider-report parameters were accepted")
engine.dispose()
def test_source_access_is_rechecked_before_retained_result_export() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
provider = _Provider()
registry = _Registry(provider)
with Session(engine) as session:
execution = execute_provider_report(
session,
_Principal(),
registry=registry,
provider_id="example",
report_id="summary",
parameters={"source_id": "source-1"},
purpose="Operational overview",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
idempotency_key="source-access-provider-run",
)
provider.result_access = False
try:
export_provider_execution(
session,
_Principal(),
registry=registry,
execution_id=str(execution["execution_id"]),
format="json",
purpose="Archive aggregate",
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
)
except PermissionError as exc:
assert "source module" in str(exc)
else:
raise AssertionError("revoked source result was exported")
engine.dispose()