Complete governed reporting execution and publication
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
"""Add authorization-bound Reporting drill contexts.
|
||||
|
||||
Revision ID: c8d5e2f6a9b3
|
||||
Revises: b7c4e1a9d2f6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c8d5e2f6a9b3"
|
||||
down_revision = "b7c4e1a9d2f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"reporting_drill_contexts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("drill_context_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("execution_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("token_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("context_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("dimension_path", sa.JSON(), nullable=False),
|
||||
sa.Column("source_fingerprints", sa.JSON(), nullable=False),
|
||||
sa.Column("policy_provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_accessed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_drill_contexts")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"drill_context_id",
|
||||
name="uq_reporting_drill_context",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"drill_context_id",
|
||||
"execution_id",
|
||||
"actor_id",
|
||||
"expires_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_reporting_drill_contexts_{column}"),
|
||||
"reporting_drill_contexts",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_drill_context_expiry",
|
||||
"reporting_drill_contexts",
|
||||
["tenant_id", "expires_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_drill_context_execution",
|
||||
"reporting_drill_contexts",
|
||||
["tenant_id", "execution_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("reporting_drill_contexts")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user