Complete governed reporting execution and publication

This commit is contained in:
2026-08-04 02:23:24 +02:00
parent a81151391b
commit 8337aec19a
29 changed files with 3611 additions and 81 deletions
+6 -3
View File
@@ -16,15 +16,18 @@ The module now provides an executable governed semantic-reporting vertical:
grants, row-policy handoff, freshness checks, and reconstructable run
provenance;
- safe dimensions, hierarchies, measures, typed calculations, filters,
detail/summary/pivot queries, and accessible chart models without executing
arbitrary report SQL;
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, and exporting authorized reports.
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
+30
View File
@@ -14,12 +14,28 @@ 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
@@ -32,6 +48,20 @@ 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;
+23 -7
View File
@@ -83,6 +83,10 @@ Reporting does not own:
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.
@@ -125,14 +129,22 @@ 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 engine deliberately implements a typed expression and semantic
The query layer deliberately implements a typed expression and semantic
query language rather than `eval`, arbitrary SQL, stored procedures, or
runtime scripts. It supports detail, grouped summary, pivot, dimensions,
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
@@ -142,7 +154,10 @@ 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, history/provenance, saved views, schedules, and downloads.
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
@@ -151,7 +166,8 @@ contract.
## Remaining Product Depth
The architecture boundary is implemented. Further work is additive product
depth: richer visualization providers, drill-through navigation, packaged
domain report catalogues, XLSX/PDF formatting through optional providers, and
target-environment evidence for a maturity claim above `vertical_slice`.
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`.
+22
View File
@@ -13,6 +13,13 @@ 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
@@ -21,6 +28,10 @@ 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
@@ -32,6 +43,17 @@ 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
@@ -10,6 +10,8 @@ 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)
@@ -119,6 +121,8 @@ def capability(registry: object | None, name: str) -> object | None:
__all__ = [
"CAPABILITY_REPORTING_CHART_RENDERER",
"CAPABILITY_REPORTING_PUBLICATION_FILES",
"CAPABILITY_REPORTING_PUBLICATION_MAIL",
"CAPABILITY_REPORTING_REGISTRY",
"CAPABILITY_REPORTING_RUNNER",
"CAPABILITY_REPORTING_SCHEDULER",
@@ -4,6 +4,7 @@ from govoplan_reporting.backend.db.models import (
ReportingDefinitionGrant,
ReportingDefinitionIdentity,
ReportingDefinitionRevision,
ReportingDrillContext,
ReportingExecution,
ReportingImportAssessment,
ReportingPublication,
@@ -16,6 +17,7 @@ __all__ = [
"ReportingDefinitionGrant",
"ReportingDefinitionIdentity",
"ReportingDefinitionRevision",
"ReportingDrillContext",
"ReportingExecution",
"ReportingImportAssessment",
"ReportingPublication",
@@ -484,6 +484,50 @@ class ReportingPublication(Base, TimestampMixin):
)
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__ = (
@@ -551,6 +595,7 @@ __all__ = [
"ReportingDefinitionGrant",
"ReportingDefinitionIdentity",
"ReportingDefinitionRevision",
"ReportingDrillContext",
"ReportingExecution",
"ReportingImportAssessment",
"ReportingPublication",
+75 -17
View File
@@ -27,6 +27,11 @@ from govoplan_reporting.backend.domain import (
ReportingDefinitionRecord,
definition_from_row,
)
from govoplan_reporting.backend.governance import (
apply_parent_governance,
normalize_definition_governance,
scope_visible,
)
from govoplan_reporting.backend.schemas import validate_definition_payload
@@ -86,17 +91,29 @@ def create_definition(
clean_reason = _required(change_reason, "Reporting change reason", 1_000)
_aware(recorded_at, "Reporting recorded_at")
validated_payload = validate_definition_payload(kind, dict(payload))
validated_payload = validate_definition_payload(
kind,
normalize_definition_governance(
validated_payload,
principal,
administrative=_has_scope(principal, ADMIN_SCOPE),
),
)
parent_kind, parent_id, parent_revision = _parent_reference(
kind,
validated_payload,
)
_validate_parent(
session,
tenant_id=tenant_id,
child_status=clean_status,
parent_kind=parent_kind,
parent_id=parent_id,
parent_revision=parent_revision,
validated_payload = validate_definition_payload(
kind,
_validate_parent(
session,
tenant_id=tenant_id,
child_status=clean_status,
parent_kind=parent_kind,
parent_id=parent_id,
parent_revision=parent_revision,
child_payload=validated_payload,
),
)
request = {
"definition_kind": kind,
@@ -241,14 +258,26 @@ def update_definition(
kind,
dict(changes.get("payload", current.payload)),
)
next_payload = validate_definition_payload(
kind,
normalize_definition_governance(
next_payload,
principal,
administrative=_has_scope(principal, ADMIN_SCOPE),
),
)
parent_kind, parent_id, parent_revision = _parent_reference(kind, next_payload)
_validate_parent(
session,
tenant_id=tenant_id,
child_status=next_status,
parent_kind=parent_kind,
parent_id=parent_id,
parent_revision=parent_revision,
next_payload = validate_definition_payload(
kind,
_validate_parent(
session,
tenant_id=tenant_id,
child_status=next_status,
parent_kind=parent_kind,
parent_id=parent_id,
parent_revision=parent_revision,
child_payload=next_payload,
),
)
identity = _identity(session, tenant_id, kind, definition_id)
if identity is None:
@@ -584,9 +613,10 @@ def _validate_parent(
parent_kind: str | None,
parent_id: str | None,
parent_revision: int | None,
) -> None:
child_payload: Mapping[str, object],
) -> dict[str, object]:
if parent_kind is None:
return
return dict(child_payload)
row = (
session.query(ReportingDefinitionRevision)
.filter(
@@ -605,6 +635,7 @@ def _validate_parent(
raise ReportingDefinitionError(
f"An active Reporting definition requires an active {parent_kind} revision."
)
return apply_parent_governance(child_payload, row.payload)
def _parent_reference(
@@ -667,6 +698,8 @@ def _can_access(
)
if row is None:
return False
if not scope_visible(row.payload, principal):
return False
if _has_scope(principal, ADMIN_SCOPE):
return True
identity = _identity(session, tenant_id, definition_kind, definition_id)
@@ -712,6 +745,24 @@ def _require_scope(principal: object, scope: str) -> None:
def _filter_accessible(query: Query, principal: object) -> Query:
if _has_scope(principal, ADMIN_SCOPE):
return query
governance = ReportingDefinitionRevision.payload["governance"]
scope_type = governance["scope_type"].as_string()
scope_id = governance["scope_id"].as_string()
inherited = governance["inherit_to_lower_scopes"].as_boolean()
scope_conditions = [
scope_type.is_(None),
and_(
scope_type == "tenant",
or_(scope_id.is_(None), scope_id == _principal_tenant(principal)),
),
and_(scope_type == "system", inherited.is_(True)),
]
group_ids = tuple(_string_subject_ids(principal, "group_ids"))
if group_ids:
scope_conditions.append(and_(scope_type == "group", scope_id.in_(group_ids)))
user_ids = _actor_ids(principal)
if user_ids:
scope_conditions.append(and_(scope_type == "user", scope_id.in_(user_ids)))
conditions = [ReportingDefinitionRevision.visibility == "tenant"]
actor_ids = _actor_ids(principal)
if actor_ids:
@@ -752,7 +803,14 @@ def _filter_accessible(query: Query, principal: object) -> Query:
)
)
)
return query.filter(or_(*conditions))
return query.filter(and_(or_(*scope_conditions), or_(*conditions)))
def _string_subject_ids(principal: object, attribute: str) -> tuple[str, ...]:
raw = getattr(principal, attribute, ()) or ()
if isinstance(raw, (str, bytes)):
return (str(raw),) if raw else ()
return tuple(dict.fromkeys(str(value) for value in raw if str(value or "").strip()))
def _current_row(
+388
View File
@@ -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",
]
+278 -17
View File
@@ -37,6 +37,12 @@ from govoplan_reporting.backend.db.models import (
ReportingQualityResult,
)
from govoplan_reporting.backend.definitions import get_definition, list_definitions
from govoplan_reporting.backend.domain import ReportingDefinitionRecord
from govoplan_reporting.backend.governance import require_definition_action
from govoplan_reporting.backend.postgres_planner import (
POSTGRES_PLANNER_VERSION,
execute_postgres_query,
)
from govoplan_reporting.backend.query_engine import (
QUERY_ENGINE_VERSION,
DefaultChartRenderer,
@@ -99,7 +105,12 @@ class SqlReportingRunner:
*,
execution_id: str,
) -> Mapping[str, object] | None:
return get_execution(_session(session), principal, execution_id=execution_id)
return get_execution(
_session(session),
principal,
execution_id=execution_id,
registry=self.registry,
)
def execute_report(
@@ -126,6 +137,13 @@ def execute_report(
if report_record.status != "active":
raise ReportingExecutionError("Only active report definitions can run.")
report = ReportDefinition.model_validate(report_record.payload)
report_decision = require_definition_action(
session,
principal,
registry=registry,
record=report_record,
action="run",
)
semantic_record = get_definition(
session,
principal,
@@ -138,6 +156,13 @@ def execute_report(
"The report's pinned semantic model is unavailable or inactive."
)
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
semantic_decision = require_definition_action(
session,
principal,
registry=registry,
record=semantic_record,
action="view",
)
dataset_record = get_definition(
session,
principal,
@@ -150,8 +175,15 @@ def execute_report(
"The report's pinned analytical dataset is unavailable or inactive."
)
dataset = DatasetDefinition.model_validate(dataset_record.payload)
dataset_decision = require_definition_action(
session,
principal,
registry=registry,
record=dataset_record,
action="view",
)
bound_parameters = _bind_parameters(report, parameters)
effective_query = query or report.default_query
effective_query = _enforce_query_access(report, query or report.default_query)
clean_idempotency_key = _required(
idempotency_key,
"Reporting execution idempotency key",
@@ -176,7 +208,19 @@ def execute_report(
request_sha256=request_sha256,
)
if replay is not None:
return _execution_payload(replay, report=report, registry=registry)
delivery = _authorize_execution_delivery(
session,
principal,
registry=registry,
row=replay,
report_record=report_record,
)
return _execution_payload(
replay,
report=report,
registry=registry,
delivery_authorization=delivery,
)
started_at = utc_now()
execution = ReportingExecution(
tenant_id=_tenant(principal),
@@ -232,7 +276,14 @@ def execute_report(
output_hash=source.output_hash,
source_fingerprints=source.source_fingerprints,
)
result = execute_semantic_query(authorized_rows, semantic, effective_query)
result = execute_postgres_query(
session,
rows=authorized_rows,
dataset=dataset,
semantic_model=semantic,
query=effective_query,
) or execute_semantic_query(authorized_rows, semantic, effective_query)
diagnostics.extend(result.diagnostics)
output_hash = _sha256(
{
"rows": result.rows,
@@ -244,7 +295,12 @@ def execute_report(
execution.status = "succeeded"
execution.source_fingerprints = _json_value(source.source_fingerprints)
execution.output_hash = output_hash
execution.executor_version = f"{QUERY_ENGINE_VERSION}+{source.executor_version}"
planner_version = (
POSTGRES_PLANNER_VERSION
if any(item.get("code") == "postgresql_semantic_plan" for item in result.diagnostics)
else QUERY_ENGINE_VERSION
)
execution.executor_version = f"{planner_version}+{source.executor_version}"
execution.result_schema = list(result.schema)
execution.result_rows = list(result.rows)
execution.total_rows = result.total_rows
@@ -259,11 +315,35 @@ def execute_report(
"report_content_hash": report_record.content_hash,
"semantic_model_content_hash": semantic_record.content_hash,
"dataset_content_hash": dataset_record.content_hash,
"definition_governance": {
"report": report_decision.to_dict(),
"semantic_model": semantic_decision.to_dict(),
"dataset": dataset_decision.to_dict(),
},
"access_explanation": _access_explanation(
report,
effective_query,
source_rows=len(normalized_rows),
authorized_rows=len(authorized_rows),
row_policy=policy_provenance,
),
}
execution.finished_at = utc_now()
session.flush()
_emit_execution_event(session, execution, report_record.name)
return _execution_payload(execution, report=report, registry=registry)
delivery = _authorize_execution_delivery(
session,
principal,
registry=registry,
row=execution,
report_record=report_record,
)
return _execution_payload(
execution,
report=report,
registry=registry,
delivery_authorization=delivery,
)
except Exception as exc:
execution.status = "failed"
execution.finished_at = utc_now()
@@ -284,6 +364,7 @@ def get_execution(
principal: object,
*,
execution_id: str,
registry: object | None = None,
) -> dict[str, object] | None:
row = (
session.query(ReportingExecution)
@@ -304,10 +385,18 @@ def get_execution(
)
if report_record is None:
return None
delivery = _authorize_execution_delivery(
session,
principal,
registry=registry,
row=row,
report_record=report_record,
)
return _execution_payload(
row,
report=ReportDefinition.model_validate(report_record.payload),
registry=None,
registry=registry,
delivery_authorization=delivery,
)
@@ -317,16 +406,15 @@ def list_executions(
*,
report_id: str,
limit: int = 100,
registry: object | None = None,
) -> tuple[dict[str, object], ...]:
if (
get_definition(
session,
principal,
definition_kind="report",
definition_id=report_id,
)
is None
):
current_report = get_definition(
session,
principal,
definition_kind="report",
definition_id=report_id,
)
if current_report is None:
return ()
rows = (
session.query(ReportingExecution)
@@ -338,7 +426,46 @@ def list_executions(
.limit(max(1, min(limit, 200)))
.all()
)
return tuple(_execution_payload(row, report=None, registry=None) for row in rows)
authorization_cache: dict[tuple[int, int, int], dict[str, object]] = {}
payloads: list[dict[str, object]] = []
for row in rows:
key = (
row.report_revision,
row.semantic_model_revision,
row.dataset_revision,
)
report_record = (
current_report
if current_report.revision == row.report_revision
else get_definition(
session,
principal,
definition_kind="report",
definition_id=row.report_id,
revision=row.report_revision,
)
)
if report_record is None:
continue
delivery = authorization_cache.get(key)
if delivery is None:
delivery = _authorize_execution_delivery(
session,
principal,
registry=registry,
row=row,
report_record=report_record,
)
authorization_cache[key] = delivery
payloads.append(
_execution_payload(
row,
report=ReportDefinition.model_validate(report_record.payload),
registry=registry,
delivery_authorization=delivery,
)
)
return tuple(payloads)
def _read_dataset(
@@ -717,11 +844,144 @@ def _evaluate_assertion(
}
def _enforce_query_access(
report: ReportDefinition,
query: ReportQuery,
) -> ReportQuery:
policy = report.access_policy
hidden_dimensions = _policy_strings(policy, "hidden_dimensions")
hidden_measures = _policy_strings(policy, "hidden_measures")
requested_dimensions = set(query.dimensions)
requested_dimensions.update(item.dimension for item in query.filters)
if query.pivot is not None:
requested_dimensions.update(query.pivot.rows)
requested_dimensions.update(query.pivot.columns)
requested_measures = set(query.measures)
if query.pivot is not None:
requested_measures.update(query.pivot.measures)
blocked = (requested_dimensions & hidden_dimensions) | (
requested_measures & hidden_measures
)
if blocked:
raise PermissionError(
"Policy hides requested Reporting fields: "
+ ", ".join(sorted(blocked))
)
if "run" in _policy_strings(policy, "disabled_actions"):
raise PermissionError(_policy_reason(policy, "run"))
return query
def _access_explanation(
report: ReportDefinition,
query: ReportQuery,
*,
source_rows: int,
authorized_rows: int,
row_policy: Mapping[str, object],
) -> dict[str, object]:
policy = report.access_policy
hidden_dimensions = sorted(_policy_strings(policy, "hidden_dimensions"))
hidden_measures = sorted(_policy_strings(policy, "hidden_measures"))
disabled_actions = sorted(_policy_strings(policy, "disabled_actions"))
reasons = policy.get("reasons")
return {
"hidden_dimensions": hidden_dimensions,
"hidden_measures": hidden_measures,
"hidden_rows": max(0, source_rows - authorized_rows),
"disabled_actions": disabled_actions,
"reasons": dict(reasons) if isinstance(reasons, Mapping) else {},
"row_policy": dict(row_policy),
"effective_query": query.model_dump(mode="json"),
}
def _authorize_execution_delivery(
session: Session,
principal: object,
*,
registry: object | None,
row: ReportingExecution,
report_record: ReportingDefinitionRecord,
) -> dict[str, object]:
report_decision = require_definition_action(
session,
principal,
registry=registry,
record=report_record,
action="view",
)
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 semantic_record is None or dataset_record is None:
raise PermissionError(
"The source definitions for this report result are no longer accessible."
)
semantic_decision = require_definition_action(
session,
principal,
registry=registry,
record=semantic_record,
action="view",
)
dataset_decision = require_definition_action(
session,
principal,
registry=registry,
record=dataset_record,
action="view",
)
dataset = DatasetDefinition.model_validate(dataset_record.payload)
_empty, row_policy = _apply_row_policy(
session,
principal,
registry=registry,
dataset_id=dataset_record.definition_id,
dataset_revision=dataset_record.revision,
dataset=dataset,
rows=(),
)
return {
"checked": True,
"report": report_decision.to_dict(),
"semantic_model": semantic_decision.to_dict(),
"dataset": dataset_decision.to_dict(),
"row_policy": dict(row_policy),
}
def _policy_strings(policy: Mapping[str, object], key: str) -> set[str]:
raw = policy.get(key, ())
if not isinstance(raw, (list, tuple, set, frozenset)):
return set()
return {str(item) for item in raw if str(item).strip()}
def _policy_reason(policy: Mapping[str, object], action: str) -> str:
reasons = policy.get("reasons")
if isinstance(reasons, Mapping) and str(reasons.get(action) or "").strip():
return str(reasons[action])
return f"Policy disables the Reporting {action} action."
def _execution_payload(
row: ReportingExecution,
*,
report: ReportDefinition | None,
registry: object | None,
delivery_authorization: Mapping[str, object] | None = None,
) -> dict[str, object]:
payload: dict[str, object] = {
"execution_id": row.execution_id,
@@ -747,6 +1007,7 @@ def _execution_payload(
"started_at": _datetime_text(row.started_at),
"finished_at": _datetime_text(row.finished_at),
"actor_id": row.actor_id,
"delivery_authorization": dict(delivery_authorization or {}),
}
if row.status == "succeeded" and report is not None:
result = QueryResult(
@@ -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",
]
+62 -3
View File
@@ -7,6 +7,8 @@ from govoplan_core.core.access import (
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,
@@ -41,6 +43,8 @@ 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,
@@ -63,6 +67,10 @@ from govoplan_reporting.backend.operations import (
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
@@ -175,6 +183,14 @@ def _chart_renderer(context: ModuleContext) -> DefaultChartRenderer:
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
@@ -246,6 +262,8 @@ manifest = ModuleManifest(
optional_capabilities=(
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
CAPABILITY_FILES_ARTIFACT_STORE,
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -306,6 +324,13 @@ manifest = ModuleManifest(
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=(
@@ -313,6 +338,12 @@ manifest = ModuleManifest(
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=(
@@ -328,12 +359,26 @@ manifest = ModuleManifest(
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={
@@ -357,6 +402,16 @@ manifest = ModuleManifest(
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.",
@@ -384,6 +439,7 @@ manifest = ModuleManifest(
reporting_models.ReportingSavedView,
reporting_models.ReportingDefinitionGrant,
reporting_models.ReportingExecution,
reporting_models.ReportingDrillContext,
reporting_models.ReportingProviderExport,
reporting_models.ReportingProviderExecution,
reporting_models.ReportingDefinitionRevision,
@@ -401,6 +457,7 @@ manifest = ModuleManifest(
reporting_models.ReportingDefinitionRevision,
reporting_models.ReportingDefinitionGrant,
reporting_models.ReportingExecution,
reporting_models.ReportingDrillContext,
reporting_models.ReportingProviderExecution,
reporting_models.ReportingProviderExport,
reporting_models.ReportingSavedView,
@@ -429,7 +486,9 @@ manifest = ModuleManifest(
"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. Dataflow and module read models remain source owners."
"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"),
@@ -481,9 +540,9 @@ manifest = ModuleManifest(
),
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; XLSX, PDF, Files, Mail, and DMS delivery require an optional publication provider.",
"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 initial chart provider emits a renderer-neutral model and accessible table; richer visual renderers remain replaceable adapters.",
"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",
@@ -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")
+48 -2
View File
@@ -396,7 +396,12 @@ def publish_execution(
options: Mapping[str, object],
) -> dict[str, object]:
_require_scope(principal, PUBLISH_SCOPE)
execution_payload = get_execution(session, principal, execution_id=execution_id)
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":
@@ -497,14 +502,54 @@ def publish_execution(
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)
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":
@@ -850,6 +895,7 @@ __all__ = [
"dispatch_due_schedules",
"export_execution",
"list_import_assessments",
"list_publications",
"list_saved_views",
"list_schedules",
"publish_execution",
@@ -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,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",
]
@@ -86,7 +86,7 @@ def execute_semantic_query(
return QueryResult(
rows=tuple(selected),
total_rows=total,
schema=_infer_schema(selected or sorted_rows[:1]),
schema=infer_query_schema(selected or sorted_rows[:1]),
truncated=query.offset + len(selected) < total,
)
@@ -376,7 +376,7 @@ def _sort_rows(
return result
def _infer_schema(rows: Sequence[Mapping[str, object]]) -> tuple[dict[str, Any], ...]:
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(
{
@@ -491,4 +491,5 @@ __all__ = [
"QueryResult",
"ReportingQueryError",
"execute_semantic_query",
"infer_query_schema",
]
+102 -2
View File
@@ -18,6 +18,11 @@ from govoplan_reporting.backend.definitions import (
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,
@@ -38,6 +43,7 @@ from govoplan_reporting.backend.operations import (
dispatch_due_schedules,
export_execution,
list_import_assessments,
list_publications,
list_saved_views,
list_schedules,
publish_execution,
@@ -53,10 +59,12 @@ from govoplan_reporting.backend.provider_reports import (
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,
@@ -432,7 +440,13 @@ def create_router(registry: object | None) -> APIRouter:
_require(principal, RUN_SCOPE)
return {
"executions": list(
list_executions(session, principal, report_id=report_id, limit=limit)
list_executions(
session,
principal,
report_id=report_id,
limit=limit,
registry=registry,
)
)
}
@@ -443,11 +457,69 @@ def create_router(registry: object | None) -> APIRouter:
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, RUN_SCOPE)
result = get_execution(session, principal, execution_id=execution_id)
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,
@@ -462,6 +534,7 @@ def create_router(registry: object | None) -> APIRouter:
principal,
execution_id=execution_id,
format=format,
registry=registry,
)
except (ReportingOperationError, LookupError) as exc:
raise _error(exc) from exc
@@ -493,6 +566,33 @@ def create_router(registry: object | None) -> APIRouter:
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,
+51
View File
@@ -58,6 +58,43 @@ class FreshnessPolicy(BaseModel):
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)
@@ -87,6 +124,7 @@ class DatasetDefinition(BaseModel):
default_factory=list,
max_length=200,
)
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
@model_validator(mode="after")
def validate_source_pin(self) -> "DatasetDefinition":
@@ -203,6 +241,7 @@ class SemanticModelDefinition(BaseModel):
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":
@@ -307,6 +346,7 @@ class VisualizationDefinition(BaseModel):
"area",
"column",
"pie",
"donut",
"metric",
] = "table"
category_dimension: str | None = Field(default=None, max_length=120)
@@ -333,6 +373,7 @@ class ReportDefinition(BaseModel):
default_factory=list,
max_length=200,
)
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
class QualityAssertion(BaseModel):
@@ -359,6 +400,7 @@ class QualityPlanDefinition(BaseModel):
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 = {
@@ -478,6 +520,13 @@ class PublicationRequest(BaseModel):
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")
@@ -499,9 +548,11 @@ TypedExpression.model_rebuild()
__all__ = [
"DatasetDefinition",
"DefinitionGovernance",
"DefinitionUpdateRequest",
"DefinitionWriteRequest",
"DimensionDefinition",
"DrillContextCreateRequest",
"FilterClause",
"ImportAssessmentRequest",
"MeasureDefinition",
+1 -1
View File
@@ -14,7 +14,7 @@ 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(5, len(manifest.provides_interfaces))
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)
+1
View File
@@ -26,6 +26,7 @@ def test_fresh_migration_creates_provider_evidence_tables_and_current_head() ->
tables = set(inspect(connection).get_table_names())
assert {
"reporting_provider_executions",
"reporting_drill_contexts",
"reporting_provider_exports",
}.issubset(tables)
assert "b7c4e1a9d2f6" in set(
+28
View File
@@ -70,6 +70,34 @@ 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(
+263
View File
@@ -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,
*,
+3
View File
@@ -9,6 +9,9 @@ assert.ok(page.includes("DocumentationHelpLink"), "Reporting exposes configured-
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");
+146
View File
@@ -103,9 +103,69 @@ export type ReportExecution = {
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 = {
@@ -307,6 +367,26 @@ export function listExecutions(
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,
@@ -364,6 +444,72 @@ export function createIntervalSchedule(
});
}
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,
+426 -24
View File
@@ -1,8 +1,10 @@
import {
BarChart3,
CalendarClock,
ChevronRight,
Download,
FileJson,
FolderOutput,
History,
Play,
RefreshCw,
@@ -28,27 +30,39 @@ import {
PageScrollViewport,
SegmentedControl,
StatusBadge,
ToggleSwitch,
hasScope,
type DataGridColumn,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
createIntervalSchedule,
createDrillContext,
downloadExecution,
getDefinition,
listDefinitions,
listExecutions,
listPublicationTargets,
listPublications,
listProviderReports,
listSavedViews,
listSchedules,
publishExecution,
reportPayload,
runReport,
saveView,
semanticPayload,
resolveDrillContext,
updateSchedule,
type ReportExecution,
type ReportingDrillResult,
type ReportingDefinition,
type ReportingQuery,
type ReportingQueryMode,
type ReportingPublication,
type ReportingPublicationTarget,
type ReportingSavedView,
type ReportingSchedule,
type ProviderReportDescriptor,
type SemanticModelPayload
} from "../../api/reporting";
@@ -73,14 +87,22 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
const [execution, setExecution] = useState<ReportExecution | null>(null);
const [history, setHistory] = useState<ReportExecution[]>([]);
const [savedViews, setSavedViews] = useState<ReportingSavedView[]>([]);
const [schedules, setSchedules] = useState<ReportingSchedule[]>([]);
const [publicationTargets, setPublicationTargets] = useState<ReportingPublicationTarget[]>([]);
const [publications, setPublications] = useState<ReportingPublication[]>([]);
const [outputMode, setOutputMode] = useState<OutputMode>("visual");
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
const [drillDialogOpen, setDrillDialogOpen] = useState(false);
const [drillResult, setDrillResult] = useState<ReportingDrillResult | null>(null);
const [drilling, setDrilling] = useState(false);
const canRun = hasScope(auth, "reporting:report:run");
const canSchedule = hasScope(auth, "reporting:schedule:write");
const canPublish = hasScope(auth, "reporting:report:publish");
const selected = useMemo(
() => reports.find((item) => item.definition_id === selectedId) ?? null,
@@ -102,15 +124,17 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
query: submittedSearch,
limit: 200
}, signal),
listProviderReports(settings, signal)
listProviderReports(settings, signal),
canPublish ? listPublicationTargets(settings, signal) : Promise.resolve({ targets: [] })
]).
then(([result, providerResult]) => {
then(([result, providerResult, targetResult]) => {
const providerRows = providerResult.reports.filter((item) => {
const query = submittedSearch.toLocaleLowerCase();
return !query || `${item.title} ${item.summary} ${item.provider_id}`.toLocaleLowerCase().includes(query);
});
setReports(result.definitions);
setProviderReports(providerRows);
setPublicationTargets(targetResult.targets);
const currentSemanticAvailable = result.definitions.some((item) => item.definition_id === selectedId);
const currentProviderAvailable = providerRows.some((item) => `${item.provider_id}:${item.report_id}` === selectedProviderKey);
if (!currentSemanticAvailable && !currentProviderAvailable) {
@@ -141,6 +165,7 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
setExecution(null);
setHistory([]);
setSavedViews([]);
setSchedules([]);
return;
}
const controller = new AbortController();
@@ -150,12 +175,14 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
Promise.all([
getDefinition(settings, "semantic_model", report.semantic_model_id, report.semantic_model_revision, controller.signal),
canRun ? listExecutions(settings, selected.definition_id, controller.signal) : Promise.resolve({ executions: [] }),
listSavedViews(settings, selected.definition_id, controller.signal)
listSavedViews(settings, selected.definition_id, controller.signal),
canSchedule ? listSchedules(settings, selected.definition_id, controller.signal) : Promise.resolve({ schedules: [] })
]).
then(([semanticDefinition, executions, views]) => {
then(([semanticDefinition, executions, views, scheduleResult]) => {
setSemantic(semanticPayload(semanticDefinition));
setHistory(executions.executions);
setSavedViews(views.views);
setSchedules(scheduleResult.schedules);
setExecution(executions.executions.find((item) => item.status === "succeeded") ?? null);
}).
catch((reason) => {
@@ -164,6 +191,20 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
return () => controller.abort();
}, [settings, selectedId]);
useEffect(() => {
if (!execution || !canPublish) {
setPublications([]);
return;
}
const controller = new AbortController();
void listPublications(settings, execution.execution_id, controller.signal).
then((result) => setPublications(result.publications)).
catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason));
});
return () => controller.abort();
}, [settings, execution?.execution_id, canPublish]);
function submitSearch(event: FormEvent) {
event.preventDefault();
setSubmittedSearch(search.trim());
@@ -189,6 +230,23 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
if (view.state.query) setQuery(normalizeQuery(view.state.query));
}
async function drill(row: Record<string, unknown>) {
if (!execution) return;
setDrillDialogOpen(true);
setDrillResult(null);
setDrilling(true);
setError("");
try {
const context = await createDrillContext(settings, execution.execution_id, row);
setDrillResult(await resolveDrillContext(settings, context.token));
} catch (reason) {
setError(message(reason));
setDrillDialogOpen(false);
} finally {
setDrilling(false);
}
}
return (
<main className="reporting-page">
<div className="reporting-shell">
@@ -302,13 +360,16 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
<span>{execution.total_rows} rows{execution.truncated ? " (truncated)" : ""}</span>
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "csv").catch((reason) => setError(message(reason)))} />
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "json").catch((reason) => setError(message(reason)))} />
{canPublish &&
<IconButton label="Publish report" icon={<FolderOutput size={17} />} variant="ghost" onClick={() => setPublishDialogOpen(true)} />
}
</>
}
</div>
<div className="reporting-output">
{!execution && <div className="reporting-empty">Run the report or select a previous execution.</div>}
{execution && outputMode === "visual" && <ReportVisual execution={execution} />}
{execution && outputMode === "table" && <ReportTable execution={execution} />}
{execution && outputMode === "visual" && <ReportVisual execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
{execution && outputMode === "table" && <ReportTable execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
</div>
</> :
<div className="reporting-empty">Select a report.</div>
@@ -322,8 +383,18 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
execution={execution}
history={history}
savedViews={savedViews}
schedules={schedules}
publications={publications}
onSelectExecution={setExecution}
onApplySavedView={applySavedView}
onScheduleEnabledChange={async (schedule, enabled) => {
try {
const updated = await updateSchedule(settings, schedule, { enabled });
setSchedules((current) => current.map((item) => item.schedule_id === updated.schedule_id ? updated : item));
} catch (reason) {
setError(message(reason));
}
}}
/>}
</PageScrollViewport>
</div>
@@ -343,10 +414,28 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
onClose={() => setScheduleDialogOpen(false)}
onSave={async (name, seconds) => {
if (!selected) return;
await createIntervalSchedule(settings, selected, name, seconds, query, parameters);
const created = await createIntervalSchedule(settings, selected, name, seconds, query, parameters) as ReportingSchedule;
setSchedules((current) => [...current, created].sort((left, right) => left.name.localeCompare(right.name)));
setScheduleDialogOpen(false);
}}
/>
<PublishDialog
open={publishDialogOpen}
targets={publicationTargets}
onClose={() => setPublishDialogOpen(false)}
onPublish={async (request) => {
if (!execution) return;
const publication = await publishExecution(settings, execution.execution_id, request);
setPublications((current) => [publication, ...current]);
setPublishDialogOpen(false);
}}
/>
<DrillDialog
open={drillDialogOpen}
loading={drilling}
result={drillResult}
onClose={() => setDrillDialogOpen(false)}
/>
</main>
);
}
@@ -446,11 +535,11 @@ function QueryControls({ query, semantic, parameters, parameterValues, onQueryCh
);
}
function ReportTable({ execution }: { execution: ReportExecution }) {
function ReportTable({ execution, onDrill }: { execution: ReportExecution; onDrill?: (row: Record<string, unknown>) => void }) {
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [execution.execution_id]);
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() =>
execution.schema.map((field) => ({
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => {
const result = execution.schema.map((field) => ({
id: field.name,
header: humanize(field.name),
width: "1fr",
@@ -461,7 +550,26 @@ function ReportTable({ execution }: { execution: ReportExecution }) {
filterType: field.type === "integer" || field.type === "number" ? field.type : "text",
value: (row) => row[field.name],
render: (row) => formatValue(row[field.name])
})), [execution]);
} satisfies DataGridColumn<Record<string, unknown>>));
if (onDrill) {
result.push({
id: "drill",
header: "Detail",
width: 74,
minWidth: 74,
maxWidth: 74,
render: (row) => (
<IconButton
label="Show authorized contributing rows"
icon={<ChevronRight size={16} />}
variant="ghost"
onClick={() => onDrill(row)}
/>
)
});
}
return result;
}, [execution, onDrill]);
return (
<DataGrid
id={`reporting-execution-${execution.execution_id}`}
@@ -476,40 +584,268 @@ function ReportTable({ execution }: { execution: ReportExecution }) {
);
}
function ReportVisual({ execution }: { execution: ReportExecution }) {
function ReportVisual({ execution, onDrill }: { execution: ReportExecution; onDrill?: (row: Record<string, unknown>) => void }) {
const visual = execution.visualization;
if (!visual || visual.kind === "table" || !visual.category || !visual.measures?.length) {
const needsCategory = visual?.kind !== "metric";
if (!visual || visual.kind === "table" || !visual.measures?.length || (needsCategory && !visual.category)) {
return (
<div className="reporting-visual-fallback">
{visual?.fallback_reason && <DismissibleAlert tone="info" dismissible={false}>{visual.fallback_reason}</DismissibleAlert>}
<ReportTable execution={execution} />
<ReportTable execution={execution} onDrill={onDrill} />
</div>
);
}
const measure = visual.measures[0];
const values = execution.rows.map((row) => Number(row[measure] ?? 0));
const maximum = Math.max(...values.map((value) => Math.abs(value)), 1);
return (
<div className="reporting-bar-chart" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`}>
{execution.rows.map((row, index) =>
<div className="reporting-bar-row" key={`${String(row[visual.category ?? ""])}:${index}`}>
<span>{formatValue(row[visual.category ?? ""])}</span>
<div><i style={{ width: `${Math.max(1, Math.abs(values[index]) / maximum * 100)}%` }} /></div>
<strong>{formatValue(row[measure])}</strong>
if (visual.kind === "metric") {
return (
<div className="reporting-metric-grid">
{visual.measures.map((key) =>
<div className="reporting-metric" key={key}>
<span>{humanize(key)}</span>
<strong>{formatValue(execution.rows[0]?.[key])}</strong>
</div>
)}
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
</div>
);
}
if (visual.kind === "column") {
return (
<div className="reporting-chart-stack">
<div className="reporting-column-chart" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`}>
{execution.rows.slice(0, 50).map((row, index) =>
<div className="reporting-column" key={`${String(row[visual.category ?? ""])}:${index}`}>
<strong>{formatValue(row[measure])}</strong>
<i style={{ height: `${Math.max(2, Math.abs(values[index]) / maximum * 100)}%` }} />
<span>{formatValue(row[visual.category ?? ""])}</span>
</div>
)}
</div>
)}
<div className="reporting-chart-table"><ReportTable execution={execution} /></div>
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
</div>
);
}
if (visual.kind === "line" || visual.kind === "area") {
const points = chartPoints(values.slice(0, 50), 700, 250);
return (
<div className="reporting-line-chart">
<svg viewBox="0 0 700 250" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`} preserveAspectRatio="none">
{visual.kind === "area" && <polygon points={`0,250 ${points} 700,250`} className="reporting-chart-area" />}
<polyline points={points} className="reporting-chart-line" />
</svg>
<div className="reporting-chart-labels">
{execution.rows.slice(0, 50).map((row, index) => <span key={index}>{formatValue(row[visual.category ?? ""])}</span>)}
</div>
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
</div>
);
}
if (visual.kind === "pie" || visual.kind === "donut") {
const positive = values.map((value) => Math.max(0, value));
const total = positive.reduce((sum, value) => sum + value, 0) || 1;
const stops = pieStops(positive, total);
return (
<div className="reporting-pie-layout">
<div className={`reporting-pie${visual.kind === "donut" ? " is-donut" : ""}`} style={{ background: `conic-gradient(${stops})` }} role="img" aria-label={`${humanize(measure)} distribution`} />
<ol>
{execution.rows.slice(0, 12).map((row, index) => <li key={index}><i className={`reporting-swatch reporting-swatch-${index % 8}`} /><span>{formatValue(row[visual.category ?? ""])}</span><strong>{formatValue(row[measure])}</strong></li>)}
</ol>
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
</div>
);
}
return (
<div className="reporting-chart-stack">
<div className="reporting-bar-chart" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`}>
{execution.rows.map((row, index) =>
<div className="reporting-bar-row" key={`${String(row[visual.category ?? ""])}:${index}`}>
<span>{formatValue(row[visual.category ?? ""])}</span>
<div><i style={{ width: `${Math.max(1, Math.abs(values[index]) / maximum * 100)}%` }} /></div>
<strong>{formatValue(row[measure])}</strong>
</div>
)}
</div>
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
</div>
);
}
function Inspector({ selected, execution, history, savedViews, onSelectExecution, onApplySavedView }: {
function AccessExplanation({ execution }: { execution: ReportExecution }) {
const provenance = objectValue(execution.provenance);
const explanation = objectValue(provenance.access_explanation);
const hiddenDimensions = stringValues(explanation.hidden_dimensions);
const hiddenMeasures = stringValues(explanation.hidden_measures);
const disabledActions = stringValues(explanation.disabled_actions);
const hiddenRows = Number(explanation.hidden_rows ?? 0);
const reasons = objectValue(explanation.reasons);
if (!hiddenDimensions.length && !hiddenMeasures.length && !disabledActions.length && hiddenRows <= 0) {
return <DismissibleAlert tone="info" dismissible={false} compact>No report fields, rows, or actions were hidden by effective policy.</DismissibleAlert>;
}
return (
<div className="reporting-access-explanation">
<strong>Effective access</strong>
{hiddenDimensions.length > 0 && <span>Hidden dimensions: {hiddenDimensions.join(", ")}</span>}
{hiddenMeasures.length > 0 && <span>Hidden measures: {hiddenMeasures.join(", ")}</span>}
{hiddenRows > 0 && <span>{hiddenRows} source rows were removed before planning.</span>}
{disabledActions.map((action) => <span key={action}>{String(reasons[action] ?? `The ${action} action is disabled by policy.`)}</span>)}
</div>
);
}
function PublishDialog({ open, targets, onClose, onPublish }: {
open: boolean;
targets: ReportingPublicationTarget[];
onClose: () => void;
onPublish: (request: { target_capability: string; target_ref?: string | null; format: string; options: Record<string, unknown> }) => Promise<void>;
}) {
const firstAvailable = targets.find((item) => item.available) ?? targets[0];
const [targetCapability, setTargetCapability] = useState(firstAvailable?.capability ?? "");
const [targetRef, setTargetRef] = useState("");
const [format, setFormat] = useState(firstAvailable?.formats[0] ?? "csv");
const [filename, setFilename] = useState("");
const [mailProfileId, setMailProfileId] = useState("");
const [fromAddress, setFromAddress] = useState("");
const [subject, setSubject] = useState("");
const [saving, setSaving] = useState(false);
const [dialogError, setDialogError] = useState("");
const target = targets.find((item) => item.capability === targetCapability) ?? firstAvailable;
useEffect(() => {
if (!open) return;
const next = targets.find((item) => item.available) ?? targets[0];
setTargetCapability(next?.capability ?? "");
setFormat(next?.formats[0] ?? "csv");
setTargetRef("");
setFilename("");
setMailProfileId("");
setFromAddress("");
setSubject("");
setDialogError("");
}, [open, targets]);
const mailTarget = target?.capability.endsWith(".mail") === true;
const valid = Boolean(target?.available) && (!target?.target_required || targetRef.trim()) && (!mailTarget || (mailProfileId.trim() && fromAddress.trim()));
return (
<Dialog
open={open}
title="Publish report"
onClose={onClose}
footer={<>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={!valid || saving}
disabledReason={!target?.available ? target?.reason ?? "The selected target is unavailable." : undefined}
onClick={() => {
if (!target) return;
setSaving(true);
setDialogError("");
void onPublish({
target_capability: target.capability,
target_ref: targetRef.trim() || null,
format,
options: mailTarget ? {
mail_profile_id: mailProfileId.trim(),
from_address: fromAddress.trim(),
subject: subject.trim() || undefined
} : { filename: filename.trim() || undefined }
}).catch((reason) => setDialogError(message(reason))).finally(() => setSaving(false));
}}>
Publish
</Button>
</>}>
{dialogError && <DismissibleAlert tone="danger" resetKey={dialogError}>{dialogError}</DismissibleAlert>}
<div className="reporting-dialog-grid">
<label className="reporting-dialog-field">
<span>Target</span>
<select value={targetCapability} onChange={(event) => {
const next = targets.find((item) => item.capability === event.target.value);
setTargetCapability(event.target.value);
setFormat(next?.formats[0] ?? "csv");
}}>
{targets.map((item) => <option key={item.capability} value={item.capability}>{item.label}{item.available ? "" : " (unavailable)"}</option>)}
</select>
</label>
<label className="reporting-dialog-field">
<span>Format</span>
<select value={format} onChange={(event) => setFormat(event.target.value)} disabled={!target?.available}>
{(target?.formats ?? []).map((item) => <option value={item} key={item}>{item.toUpperCase()}</option>)}
</select>
</label>
{target && <label className="reporting-dialog-field">
<span>{target.target_label}</span>
<input value={targetRef} onChange={(event) => setTargetRef(event.target.value)} placeholder={mailTarget ? "recipient@example.org" : "Generated/Reports"} />
</label>}
{!mailTarget && <label className="reporting-dialog-field"><span>Filename</span><input value={filename} onChange={(event) => setFilename(event.target.value)} placeholder="Generated from report name" /></label>}
{mailTarget && <>
<label className="reporting-dialog-field"><span>Mail profile ID</span><input value={mailProfileId} onChange={(event) => setMailProfileId(event.target.value)} /></label>
<label className="reporting-dialog-field"><span>Sender address</span><input type="email" value={fromAddress} onChange={(event) => setFromAddress(event.target.value)} /></label>
<label className="reporting-dialog-field reporting-dialog-span"><span>Subject</span><input value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Generated from report name" /></label>
</>}
</div>
{target?.reason && <DismissibleAlert tone="warning" dismissible={false}>{target.reason}</DismissibleAlert>}
</Dialog>
);
}
function DrillDialog({ open, loading, result, onClose }: {
open: boolean;
loading: boolean;
result: ReportingDrillResult | null;
onClose: () => void;
}) {
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [result?.drill_context_id]);
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() =>
(result?.schema ?? []).map((field) => ({
id: field.name,
header: humanize(field.name),
width: "1fr",
minWidth: 120,
resizable: true,
sortable: true,
filterable: true,
filterType: field.type === "number" || field.type === "integer" ? field.type : "text",
value: (row) => row[field.name],
render: (row) => formatValue(row[field.name])
})), [result]);
return (
<Dialog open={open} title="Authorized contributing rows" onClose={onClose} className="reporting-drill-dialog" footer={<Button onClick={onClose}>Close</Button>}>
{loading && <LoadingIndicator label="Rechecking access and loading detail rows" />}
{result && <>
<nav className="reporting-drill-path" aria-label="Drill-through filter path">
{result.dimension_path.map((item, index) => <span key={`${item.dimension}:${index}`}><strong>{item.label}</strong> = {formatValue(item.value)}</span>)}
</nav>
<div className="reporting-drill-grid">
<DataGrid
id={`reporting-drill-${result.drill_context_id}`}
rows={result.rows}
columns={columns}
getRowKey={(_row, index) => `${result.drill_context_id}:${index}`}
initialFit="container"
resizeBehavior="cover"
emptyText="No contributing rows are authorized."
pagination={{ page, pageSize: 50, onPageChange: setPage }}
/>
</div>
<small>{result.total_rows} authorized rows{result.truncated ? " (bounded result)" : ""}. Access and source fingerprints were rechecked for this drill.</small>
</>}
</Dialog>
);
}
function Inspector({ selected, execution, history, savedViews, schedules, publications, onSelectExecution, onApplySavedView, onScheduleEnabledChange }: {
selected: ReportingDefinition | null;
execution: ReportExecution | null;
history: ReportExecution[];
savedViews: ReportingSavedView[];
schedules: ReportingSchedule[];
publications: ReportingPublication[];
onSelectExecution: (execution: ReportExecution) => void;
onApplySavedView: (view: ReportingSavedView) => void;
onScheduleEnabledChange: (schedule: ReportingSchedule, enabled: boolean) => void;
}) {
return (
<div className="reporting-inspector-content">
@@ -524,6 +860,29 @@ function Inspector({ selected, execution, history, savedViews, onSelectExecution
</button>
)}
</section>
{schedules.length > 0 &&
<section>
<h2><CalendarClock size={16} /> Schedules</h2>
{schedules.map((schedule) =>
<div className="reporting-inspector-toggle" key={schedule.schedule_id}>
<span><strong>{schedule.name}</strong><small>{schedule.trigger_kind === "interval" ? `Every ${formatInterval(schedule.trigger_config.seconds)}` : "Scheduled"}</small></span>
<ToggleSwitch label="Enabled" checked={schedule.enabled} onChange={(enabled) => onScheduleEnabledChange(schedule, enabled)} />
</div>
)}
</section>
}
{publications.length > 0 &&
<section>
<h2><FolderOutput size={16} /> Publications</h2>
{publications.map((publication) =>
<div className="reporting-inspector-record" key={publication.publication_id}>
<span>{humanize(publication.target_capability.split(".").at(-1) ?? "target")}</span>
<StatusBadge status={publication.status} label={humanize(publication.status)} />
<small>{publication.completed_at ? formatDateTime(publication.completed_at) : "Pending"}</small>
</div>
)}
</section>
}
<section>
<h2><Save size={16} /> Saved views</h2>
{savedViews.length === 0 && <p>No saved views.</p>}
@@ -548,6 +907,7 @@ function Inspector({ selected, execution, history, savedViews, onSelectExecution
{item.message ?? item.code ?? "Execution diagnostic"}
</DismissibleAlert>
)}
{execution && <AccessExplanation execution={execution} />}
</section>
}
</div>
@@ -619,6 +979,48 @@ function formatDateTime(value: string): string {
return Number.isNaN(parsed.valueOf()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
}
function formatInterval(value: unknown): string {
const seconds = Number(value);
if (seconds === 3600) return "hour";
if (seconds === 86400) return "day";
if (seconds === 604800) return "week";
if (seconds === 2592000) return "30 days";
return `${Number.isFinite(seconds) ? seconds : 0} seconds`;
}
function chartPoints(values: number[], width: number, height: number): string {
if (!values.length) return "";
const finite = values.map((value) => Number.isFinite(value) ? value : 0);
const minimum = Math.min(...finite);
const maximum = Math.max(...finite);
const range = maximum - minimum || 1;
const divisor = Math.max(1, finite.length - 1);
return finite.map((value, index) => {
const x = index / divisor * width;
const y = height - ((value - minimum) / range * (height - 20) + 10);
return `${x.toFixed(2)},${y.toFixed(2)}`;
}).join(" ");
}
const PIE_COLORS = ["#2f7d6e", "#3366a8", "#c28b2c", "#9a4f71", "#5f7f3a", "#b85c3b", "#586176", "#2e8b9a"];
function pieStops(values: number[], total: number): string {
let offset = 0;
return values.slice(0, 12).map((value, index) => {
const start = offset;
offset += value / total * 100;
return `${PIE_COLORS[index % PIE_COLORS.length]} ${start.toFixed(2)}% ${offset.toFixed(2)}%`;
}).join(", ");
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function stringValues(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
function humanize(value: string): string {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
@@ -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;
}
+43 -3
View File
@@ -1,10 +1,46 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
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",
@@ -52,8 +88,12 @@ export const reportingModule: PlatformWebModule = {
{ 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.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;
+222
View File
@@ -361,6 +361,152 @@
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);
@@ -384,11 +530,87 @@
}
.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));