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
|
grants, row-policy handoff, freshness checks, and reconstructable run
|
||||||
provenance;
|
provenance;
|
||||||
- safe dimensions, hierarchies, measures, typed calculations, filters,
|
- safe dimensions, hierarchies, measures, typed calculations, filters,
|
||||||
detail/summary/pivot queries, and accessible chart models without executing
|
detail/summary/pivot queries, parameterized PostgreSQL semantic plans, and
|
||||||
arbitrary report SQL;
|
accessible chart models without executing arbitrary report SQL;
|
||||||
- quality gates, saved views, interval/scheduled runs, CSV/JSON export,
|
- quality gates, saved views, interval/scheduled runs, CSV/JSON export,
|
||||||
provider-neutral publication targets, and import activation assessments;
|
provider-neutral publication targets, and import activation assessments;
|
||||||
- a versioned, provider-neutral cross-module report contract with source-owned
|
- a versioned, provider-neutral cross-module report contract with source-owned
|
||||||
authorization, declared result schemas, privacy transforms, effective scope,
|
authorization, declared result schemas, privacy transforms, effective scope,
|
||||||
source revisions, purpose, retention, export history, and audit provenance;
|
source revisions, purpose, retention, export history, and audit provenance;
|
||||||
- a full-height Reporting workspace for running, inspecting, saving,
|
- 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
|
Reporting consumes Dataflow outputs or provider-owned read models. It does not
|
||||||
read another module's ORM tables or take ownership of ingestion and
|
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
|
requires the currently observed revision number. Existing runs continue to
|
||||||
reference the historical revisions they used.
|
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
|
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
|
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,
|
unbounded SQL connection as a report source. Configure an explicit schema,
|
||||||
freshness policy, source fingerprint expectations, purpose, privacy,
|
freshness policy, source fingerprint expectations, purpose, privacy,
|
||||||
retention, and a row-policy provider where source access alone is not enough.
|
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
|
## Access and publication
|
||||||
|
|
||||||
Tenant-visible definitions are readable by principals with Reporting read
|
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
|
execution payload and an idempotency key. It must return bounded evidence and
|
||||||
must not expose credentials in that evidence.
|
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
|
## Cross-module provider governance
|
||||||
|
|
||||||
Source modules register `reporting.report_provider.<provider-id>` capabilities;
|
Source modules register `reporting.report_provider.<provider-id>` capabilities;
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ Reporting does not own:
|
|||||||
outcomes.
|
outcomes.
|
||||||
- `reporting.chart_renderer` renders provider-neutral visual models with an
|
- `reporting.chart_renderer` renders provider-neutral visual models with an
|
||||||
accessible table fallback.
|
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.read_model:*` capabilities can expose bounded source-owned rows.
|
||||||
- `reporting.publication_target:*` capabilities can accept immutable result
|
- `reporting.publication_target:*` capabilities can accept immutable result
|
||||||
payloads without Reporting importing the target module.
|
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
|
version, output hash, diagnostics, and authorized rows are retained with the
|
||||||
execution. Failed runs also retain evidence.
|
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
|
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,
|
hierarchies, common aggregates, calculated measures, filters, sorting,
|
||||||
pagination, totals, and a provider-neutral visualization model. A saved chart
|
pagination, totals, and a provider-neutral visualization model. A saved chart
|
||||||
that is incompatible with an ad-hoc query degrades to its mandatory table
|
that is incompatible with an ad-hoc query degrades to its mandatory table
|
||||||
fallback instead of failing a valid report run.
|
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
|
Direct export supports UTF-8 CSV and JSON. CSV cells that spreadsheet software
|
||||||
could interpret as formulas are escaped. Additional formats and delivery
|
could interpret as formulas are escaped. Additional formats and delivery
|
||||||
destinations use an optional publication capability and preserve idempotent
|
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
|
The WebUI uses the platform module loader and common controls. It exposes a
|
||||||
report catalogue, parameter and semantic-query controls, result visualization
|
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
|
The global `/reports` route is owned only by Reporting. `/reporting` is a
|
||||||
documented compatibility path. Campaign's module-local aggregate view remains
|
documented compatibility path. Campaign's module-local aggregate view remains
|
||||||
at `/campaigns/reports`; when both modules are enabled, the same safe aggregate
|
at `/campaigns/reports`; when both modules are enabled, the same safe aggregate
|
||||||
@@ -151,7 +166,8 @@ contract.
|
|||||||
|
|
||||||
## Remaining Product Depth
|
## Remaining Product Depth
|
||||||
|
|
||||||
The architecture boundary is implemented. Further work is additive product
|
The architecture boundary and first operational vertical are implemented.
|
||||||
depth: richer visualization providers, drill-through navigation, packaged
|
Further work is additive product depth: packaged domain report catalogues,
|
||||||
domain report catalogues, XLSX/PDF formatting through optional providers, and
|
XLSX/PDF formatting through optional renderer providers, selector-backed Mail
|
||||||
target-environment evidence for a maturity claim above `vertical_slice`.
|
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
|
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.
|
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
|
## Inspect evidence
|
||||||
|
|
||||||
The right panel lists previous runs and definition/source pins. Select an
|
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
|
or provider diagnostics. A failed quality gate records a failed execution and
|
||||||
does not publish a result.
|
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
|
## Save and export
|
||||||
|
|
||||||
Use **Save current view** to keep the current query under your account. Saved
|
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
|
30-day interval from the current revision, parameters, and query. Scheduled
|
||||||
runs continue to use those exact pins until the schedule is edited.
|
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
|
## Run a module report
|
||||||
|
|
||||||
Module reports retain their source module's access rules. Select the source
|
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_RUNNER = "reporting.runner"
|
||||||
CAPABILITY_REPORTING_SCHEDULER = "reporting.scheduler"
|
CAPABILITY_REPORTING_SCHEDULER = "reporting.scheduler"
|
||||||
CAPABILITY_REPORTING_CHART_RENDERER = "reporting.chart_renderer"
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -119,6 +121,8 @@ def capability(registry: object | None, name: str) -> object | None:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CAPABILITY_REPORTING_CHART_RENDERER",
|
"CAPABILITY_REPORTING_CHART_RENDERER",
|
||||||
|
"CAPABILITY_REPORTING_PUBLICATION_FILES",
|
||||||
|
"CAPABILITY_REPORTING_PUBLICATION_MAIL",
|
||||||
"CAPABILITY_REPORTING_REGISTRY",
|
"CAPABILITY_REPORTING_REGISTRY",
|
||||||
"CAPABILITY_REPORTING_RUNNER",
|
"CAPABILITY_REPORTING_RUNNER",
|
||||||
"CAPABILITY_REPORTING_SCHEDULER",
|
"CAPABILITY_REPORTING_SCHEDULER",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from govoplan_reporting.backend.db.models import (
|
|||||||
ReportingDefinitionGrant,
|
ReportingDefinitionGrant,
|
||||||
ReportingDefinitionIdentity,
|
ReportingDefinitionIdentity,
|
||||||
ReportingDefinitionRevision,
|
ReportingDefinitionRevision,
|
||||||
|
ReportingDrillContext,
|
||||||
ReportingExecution,
|
ReportingExecution,
|
||||||
ReportingImportAssessment,
|
ReportingImportAssessment,
|
||||||
ReportingPublication,
|
ReportingPublication,
|
||||||
@@ -16,6 +17,7 @@ __all__ = [
|
|||||||
"ReportingDefinitionGrant",
|
"ReportingDefinitionGrant",
|
||||||
"ReportingDefinitionIdentity",
|
"ReportingDefinitionIdentity",
|
||||||
"ReportingDefinitionRevision",
|
"ReportingDefinitionRevision",
|
||||||
|
"ReportingDrillContext",
|
||||||
"ReportingExecution",
|
"ReportingExecution",
|
||||||
"ReportingImportAssessment",
|
"ReportingImportAssessment",
|
||||||
"ReportingPublication",
|
"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):
|
class ReportingQualityResult(Base, TimestampMixin):
|
||||||
__tablename__ = "reporting_quality_results"
|
__tablename__ = "reporting_quality_results"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -551,6 +595,7 @@ __all__ = [
|
|||||||
"ReportingDefinitionGrant",
|
"ReportingDefinitionGrant",
|
||||||
"ReportingDefinitionIdentity",
|
"ReportingDefinitionIdentity",
|
||||||
"ReportingDefinitionRevision",
|
"ReportingDefinitionRevision",
|
||||||
|
"ReportingDrillContext",
|
||||||
"ReportingExecution",
|
"ReportingExecution",
|
||||||
"ReportingImportAssessment",
|
"ReportingImportAssessment",
|
||||||
"ReportingPublication",
|
"ReportingPublication",
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ from govoplan_reporting.backend.domain import (
|
|||||||
ReportingDefinitionRecord,
|
ReportingDefinitionRecord,
|
||||||
definition_from_row,
|
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
|
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)
|
clean_reason = _required(change_reason, "Reporting change reason", 1_000)
|
||||||
_aware(recorded_at, "Reporting recorded_at")
|
_aware(recorded_at, "Reporting recorded_at")
|
||||||
validated_payload = validate_definition_payload(kind, dict(payload))
|
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(
|
parent_kind, parent_id, parent_revision = _parent_reference(
|
||||||
kind,
|
kind,
|
||||||
validated_payload,
|
validated_payload,
|
||||||
)
|
)
|
||||||
_validate_parent(
|
validated_payload = validate_definition_payload(
|
||||||
session,
|
kind,
|
||||||
tenant_id=tenant_id,
|
_validate_parent(
|
||||||
child_status=clean_status,
|
session,
|
||||||
parent_kind=parent_kind,
|
tenant_id=tenant_id,
|
||||||
parent_id=parent_id,
|
child_status=clean_status,
|
||||||
parent_revision=parent_revision,
|
parent_kind=parent_kind,
|
||||||
|
parent_id=parent_id,
|
||||||
|
parent_revision=parent_revision,
|
||||||
|
child_payload=validated_payload,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
request = {
|
request = {
|
||||||
"definition_kind": kind,
|
"definition_kind": kind,
|
||||||
@@ -241,14 +258,26 @@ def update_definition(
|
|||||||
kind,
|
kind,
|
||||||
dict(changes.get("payload", current.payload)),
|
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)
|
parent_kind, parent_id, parent_revision = _parent_reference(kind, next_payload)
|
||||||
_validate_parent(
|
next_payload = validate_definition_payload(
|
||||||
session,
|
kind,
|
||||||
tenant_id=tenant_id,
|
_validate_parent(
|
||||||
child_status=next_status,
|
session,
|
||||||
parent_kind=parent_kind,
|
tenant_id=tenant_id,
|
||||||
parent_id=parent_id,
|
child_status=next_status,
|
||||||
parent_revision=parent_revision,
|
parent_kind=parent_kind,
|
||||||
|
parent_id=parent_id,
|
||||||
|
parent_revision=parent_revision,
|
||||||
|
child_payload=next_payload,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
identity = _identity(session, tenant_id, kind, definition_id)
|
identity = _identity(session, tenant_id, kind, definition_id)
|
||||||
if identity is None:
|
if identity is None:
|
||||||
@@ -584,9 +613,10 @@ def _validate_parent(
|
|||||||
parent_kind: str | None,
|
parent_kind: str | None,
|
||||||
parent_id: str | None,
|
parent_id: str | None,
|
||||||
parent_revision: int | None,
|
parent_revision: int | None,
|
||||||
) -> None:
|
child_payload: Mapping[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
if parent_kind is None:
|
if parent_kind is None:
|
||||||
return
|
return dict(child_payload)
|
||||||
row = (
|
row = (
|
||||||
session.query(ReportingDefinitionRevision)
|
session.query(ReportingDefinitionRevision)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -605,6 +635,7 @@ def _validate_parent(
|
|||||||
raise ReportingDefinitionError(
|
raise ReportingDefinitionError(
|
||||||
f"An active Reporting definition requires an active {parent_kind} revision."
|
f"An active Reporting definition requires an active {parent_kind} revision."
|
||||||
)
|
)
|
||||||
|
return apply_parent_governance(child_payload, row.payload)
|
||||||
|
|
||||||
|
|
||||||
def _parent_reference(
|
def _parent_reference(
|
||||||
@@ -667,6 +698,8 @@ def _can_access(
|
|||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
return False
|
return False
|
||||||
|
if not scope_visible(row.payload, principal):
|
||||||
|
return False
|
||||||
if _has_scope(principal, ADMIN_SCOPE):
|
if _has_scope(principal, ADMIN_SCOPE):
|
||||||
return True
|
return True
|
||||||
identity = _identity(session, tenant_id, definition_kind, definition_id)
|
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:
|
def _filter_accessible(query: Query, principal: object) -> Query:
|
||||||
if _has_scope(principal, ADMIN_SCOPE):
|
if _has_scope(principal, ADMIN_SCOPE):
|
||||||
return query
|
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"]
|
conditions = [ReportingDefinitionRevision.visibility == "tenant"]
|
||||||
actor_ids = _actor_ids(principal)
|
actor_ids = _actor_ids(principal)
|
||||||
if actor_ids:
|
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(
|
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,
|
ReportingQualityResult,
|
||||||
)
|
)
|
||||||
from govoplan_reporting.backend.definitions import get_definition, list_definitions
|
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 (
|
from govoplan_reporting.backend.query_engine import (
|
||||||
QUERY_ENGINE_VERSION,
|
QUERY_ENGINE_VERSION,
|
||||||
DefaultChartRenderer,
|
DefaultChartRenderer,
|
||||||
@@ -99,7 +105,12 @@ class SqlReportingRunner:
|
|||||||
*,
|
*,
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
) -> Mapping[str, object] | None:
|
) -> 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(
|
def execute_report(
|
||||||
@@ -126,6 +137,13 @@ def execute_report(
|
|||||||
if report_record.status != "active":
|
if report_record.status != "active":
|
||||||
raise ReportingExecutionError("Only active report definitions can run.")
|
raise ReportingExecutionError("Only active report definitions can run.")
|
||||||
report = ReportDefinition.model_validate(report_record.payload)
|
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(
|
semantic_record = get_definition(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
@@ -138,6 +156,13 @@ def execute_report(
|
|||||||
"The report's pinned semantic model is unavailable or inactive."
|
"The report's pinned semantic model is unavailable or inactive."
|
||||||
)
|
)
|
||||||
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
|
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(
|
dataset_record = get_definition(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
@@ -150,8 +175,15 @@ def execute_report(
|
|||||||
"The report's pinned analytical dataset is unavailable or inactive."
|
"The report's pinned analytical dataset is unavailable or inactive."
|
||||||
)
|
)
|
||||||
dataset = DatasetDefinition.model_validate(dataset_record.payload)
|
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)
|
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(
|
clean_idempotency_key = _required(
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
"Reporting execution idempotency key",
|
"Reporting execution idempotency key",
|
||||||
@@ -176,7 +208,19 @@ def execute_report(
|
|||||||
request_sha256=request_sha256,
|
request_sha256=request_sha256,
|
||||||
)
|
)
|
||||||
if replay is not None:
|
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()
|
started_at = utc_now()
|
||||||
execution = ReportingExecution(
|
execution = ReportingExecution(
|
||||||
tenant_id=_tenant(principal),
|
tenant_id=_tenant(principal),
|
||||||
@@ -232,7 +276,14 @@ def execute_report(
|
|||||||
output_hash=source.output_hash,
|
output_hash=source.output_hash,
|
||||||
source_fingerprints=source.source_fingerprints,
|
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(
|
output_hash = _sha256(
|
||||||
{
|
{
|
||||||
"rows": result.rows,
|
"rows": result.rows,
|
||||||
@@ -244,7 +295,12 @@ def execute_report(
|
|||||||
execution.status = "succeeded"
|
execution.status = "succeeded"
|
||||||
execution.source_fingerprints = _json_value(source.source_fingerprints)
|
execution.source_fingerprints = _json_value(source.source_fingerprints)
|
||||||
execution.output_hash = output_hash
|
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_schema = list(result.schema)
|
||||||
execution.result_rows = list(result.rows)
|
execution.result_rows = list(result.rows)
|
||||||
execution.total_rows = result.total_rows
|
execution.total_rows = result.total_rows
|
||||||
@@ -259,11 +315,35 @@ def execute_report(
|
|||||||
"report_content_hash": report_record.content_hash,
|
"report_content_hash": report_record.content_hash,
|
||||||
"semantic_model_content_hash": semantic_record.content_hash,
|
"semantic_model_content_hash": semantic_record.content_hash,
|
||||||
"dataset_content_hash": dataset_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()
|
execution.finished_at = utc_now()
|
||||||
session.flush()
|
session.flush()
|
||||||
_emit_execution_event(session, execution, report_record.name)
|
_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:
|
except Exception as exc:
|
||||||
execution.status = "failed"
|
execution.status = "failed"
|
||||||
execution.finished_at = utc_now()
|
execution.finished_at = utc_now()
|
||||||
@@ -284,6 +364,7 @@ def get_execution(
|
|||||||
principal: object,
|
principal: object,
|
||||||
*,
|
*,
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
|
registry: object | None = None,
|
||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
row = (
|
row = (
|
||||||
session.query(ReportingExecution)
|
session.query(ReportingExecution)
|
||||||
@@ -304,10 +385,18 @@ def get_execution(
|
|||||||
)
|
)
|
||||||
if report_record is None:
|
if report_record is None:
|
||||||
return None
|
return None
|
||||||
|
delivery = _authorize_execution_delivery(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
registry=registry,
|
||||||
|
row=row,
|
||||||
|
report_record=report_record,
|
||||||
|
)
|
||||||
return _execution_payload(
|
return _execution_payload(
|
||||||
row,
|
row,
|
||||||
report=ReportDefinition.model_validate(report_record.payload),
|
report=ReportDefinition.model_validate(report_record.payload),
|
||||||
registry=None,
|
registry=registry,
|
||||||
|
delivery_authorization=delivery,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -317,16 +406,15 @@ def list_executions(
|
|||||||
*,
|
*,
|
||||||
report_id: str,
|
report_id: str,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
registry: object | None = None,
|
||||||
) -> tuple[dict[str, object], ...]:
|
) -> tuple[dict[str, object], ...]:
|
||||||
if (
|
current_report = get_definition(
|
||||||
get_definition(
|
session,
|
||||||
session,
|
principal,
|
||||||
principal,
|
definition_kind="report",
|
||||||
definition_kind="report",
|
definition_id=report_id,
|
||||||
definition_id=report_id,
|
)
|
||||||
)
|
if current_report is None:
|
||||||
is None
|
|
||||||
):
|
|
||||||
return ()
|
return ()
|
||||||
rows = (
|
rows = (
|
||||||
session.query(ReportingExecution)
|
session.query(ReportingExecution)
|
||||||
@@ -338,7 +426,46 @@ def list_executions(
|
|||||||
.limit(max(1, min(limit, 200)))
|
.limit(max(1, min(limit, 200)))
|
||||||
.all()
|
.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(
|
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(
|
def _execution_payload(
|
||||||
row: ReportingExecution,
|
row: ReportingExecution,
|
||||||
*,
|
*,
|
||||||
report: ReportDefinition | None,
|
report: ReportDefinition | None,
|
||||||
registry: object | None,
|
registry: object | None,
|
||||||
|
delivery_authorization: Mapping[str, object] | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
payload: dict[str, object] = {
|
payload: dict[str, object] = {
|
||||||
"execution_id": row.execution_id,
|
"execution_id": row.execution_id,
|
||||||
@@ -747,6 +1007,7 @@ def _execution_payload(
|
|||||||
"started_at": _datetime_text(row.started_at),
|
"started_at": _datetime_text(row.started_at),
|
||||||
"finished_at": _datetime_text(row.finished_at),
|
"finished_at": _datetime_text(row.finished_at),
|
||||||
"actor_id": row.actor_id,
|
"actor_id": row.actor_id,
|
||||||
|
"delivery_authorization": dict(delivery_authorization or {}),
|
||||||
}
|
}
|
||||||
if row.status == "succeeded" and report is not None:
|
if row.status == "succeeded" and report is not None:
|
||||||
result = QueryResult(
|
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,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_DATASET_OUTPUT
|
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 (
|
from govoplan_core.core.module_guards import (
|
||||||
drop_table_retirement_provider,
|
drop_table_retirement_provider,
|
||||||
persistent_table_uninstall_guard,
|
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.acl import ReportingScopeAclProvider
|
||||||
from govoplan_reporting.backend.contracts import (
|
from govoplan_reporting.backend.contracts import (
|
||||||
CAPABILITY_REPORTING_CHART_RENDERER,
|
CAPABILITY_REPORTING_CHART_RENDERER,
|
||||||
|
CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||||
|
CAPABILITY_REPORTING_PUBLICATION_MAIL,
|
||||||
CAPABILITY_REPORTING_REGISTRY,
|
CAPABILITY_REPORTING_REGISTRY,
|
||||||
CAPABILITY_REPORTING_RUNNER,
|
CAPABILITY_REPORTING_RUNNER,
|
||||||
CAPABILITY_REPORTING_SCHEDULER,
|
CAPABILITY_REPORTING_SCHEDULER,
|
||||||
@@ -63,6 +67,10 @@ from govoplan_reporting.backend.operations import (
|
|||||||
SqlReportingScheduler,
|
SqlReportingScheduler,
|
||||||
)
|
)
|
||||||
from govoplan_reporting.backend.query_engine import DefaultChartRenderer
|
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.registry import SqlReportingRegistry
|
||||||
from govoplan_reporting.backend.search_source import create_reporting_search_source
|
from govoplan_reporting.backend.search_source import create_reporting_search_source
|
||||||
|
|
||||||
@@ -175,6 +183,14 @@ def _chart_renderer(context: ModuleContext) -> DefaultChartRenderer:
|
|||||||
return 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):
|
def _retention(context: ModuleContext):
|
||||||
del context
|
del context
|
||||||
from govoplan_reporting.backend.retention import ReportingRetentionService
|
from govoplan_reporting.backend.retention import ReportingRetentionService
|
||||||
@@ -246,6 +262,8 @@ manifest = ModuleManifest(
|
|||||||
optional_capabilities=(
|
optional_capabilities=(
|
||||||
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
|
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
|
||||||
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
|
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
|
||||||
|
CAPABILITY_FILES_ARTIFACT_STORE,
|
||||||
|
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
@@ -306,6 +324,13 @@ manifest = ModuleManifest(
|
|||||||
parent_id="reporting.workspace",
|
parent_id="reporting.workspace",
|
||||||
order=40,
|
order=40,
|
||||||
),
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="reporting.widget.reports",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Reports dashboard widget",
|
||||||
|
order=75,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
@@ -313,6 +338,12 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceProvider(name="reporting.runner", version="0.1.0"),
|
ModuleInterfaceProvider(name="reporting.runner", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="reporting.scheduler", version="0.1.0"),
|
ModuleInterfaceProvider(name="reporting.scheduler", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="reporting.chart_renderer", 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"),
|
ModuleInterfaceProvider(name=CAPABILITY_REPORTING_RETENTION, version="1.0.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
@@ -328,12 +359,26 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="2.0.0",
|
version_max_exclusive="2.0.0",
|
||||||
optional=True,
|
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_factories={
|
||||||
CAPABILITY_REPORTING_REGISTRY: _registry,
|
CAPABILITY_REPORTING_REGISTRY: _registry,
|
||||||
CAPABILITY_REPORTING_RUNNER: _runner,
|
CAPABILITY_REPORTING_RUNNER: _runner,
|
||||||
CAPABILITY_REPORTING_SCHEDULER: _scheduler,
|
CAPABILITY_REPORTING_SCHEDULER: _scheduler,
|
||||||
CAPABILITY_REPORTING_CHART_RENDERER: _chart_renderer,
|
CAPABILITY_REPORTING_CHART_RENDERER: _chart_renderer,
|
||||||
|
CAPABILITY_REPORTING_PUBLICATION_FILES: _files_publication,
|
||||||
|
CAPABILITY_REPORTING_PUBLICATION_MAIL: _mail_publication,
|
||||||
CAPABILITY_REPORTING_RETENTION: _retention,
|
CAPABILITY_REPORTING_RETENTION: _retention,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
@@ -357,6 +402,16 @@ manifest = ModuleManifest(
|
|||||||
summary="Builds provider-neutral chart models with an accessible tabular fallback.",
|
summary="Builds provider-neutral chart models with an accessible tabular fallback.",
|
||||||
contract_version="0.1.0",
|
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(
|
CAPABILITY_REPORTING_RETENTION: CapabilityDocumentation(
|
||||||
label="Reporting result retention",
|
label="Reporting result retention",
|
||||||
summary="Minimizes expired provider-report detail while retaining audit hashes and provenance.",
|
summary="Minimizes expired provider-report detail while retaining audit hashes and provenance.",
|
||||||
@@ -384,6 +439,7 @@ manifest = ModuleManifest(
|
|||||||
reporting_models.ReportingSavedView,
|
reporting_models.ReportingSavedView,
|
||||||
reporting_models.ReportingDefinitionGrant,
|
reporting_models.ReportingDefinitionGrant,
|
||||||
reporting_models.ReportingExecution,
|
reporting_models.ReportingExecution,
|
||||||
|
reporting_models.ReportingDrillContext,
|
||||||
reporting_models.ReportingProviderExport,
|
reporting_models.ReportingProviderExport,
|
||||||
reporting_models.ReportingProviderExecution,
|
reporting_models.ReportingProviderExecution,
|
||||||
reporting_models.ReportingDefinitionRevision,
|
reporting_models.ReportingDefinitionRevision,
|
||||||
@@ -401,6 +457,7 @@ manifest = ModuleManifest(
|
|||||||
reporting_models.ReportingDefinitionRevision,
|
reporting_models.ReportingDefinitionRevision,
|
||||||
reporting_models.ReportingDefinitionGrant,
|
reporting_models.ReportingDefinitionGrant,
|
||||||
reporting_models.ReportingExecution,
|
reporting_models.ReportingExecution,
|
||||||
|
reporting_models.ReportingDrillContext,
|
||||||
reporting_models.ReportingProviderExecution,
|
reporting_models.ReportingProviderExecution,
|
||||||
reporting_models.ReportingProviderExport,
|
reporting_models.ReportingProviderExport,
|
||||||
reporting_models.ReportingSavedView,
|
reporting_models.ReportingSavedView,
|
||||||
@@ -429,7 +486,9 @@ manifest = ModuleManifest(
|
|||||||
"authorized result rows, diagnostics, and output hashes. Safe dimensions, "
|
"authorized result rows, diagnostics, and output hashes. Safe dimensions, "
|
||||||
"aggregations, typed expressions, filters, pivots, saved views, chart models, "
|
"aggregations, typed expressions, filters, pivots, saved views, chart models, "
|
||||||
"schedules, exports, and publication providers replace unchecked SQL in the "
|
"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",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -481,9 +540,9 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
known_limits=(
|
known_limits=(
|
||||||
"Dataflow is the first live dataset adapter; additional module read models use the provider-neutral contract.",
|
"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.",
|
"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=(
|
owned_concepts=(
|
||||||
"analytical dataset binding",
|
"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],
|
options: Mapping[str, object],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
_require_scope(principal, PUBLISH_SCOPE)
|
_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:
|
if execution_payload is None:
|
||||||
raise LookupError("Reporting execution not found.")
|
raise LookupError("Reporting execution not found.")
|
||||||
if execution_payload["status"] != "succeeded":
|
if execution_payload["status"] != "succeeded":
|
||||||
@@ -497,14 +502,54 @@ def publish_execution(
|
|||||||
return _publication_payload(publication)
|
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(
|
def export_execution(
|
||||||
session: Session,
|
session: Session,
|
||||||
principal: object,
|
principal: object,
|
||||||
*,
|
*,
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
format: str,
|
format: str,
|
||||||
|
registry: object | None = None,
|
||||||
) -> tuple[bytes, str, str]:
|
) -> 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:
|
if payload is None:
|
||||||
raise LookupError("Reporting execution not found.")
|
raise LookupError("Reporting execution not found.")
|
||||||
if payload["status"] != "succeeded":
|
if payload["status"] != "succeeded":
|
||||||
@@ -850,6 +895,7 @@ __all__ = [
|
|||||||
"dispatch_due_schedules",
|
"dispatch_due_schedules",
|
||||||
"export_execution",
|
"export_execution",
|
||||||
"list_import_assessments",
|
"list_import_assessments",
|
||||||
|
"list_publications",
|
||||||
"list_saved_views",
|
"list_saved_views",
|
||||||
"list_schedules",
|
"list_schedules",
|
||||||
"publish_execution",
|
"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(
|
return QueryResult(
|
||||||
rows=tuple(selected),
|
rows=tuple(selected),
|
||||||
total_rows=total,
|
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,
|
truncated=query.offset + len(selected) < total,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ def _sort_rows(
|
|||||||
return result
|
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))
|
names = tuple(dict.fromkeys(str(key) for row in rows for key in row))
|
||||||
return tuple(
|
return tuple(
|
||||||
{
|
{
|
||||||
@@ -491,4 +491,5 @@ __all__ = [
|
|||||||
"QueryResult",
|
"QueryResult",
|
||||||
"ReportingQueryError",
|
"ReportingQueryError",
|
||||||
"execute_semantic_query",
|
"execute_semantic_query",
|
||||||
|
"infer_query_schema",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ from govoplan_reporting.backend.definitions import (
|
|||||||
list_definitions,
|
list_definitions,
|
||||||
update_definition,
|
update_definition,
|
||||||
)
|
)
|
||||||
|
from govoplan_reporting.backend.drilldown import (
|
||||||
|
ReportingDrillError,
|
||||||
|
create_drill_context,
|
||||||
|
resolve_drill_context,
|
||||||
|
)
|
||||||
from govoplan_reporting.backend.execution import (
|
from govoplan_reporting.backend.execution import (
|
||||||
QUALITY_SCOPE,
|
QUALITY_SCOPE,
|
||||||
RUN_SCOPE,
|
RUN_SCOPE,
|
||||||
@@ -38,6 +43,7 @@ from govoplan_reporting.backend.operations import (
|
|||||||
dispatch_due_schedules,
|
dispatch_due_schedules,
|
||||||
export_execution,
|
export_execution,
|
||||||
list_import_assessments,
|
list_import_assessments,
|
||||||
|
list_publications,
|
||||||
list_saved_views,
|
list_saved_views,
|
||||||
list_schedules,
|
list_schedules,
|
||||||
publish_execution,
|
publish_execution,
|
||||||
@@ -53,10 +59,12 @@ from govoplan_reporting.backend.provider_reports import (
|
|||||||
list_provider_reports,
|
list_provider_reports,
|
||||||
provider_parameter_options,
|
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.query_engine import ReportingQueryError
|
||||||
from govoplan_reporting.backend.schemas import (
|
from govoplan_reporting.backend.schemas import (
|
||||||
DefinitionUpdateRequest,
|
DefinitionUpdateRequest,
|
||||||
DefinitionWriteRequest,
|
DefinitionWriteRequest,
|
||||||
|
DrillContextCreateRequest,
|
||||||
ImportAssessmentRequest,
|
ImportAssessmentRequest,
|
||||||
PublicationRequest,
|
PublicationRequest,
|
||||||
ProviderReportExecutionRequest,
|
ProviderReportExecutionRequest,
|
||||||
@@ -432,7 +440,13 @@ def create_router(registry: object | None) -> APIRouter:
|
|||||||
_require(principal, RUN_SCOPE)
|
_require(principal, RUN_SCOPE)
|
||||||
return {
|
return {
|
||||||
"executions": list(
|
"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),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
_require(principal, RUN_SCOPE)
|
_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:
|
if result is None:
|
||||||
raise HTTPException(status_code=404, detail="Reporting execution not found")
|
raise HTTPException(status_code=404, detail="Reporting execution not found")
|
||||||
return result
|
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")
|
@router.get("/executions/{execution_id}/export")
|
||||||
def api_export_execution(
|
def api_export_execution(
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
@@ -462,6 +534,7 @@ def create_router(registry: object | None) -> APIRouter:
|
|||||||
principal,
|
principal,
|
||||||
execution_id=execution_id,
|
execution_id=execution_id,
|
||||||
format=format,
|
format=format,
|
||||||
|
registry=registry,
|
||||||
)
|
)
|
||||||
except (ReportingOperationError, LookupError) as exc:
|
except (ReportingOperationError, LookupError) as exc:
|
||||||
raise _error(exc) from exc
|
raise _error(exc) from exc
|
||||||
@@ -493,6 +566,33 @@ def create_router(registry: object | None) -> APIRouter:
|
|||||||
raise _error(exc) from exc
|
raise _error(exc) from exc
|
||||||
return result
|
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")
|
@router.get("/reports/{report_id}/saved-views")
|
||||||
def api_list_saved_views(
|
def api_list_saved_views(
|
||||||
report_id: str,
|
report_id: str,
|
||||||
|
|||||||
@@ -58,6 +58,43 @@ class FreshnessPolicy(BaseModel):
|
|||||||
require_source_fingerprints: bool = True
|
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):
|
class DatasetDefinition(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||||
|
|
||||||
@@ -87,6 +124,7 @@ class DatasetDefinition(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
max_length=200,
|
max_length=200,
|
||||||
)
|
)
|
||||||
|
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_source_pin(self) -> "DatasetDefinition":
|
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_dimensions: list[str] = Field(default_factory=list, max_length=50)
|
||||||
default_measures: 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)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_semantics(self) -> "SemanticModelDefinition":
|
def validate_semantics(self) -> "SemanticModelDefinition":
|
||||||
@@ -307,6 +346,7 @@ class VisualizationDefinition(BaseModel):
|
|||||||
"area",
|
"area",
|
||||||
"column",
|
"column",
|
||||||
"pie",
|
"pie",
|
||||||
|
"donut",
|
||||||
"metric",
|
"metric",
|
||||||
] = "table"
|
] = "table"
|
||||||
category_dimension: str | None = Field(default=None, max_length=120)
|
category_dimension: str | None = Field(default=None, max_length=120)
|
||||||
@@ -333,6 +373,7 @@ class ReportDefinition(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
max_length=200,
|
max_length=200,
|
||||||
)
|
)
|
||||||
|
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||||
|
|
||||||
|
|
||||||
class QualityAssertion(BaseModel):
|
class QualityAssertion(BaseModel):
|
||||||
@@ -359,6 +400,7 @@ class QualityPlanDefinition(BaseModel):
|
|||||||
dataset_revision: int = Field(ge=1)
|
dataset_revision: int = Field(ge=1)
|
||||||
assertions: list[QualityAssertion] = Field(min_length=1, max_length=200)
|
assertions: list[QualityAssertion] = Field(min_length=1, max_length=200)
|
||||||
block_report_execution: bool = True
|
block_report_execution: bool = True
|
||||||
|
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||||
|
|
||||||
|
|
||||||
DEFINITION_PAYLOAD_TYPES = {
|
DEFINITION_PAYLOAD_TYPES = {
|
||||||
@@ -478,6 +520,13 @@ class PublicationRequest(BaseModel):
|
|||||||
options: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class QualityRunRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -499,9 +548,11 @@ TypedExpression.model_rebuild()
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DatasetDefinition",
|
"DatasetDefinition",
|
||||||
|
"DefinitionGovernance",
|
||||||
"DefinitionUpdateRequest",
|
"DefinitionUpdateRequest",
|
||||||
"DefinitionWriteRequest",
|
"DefinitionWriteRequest",
|
||||||
"DimensionDefinition",
|
"DimensionDefinition",
|
||||||
|
"DrillContextCreateRequest",
|
||||||
"FilterClause",
|
"FilterClause",
|
||||||
"ImportAssessmentRequest",
|
"ImportAssessmentRequest",
|
||||||
"MeasureDefinition",
|
"MeasureDefinition",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class ReportingManifestTests(unittest.TestCase):
|
|||||||
self.assertEqual("@govoplan/reporting-webui", manifest.frontend.package_name)
|
self.assertEqual("@govoplan/reporting-webui", manifest.frontend.package_name)
|
||||||
self.assertIsNotNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNotNone(manifest.migration_spec)
|
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.assertEqual(1, len(manifest.search_sources))
|
||||||
self.assertIn("dataflow", manifest.optional_dependencies)
|
self.assertIn("dataflow", manifest.optional_dependencies)
|
||||||
self.assertIn("policy", 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())
|
tables = set(inspect(connection).get_table_names())
|
||||||
assert {
|
assert {
|
||||||
"reporting_provider_executions",
|
"reporting_provider_executions",
|
||||||
|
"reporting_drill_contexts",
|
||||||
"reporting_provider_exports",
|
"reporting_provider_exports",
|
||||||
}.issubset(tables)
|
}.issubset(tables)
|
||||||
assert "b7c4e1a9d2f6" in set(
|
assert "b7c4e1a9d2f6" in set(
|
||||||
|
|||||||
@@ -70,6 +70,34 @@ assert provider.contract_version == '1.0'
|
|||||||
_run_probe(script)
|
_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:
|
def _run_probe(source: str) -> None:
|
||||||
environment = dict(os.environ)
|
environment = dict(os.environ)
|
||||||
environment["PYTHONPATH"] = os.pathsep.join(
|
environment["PYTHONPATH"] = os.pathsep.join(
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.db.base import Base
|
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_core.security.module_permissions import scopes_grant_compatible
|
||||||
from govoplan_reporting.backend.definitions import (
|
from govoplan_reporting.backend.definitions import (
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
@@ -27,6 +31,11 @@ from govoplan_reporting.backend.execution import (
|
|||||||
execute_report,
|
execute_report,
|
||||||
run_quality_plan,
|
run_quality_plan,
|
||||||
)
|
)
|
||||||
|
from govoplan_reporting.backend.drilldown import (
|
||||||
|
ReportingDrillError,
|
||||||
|
create_drill_context,
|
||||||
|
resolve_drill_context,
|
||||||
|
)
|
||||||
from govoplan_reporting.backend.operations import (
|
from govoplan_reporting.backend.operations import (
|
||||||
IMPORT_SCOPE,
|
IMPORT_SCOPE,
|
||||||
PUBLISH_SCOPE,
|
PUBLISH_SCOPE,
|
||||||
@@ -35,10 +44,24 @@ from govoplan_reporting.backend.operations import (
|
|||||||
assess_import,
|
assess_import,
|
||||||
dispatch_due_schedules,
|
dispatch_due_schedules,
|
||||||
export_execution,
|
export_execution,
|
||||||
|
list_publications,
|
||||||
|
publish_execution,
|
||||||
upsert_saved_view,
|
upsert_saved_view,
|
||||||
upsert_schedule,
|
upsert_schedule,
|
||||||
)
|
)
|
||||||
from govoplan_reporting.backend.schemas import ReportQuery
|
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)
|
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||||
@@ -79,6 +102,36 @@ class Principal:
|
|||||||
return scopes_grant_compatible(self.scopes, scope)
|
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):
|
class ReportingServiceTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
@@ -404,6 +457,216 @@ class ReportingServiceTests(unittest.TestCase):
|
|||||||
accepted_approximations=[],
|
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(
|
def _create_report_graph(
|
||||||
self,
|
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("PageScrollViewport"), "Reporting owns bounded catalogue and inspector scrolling");
|
||||||
assert.ok(page.includes("DataGrid"), "Tabular report results use the shared grid");
|
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("<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("disabledReason={runDisabledReason}"), "Governed report blockers remain keyboard-explainable");
|
||||||
assert.ok(provider.includes("DismissibleAlert"), "Provider failures and unavailable states use shared alerts");
|
assert.ok(provider.includes("DismissibleAlert"), "Provider failures and unavailable states use shared alerts");
|
||||||
assert.ok(!page.includes("window.alert("), "Reporting must not use browser alerts");
|
assert.ok(!page.includes("window.alert("), "Reporting must not use browser alerts");
|
||||||
|
|||||||
@@ -103,9 +103,69 @@ export type ReportExecution = {
|
|||||||
kind: string;
|
kind: string;
|
||||||
requested_kind?: string;
|
requested_kind?: string;
|
||||||
category?: string | null;
|
category?: string | null;
|
||||||
|
series?: string | null;
|
||||||
measures?: string[];
|
measures?: string[];
|
||||||
|
options?: Record<string, unknown>;
|
||||||
fallback_reason?: string | null;
|
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 = {
|
export type ReportingSavedView = {
|
||||||
@@ -307,6 +367,26 @@ export function listExecutions(
|
|||||||
return apiFetch(settings, `/api/v1/reporting/reports/${encodeURIComponent(reportId)}/executions?limit=30`, { signal });
|
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(
|
export function listSavedViews(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
reportId: string,
|
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(
|
export async function downloadExecution(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
executionId: string,
|
executionId: string,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
FileJson,
|
FileJson,
|
||||||
|
FolderOutput,
|
||||||
History,
|
History,
|
||||||
Play,
|
Play,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -28,27 +30,39 @@ import {
|
|||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
hasScope,
|
hasScope,
|
||||||
type DataGridColumn,
|
type DataGridColumn,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
createIntervalSchedule,
|
createIntervalSchedule,
|
||||||
|
createDrillContext,
|
||||||
downloadExecution,
|
downloadExecution,
|
||||||
getDefinition,
|
getDefinition,
|
||||||
listDefinitions,
|
listDefinitions,
|
||||||
listExecutions,
|
listExecutions,
|
||||||
|
listPublicationTargets,
|
||||||
|
listPublications,
|
||||||
listProviderReports,
|
listProviderReports,
|
||||||
listSavedViews,
|
listSavedViews,
|
||||||
|
listSchedules,
|
||||||
|
publishExecution,
|
||||||
reportPayload,
|
reportPayload,
|
||||||
runReport,
|
runReport,
|
||||||
saveView,
|
saveView,
|
||||||
semanticPayload,
|
semanticPayload,
|
||||||
|
resolveDrillContext,
|
||||||
|
updateSchedule,
|
||||||
type ReportExecution,
|
type ReportExecution,
|
||||||
|
type ReportingDrillResult,
|
||||||
type ReportingDefinition,
|
type ReportingDefinition,
|
||||||
type ReportingQuery,
|
type ReportingQuery,
|
||||||
type ReportingQueryMode,
|
type ReportingQueryMode,
|
||||||
|
type ReportingPublication,
|
||||||
|
type ReportingPublicationTarget,
|
||||||
type ReportingSavedView,
|
type ReportingSavedView,
|
||||||
|
type ReportingSchedule,
|
||||||
type ProviderReportDescriptor,
|
type ProviderReportDescriptor,
|
||||||
type SemanticModelPayload
|
type SemanticModelPayload
|
||||||
} from "../../api/reporting";
|
} from "../../api/reporting";
|
||||||
@@ -73,14 +87,22 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
const [execution, setExecution] = useState<ReportExecution | null>(null);
|
const [execution, setExecution] = useState<ReportExecution | null>(null);
|
||||||
const [history, setHistory] = useState<ReportExecution[]>([]);
|
const [history, setHistory] = useState<ReportExecution[]>([]);
|
||||||
const [savedViews, setSavedViews] = useState<ReportingSavedView[]>([]);
|
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 [outputMode, setOutputMode] = useState<OutputMode>("visual");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
const [scheduleDialogOpen, setScheduleDialogOpen] = 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 canRun = hasScope(auth, "reporting:report:run");
|
||||||
const canSchedule = hasScope(auth, "reporting:schedule:write");
|
const canSchedule = hasScope(auth, "reporting:schedule:write");
|
||||||
|
const canPublish = hasScope(auth, "reporting:report:publish");
|
||||||
|
|
||||||
const selected = useMemo(
|
const selected = useMemo(
|
||||||
() => reports.find((item) => item.definition_id === selectedId) ?? null,
|
() => reports.find((item) => item.definition_id === selectedId) ?? null,
|
||||||
@@ -102,15 +124,17 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
query: submittedSearch,
|
query: submittedSearch,
|
||||||
limit: 200
|
limit: 200
|
||||||
}, signal),
|
}, 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 providerRows = providerResult.reports.filter((item) => {
|
||||||
const query = submittedSearch.toLocaleLowerCase();
|
const query = submittedSearch.toLocaleLowerCase();
|
||||||
return !query || `${item.title} ${item.summary} ${item.provider_id}`.toLocaleLowerCase().includes(query);
|
return !query || `${item.title} ${item.summary} ${item.provider_id}`.toLocaleLowerCase().includes(query);
|
||||||
});
|
});
|
||||||
setReports(result.definitions);
|
setReports(result.definitions);
|
||||||
setProviderReports(providerRows);
|
setProviderReports(providerRows);
|
||||||
|
setPublicationTargets(targetResult.targets);
|
||||||
const currentSemanticAvailable = result.definitions.some((item) => item.definition_id === selectedId);
|
const currentSemanticAvailable = result.definitions.some((item) => item.definition_id === selectedId);
|
||||||
const currentProviderAvailable = providerRows.some((item) => `${item.provider_id}:${item.report_id}` === selectedProviderKey);
|
const currentProviderAvailable = providerRows.some((item) => `${item.provider_id}:${item.report_id}` === selectedProviderKey);
|
||||||
if (!currentSemanticAvailable && !currentProviderAvailable) {
|
if (!currentSemanticAvailable && !currentProviderAvailable) {
|
||||||
@@ -141,6 +165,7 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
setExecution(null);
|
setExecution(null);
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
setSavedViews([]);
|
setSavedViews([]);
|
||||||
|
setSchedules([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -150,12 +175,14 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
getDefinition(settings, "semantic_model", report.semantic_model_id, report.semantic_model_revision, controller.signal),
|
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: [] }),
|
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));
|
setSemantic(semanticPayload(semanticDefinition));
|
||||||
setHistory(executions.executions);
|
setHistory(executions.executions);
|
||||||
setSavedViews(views.views);
|
setSavedViews(views.views);
|
||||||
|
setSchedules(scheduleResult.schedules);
|
||||||
setExecution(executions.executions.find((item) => item.status === "succeeded") ?? null);
|
setExecution(executions.executions.find((item) => item.status === "succeeded") ?? null);
|
||||||
}).
|
}).
|
||||||
catch((reason) => {
|
catch((reason) => {
|
||||||
@@ -164,6 +191,20 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [settings, selectedId]);
|
}, [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) {
|
function submitSearch(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSubmittedSearch(search.trim());
|
setSubmittedSearch(search.trim());
|
||||||
@@ -189,6 +230,23 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
if (view.state.query) setQuery(normalizeQuery(view.state.query));
|
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 (
|
return (
|
||||||
<main className="reporting-page">
|
<main className="reporting-page">
|
||||||
<div className="reporting-shell">
|
<div className="reporting-shell">
|
||||||
@@ -302,13 +360,16 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
<span>{execution.total_rows} rows{execution.truncated ? " (truncated)" : ""}</span>
|
<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 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)))} />
|
<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>
|
||||||
<div className="reporting-output">
|
<div className="reporting-output">
|
||||||
{!execution && <div className="reporting-empty">Run the report or select a previous execution.</div>}
|
{!execution && <div className="reporting-empty">Run the report or select a previous execution.</div>}
|
||||||
{execution && outputMode === "visual" && <ReportVisual execution={execution} />}
|
{execution && outputMode === "visual" && <ReportVisual execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
|
||||||
{execution && outputMode === "table" && <ReportTable execution={execution} />}
|
{execution && outputMode === "table" && <ReportTable execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
|
||||||
</div>
|
</div>
|
||||||
</> :
|
</> :
|
||||||
<div className="reporting-empty">Select a report.</div>
|
<div className="reporting-empty">Select a report.</div>
|
||||||
@@ -322,8 +383,18 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
execution={execution}
|
execution={execution}
|
||||||
history={history}
|
history={history}
|
||||||
savedViews={savedViews}
|
savedViews={savedViews}
|
||||||
|
schedules={schedules}
|
||||||
|
publications={publications}
|
||||||
onSelectExecution={setExecution}
|
onSelectExecution={setExecution}
|
||||||
onApplySavedView={applySavedView}
|
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>
|
</PageScrollViewport>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,10 +414,28 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
onClose={() => setScheduleDialogOpen(false)}
|
onClose={() => setScheduleDialogOpen(false)}
|
||||||
onSave={async (name, seconds) => {
|
onSave={async (name, seconds) => {
|
||||||
if (!selected) return;
|
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);
|
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>
|
</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);
|
const [page, setPage] = useState(0);
|
||||||
useEffect(() => setPage(0), [execution.execution_id]);
|
useEffect(() => setPage(0), [execution.execution_id]);
|
||||||
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() =>
|
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => {
|
||||||
execution.schema.map((field) => ({
|
const result = execution.schema.map((field) => ({
|
||||||
id: field.name,
|
id: field.name,
|
||||||
header: humanize(field.name),
|
header: humanize(field.name),
|
||||||
width: "1fr",
|
width: "1fr",
|
||||||
@@ -461,7 +550,26 @@ function ReportTable({ execution }: { execution: ReportExecution }) {
|
|||||||
filterType: field.type === "integer" || field.type === "number" ? field.type : "text",
|
filterType: field.type === "integer" || field.type === "number" ? field.type : "text",
|
||||||
value: (row) => row[field.name],
|
value: (row) => row[field.name],
|
||||||
render: (row) => formatValue(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 (
|
return (
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`reporting-execution-${execution.execution_id}`}
|
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;
|
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 (
|
return (
|
||||||
<div className="reporting-visual-fallback">
|
<div className="reporting-visual-fallback">
|
||||||
{visual?.fallback_reason && <DismissibleAlert tone="info" dismissible={false}>{visual.fallback_reason}</DismissibleAlert>}
|
{visual?.fallback_reason && <DismissibleAlert tone="info" dismissible={false}>{visual.fallback_reason}</DismissibleAlert>}
|
||||||
<ReportTable execution={execution} />
|
<ReportTable execution={execution} onDrill={onDrill} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const measure = visual.measures[0];
|
const measure = visual.measures[0];
|
||||||
const values = execution.rows.map((row) => Number(row[measure] ?? 0));
|
const values = execution.rows.map((row) => Number(row[measure] ?? 0));
|
||||||
const maximum = Math.max(...values.map((value) => Math.abs(value)), 1);
|
const maximum = Math.max(...values.map((value) => Math.abs(value)), 1);
|
||||||
return (
|
if (visual.kind === "metric") {
|
||||||
<div className="reporting-bar-chart" role="img" aria-label={`${humanize(measure)} by ${humanize(visual.category)}`}>
|
return (
|
||||||
{execution.rows.map((row, index) =>
|
<div className="reporting-metric-grid">
|
||||||
<div className="reporting-bar-row" key={`${String(row[visual.category ?? ""])}:${index}`}>
|
{visual.measures.map((key) =>
|
||||||
<span>{formatValue(row[visual.category ?? ""])}</span>
|
<div className="reporting-metric" key={key}>
|
||||||
<div><i style={{ width: `${Math.max(1, Math.abs(values[index]) / maximum * 100)}%` }} /></div>
|
<span>{humanize(key)}</span>
|
||||||
<strong>{formatValue(row[measure])}</strong>
|
<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>
|
||||||
)}
|
<div className="reporting-chart-table"><ReportTable execution={execution} onDrill={onDrill} /></div>
|
||||||
<div className="reporting-chart-table"><ReportTable execution={execution} /></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>
|
</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;
|
selected: ReportingDefinition | null;
|
||||||
execution: ReportExecution | null;
|
execution: ReportExecution | null;
|
||||||
history: ReportExecution[];
|
history: ReportExecution[];
|
||||||
savedViews: ReportingSavedView[];
|
savedViews: ReportingSavedView[];
|
||||||
|
schedules: ReportingSchedule[];
|
||||||
|
publications: ReportingPublication[];
|
||||||
onSelectExecution: (execution: ReportExecution) => void;
|
onSelectExecution: (execution: ReportExecution) => void;
|
||||||
onApplySavedView: (view: ReportingSavedView) => void;
|
onApplySavedView: (view: ReportingSavedView) => void;
|
||||||
|
onScheduleEnabledChange: (schedule: ReportingSchedule, enabled: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="reporting-inspector-content">
|
<div className="reporting-inspector-content">
|
||||||
@@ -524,6 +860,29 @@ function Inspector({ selected, execution, history, savedViews, onSelectExecution
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h2><Save size={16} /> Saved views</h2>
|
<h2><Save size={16} /> Saved views</h2>
|
||||||
{savedViews.length === 0 && <p>No saved views.</p>}
|
{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"}
|
{item.message ?? item.code ?? "Execution diagnostic"}
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
)}
|
)}
|
||||||
|
{execution && <AccessExplanation execution={execution} />}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
</div>
|
</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);
|
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 {
|
function humanize(value: string): string {
|
||||||
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
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 { 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";
|
import "./styles/reporting.css";
|
||||||
|
|
||||||
|
|
||||||
const ReportingPage = lazy(() => import("./features/reporting/ReportingPage"));
|
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 = {
|
export const reportingModule: PlatformWebModule = {
|
||||||
id: "reporting",
|
id: "reporting",
|
||||||
label: "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.navigation", moduleId: "reporting", kind: "navigation", label: "Reporting navigation", order: 10 },
|
||||||
{ id: "reporting.workspace", moduleId: "reporting", kind: "route", label: "Reporting workspace", order: 20 },
|
{ 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.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;
|
export default reportingModule;
|
||||||
|
|||||||
@@ -361,6 +361,152 @@
|
|||||||
padding: 10px;
|
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 {
|
.reporting-bar-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(100px, 22%) minmax(180px, 1fr) minmax(80px, auto);
|
grid-template-columns: minmax(100px, 22%) minmax(180px, 1fr) minmax(80px, auto);
|
||||||
@@ -384,11 +530,87 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reporting-chart-table {
|
.reporting-chart-table {
|
||||||
|
grid-column: 1 / -1;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid var(--border);
|
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 {
|
.reporting-dialog-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
Reference in New Issue
Block a user