Complete governed reporting execution and publication
This commit is contained in:
@@ -8,6 +8,10 @@ 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,
|
||||
@@ -27,6 +31,11 @@ from govoplan_reporting.backend.execution import (
|
||||
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,
|
||||
@@ -35,10 +44,24 @@ from govoplan_reporting.backend.operations import (
|
||||
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)
|
||||
@@ -79,6 +102,36 @@ class Principal:
|
||||
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:")
|
||||
@@ -404,6 +457,216 @@ class ReportingServiceTests(unittest.TestCase):
|
||||
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,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user