Complete governed reporting execution and publication
This commit is contained in:
@@ -10,6 +10,8 @@ CAPABILITY_REPORTING_REGISTRY = "reporting.registry"
|
||||
CAPABILITY_REPORTING_RUNNER = "reporting.runner"
|
||||
CAPABILITY_REPORTING_SCHEDULER = "reporting.scheduler"
|
||||
CAPABILITY_REPORTING_CHART_RENDERER = "reporting.chart_renderer"
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES = "reporting.publication.files"
|
||||
CAPABILITY_REPORTING_PUBLICATION_MAIL = "reporting.publication.mail"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -119,6 +121,8 @@ def capability(registry: object | None, name: str) -> object | None:
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_REPORTING_CHART_RENDERER",
|
||||
"CAPABILITY_REPORTING_PUBLICATION_FILES",
|
||||
"CAPABILITY_REPORTING_PUBLICATION_MAIL",
|
||||
"CAPABILITY_REPORTING_REGISTRY",
|
||||
"CAPABILITY_REPORTING_RUNNER",
|
||||
"CAPABILITY_REPORTING_SCHEDULER",
|
||||
|
||||
@@ -4,6 +4,7 @@ from govoplan_reporting.backend.db.models import (
|
||||
ReportingDefinitionGrant,
|
||||
ReportingDefinitionIdentity,
|
||||
ReportingDefinitionRevision,
|
||||
ReportingDrillContext,
|
||||
ReportingExecution,
|
||||
ReportingImportAssessment,
|
||||
ReportingPublication,
|
||||
@@ -16,6 +17,7 @@ __all__ = [
|
||||
"ReportingDefinitionGrant",
|
||||
"ReportingDefinitionIdentity",
|
||||
"ReportingDefinitionRevision",
|
||||
"ReportingDrillContext",
|
||||
"ReportingExecution",
|
||||
"ReportingImportAssessment",
|
||||
"ReportingPublication",
|
||||
|
||||
@@ -484,6 +484,50 @@ class ReportingPublication(Base, TimestampMixin):
|
||||
)
|
||||
|
||||
|
||||
class ReportingDrillContext(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_drill_contexts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "drill_context_id", name="uq_reporting_drill_context"
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_drill_context_expiry",
|
||||
"tenant_id",
|
||||
"expires_at",
|
||||
),
|
||||
Index(
|
||||
"ix_reporting_drill_context_execution",
|
||||
"tenant_id",
|
||||
"execution_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
drill_context_id: Mapped[str] = mapped_column(
|
||||
String(36), nullable=False, index=True
|
||||
)
|
||||
execution_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
token_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
context_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
dimension_path: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
source_fingerprints: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
policy_provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
last_accessed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class ReportingQualityResult(Base, TimestampMixin):
|
||||
__tablename__ = "reporting_quality_results"
|
||||
__table_args__ = (
|
||||
@@ -551,6 +595,7 @@ __all__ = [
|
||||
"ReportingDefinitionGrant",
|
||||
"ReportingDefinitionIdentity",
|
||||
"ReportingDefinitionRevision",
|
||||
"ReportingDrillContext",
|
||||
"ReportingExecution",
|
||||
"ReportingImportAssessment",
|
||||
"ReportingPublication",
|
||||
|
||||
@@ -27,6 +27,11 @@ from govoplan_reporting.backend.domain import (
|
||||
ReportingDefinitionRecord,
|
||||
definition_from_row,
|
||||
)
|
||||
from govoplan_reporting.backend.governance import (
|
||||
apply_parent_governance,
|
||||
normalize_definition_governance,
|
||||
scope_visible,
|
||||
)
|
||||
from govoplan_reporting.backend.schemas import validate_definition_payload
|
||||
|
||||
|
||||
@@ -86,17 +91,29 @@ def create_definition(
|
||||
clean_reason = _required(change_reason, "Reporting change reason", 1_000)
|
||||
_aware(recorded_at, "Reporting recorded_at")
|
||||
validated_payload = validate_definition_payload(kind, dict(payload))
|
||||
validated_payload = validate_definition_payload(
|
||||
kind,
|
||||
normalize_definition_governance(
|
||||
validated_payload,
|
||||
principal,
|
||||
administrative=_has_scope(principal, ADMIN_SCOPE),
|
||||
),
|
||||
)
|
||||
parent_kind, parent_id, parent_revision = _parent_reference(
|
||||
kind,
|
||||
validated_payload,
|
||||
)
|
||||
_validate_parent(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
child_status=clean_status,
|
||||
parent_kind=parent_kind,
|
||||
parent_id=parent_id,
|
||||
parent_revision=parent_revision,
|
||||
validated_payload = validate_definition_payload(
|
||||
kind,
|
||||
_validate_parent(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
child_status=clean_status,
|
||||
parent_kind=parent_kind,
|
||||
parent_id=parent_id,
|
||||
parent_revision=parent_revision,
|
||||
child_payload=validated_payload,
|
||||
),
|
||||
)
|
||||
request = {
|
||||
"definition_kind": kind,
|
||||
@@ -241,14 +258,26 @@ def update_definition(
|
||||
kind,
|
||||
dict(changes.get("payload", current.payload)),
|
||||
)
|
||||
next_payload = validate_definition_payload(
|
||||
kind,
|
||||
normalize_definition_governance(
|
||||
next_payload,
|
||||
principal,
|
||||
administrative=_has_scope(principal, ADMIN_SCOPE),
|
||||
),
|
||||
)
|
||||
parent_kind, parent_id, parent_revision = _parent_reference(kind, next_payload)
|
||||
_validate_parent(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
child_status=next_status,
|
||||
parent_kind=parent_kind,
|
||||
parent_id=parent_id,
|
||||
parent_revision=parent_revision,
|
||||
next_payload = validate_definition_payload(
|
||||
kind,
|
||||
_validate_parent(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
child_status=next_status,
|
||||
parent_kind=parent_kind,
|
||||
parent_id=parent_id,
|
||||
parent_revision=parent_revision,
|
||||
child_payload=next_payload,
|
||||
),
|
||||
)
|
||||
identity = _identity(session, tenant_id, kind, definition_id)
|
||||
if identity is None:
|
||||
@@ -584,9 +613,10 @@ def _validate_parent(
|
||||
parent_kind: str | None,
|
||||
parent_id: str | None,
|
||||
parent_revision: int | None,
|
||||
) -> None:
|
||||
child_payload: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
if parent_kind is None:
|
||||
return
|
||||
return dict(child_payload)
|
||||
row = (
|
||||
session.query(ReportingDefinitionRevision)
|
||||
.filter(
|
||||
@@ -605,6 +635,7 @@ def _validate_parent(
|
||||
raise ReportingDefinitionError(
|
||||
f"An active Reporting definition requires an active {parent_kind} revision."
|
||||
)
|
||||
return apply_parent_governance(child_payload, row.payload)
|
||||
|
||||
|
||||
def _parent_reference(
|
||||
@@ -667,6 +698,8 @@ def _can_access(
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
if not scope_visible(row.payload, principal):
|
||||
return False
|
||||
if _has_scope(principal, ADMIN_SCOPE):
|
||||
return True
|
||||
identity = _identity(session, tenant_id, definition_kind, definition_id)
|
||||
@@ -712,6 +745,24 @@ def _require_scope(principal: object, scope: str) -> None:
|
||||
def _filter_accessible(query: Query, principal: object) -> Query:
|
||||
if _has_scope(principal, ADMIN_SCOPE):
|
||||
return query
|
||||
governance = ReportingDefinitionRevision.payload["governance"]
|
||||
scope_type = governance["scope_type"].as_string()
|
||||
scope_id = governance["scope_id"].as_string()
|
||||
inherited = governance["inherit_to_lower_scopes"].as_boolean()
|
||||
scope_conditions = [
|
||||
scope_type.is_(None),
|
||||
and_(
|
||||
scope_type == "tenant",
|
||||
or_(scope_id.is_(None), scope_id == _principal_tenant(principal)),
|
||||
),
|
||||
and_(scope_type == "system", inherited.is_(True)),
|
||||
]
|
||||
group_ids = tuple(_string_subject_ids(principal, "group_ids"))
|
||||
if group_ids:
|
||||
scope_conditions.append(and_(scope_type == "group", scope_id.in_(group_ids)))
|
||||
user_ids = _actor_ids(principal)
|
||||
if user_ids:
|
||||
scope_conditions.append(and_(scope_type == "user", scope_id.in_(user_ids)))
|
||||
conditions = [ReportingDefinitionRevision.visibility == "tenant"]
|
||||
actor_ids = _actor_ids(principal)
|
||||
if actor_ids:
|
||||
@@ -752,7 +803,14 @@ def _filter_accessible(query: Query, principal: object) -> Query:
|
||||
)
|
||||
)
|
||||
)
|
||||
return query.filter(or_(*conditions))
|
||||
return query.filter(and_(or_(*scope_conditions), or_(*conditions)))
|
||||
|
||||
|
||||
def _string_subject_ids(principal: object, attribute: str) -> tuple[str, ...]:
|
||||
raw = getattr(principal, attribute, ()) or ()
|
||||
if isinstance(raw, (str, bytes)):
|
||||
return (str(raw),) if raw else ()
|
||||
return tuple(dict.fromkeys(str(value) for value in raw if str(value or "").strip()))
|
||||
|
||||
|
||||
def _current_row(
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_reporting.backend.db.models import (
|
||||
ReportingDrillContext,
|
||||
ReportingExecution,
|
||||
)
|
||||
from govoplan_reporting.backend.definitions import get_definition
|
||||
from govoplan_reporting.backend.execution import (
|
||||
ReportingExecutionError,
|
||||
_apply_row_policy,
|
||||
_read_dataset,
|
||||
_validate_schema,
|
||||
get_execution,
|
||||
)
|
||||
from govoplan_reporting.backend.postgres_planner import execute_postgres_query
|
||||
from govoplan_reporting.backend.query_engine import execute_semantic_query
|
||||
from govoplan_reporting.backend.schemas import (
|
||||
DatasetDefinition,
|
||||
FilterClause,
|
||||
ReportDefinition,
|
||||
ReportQuery,
|
||||
SemanticModelDefinition,
|
||||
)
|
||||
|
||||
|
||||
DRILL_CONTEXT_TTL = timedelta(minutes=20)
|
||||
|
||||
|
||||
class ReportingDrillError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def create_drill_context(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
execution_id: str,
|
||||
aggregate_row: Mapping[str, object],
|
||||
limit: int,
|
||||
) -> dict[str, object]:
|
||||
execution_payload = get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
if execution_payload is None or execution_payload.get("status") != "succeeded":
|
||||
raise LookupError("Successful Reporting execution not found.")
|
||||
row = _execution(session, _tenant(principal), execution_id)
|
||||
normalized_aggregate = _json_value(dict(aggregate_row))
|
||||
if normalized_aggregate not in [
|
||||
_json_value(dict(item)) for item in row.result_rows or []
|
||||
]:
|
||||
raise ReportingDrillError(
|
||||
"The selected aggregate row does not belong to this execution."
|
||||
)
|
||||
semantic_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="semantic_model",
|
||||
definition_id=row.semantic_model_id,
|
||||
revision=row.semantic_model_revision,
|
||||
)
|
||||
if semantic_record is None:
|
||||
raise PermissionError("The report semantic model is no longer accessible.")
|
||||
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
|
||||
query = ReportQuery.model_validate(row.query or {})
|
||||
dimension_keys = _drill_dimensions(query, semantic)
|
||||
dimension_map = {item.key: item for item in semantic.dimensions}
|
||||
path = [
|
||||
{
|
||||
"dimension": key,
|
||||
"label": dimension_map[key].label,
|
||||
"value": normalized_aggregate.get(key),
|
||||
}
|
||||
for key in dimension_keys
|
||||
if key in normalized_aggregate
|
||||
]
|
||||
if not path:
|
||||
raise ReportingDrillError(
|
||||
"This aggregate has no dimension path to drill through."
|
||||
)
|
||||
bounded_limit = max(1, min(int(limit), 500))
|
||||
actor_id = _actor(principal)
|
||||
if not actor_id:
|
||||
raise ReportingDrillError("Drill-through requires an accountable actor.")
|
||||
drill_context_id = str(uuid.uuid4())
|
||||
secret = secrets.token_urlsafe(32)
|
||||
token = f"{drill_context_id}.{secret}"
|
||||
context = {
|
||||
"execution_id": execution_id,
|
||||
"output_hash": row.output_hash,
|
||||
"actor_id": actor_id,
|
||||
"dimension_path": path,
|
||||
"source_fingerprints": list(row.source_fingerprints or []),
|
||||
"limit": bounded_limit,
|
||||
}
|
||||
item = ReportingDrillContext(
|
||||
tenant_id=row.tenant_id,
|
||||
drill_context_id=drill_context_id,
|
||||
execution_id=execution_id,
|
||||
token_sha256=_sha256(token),
|
||||
context_sha256=_sha256(context),
|
||||
actor_id=actor_id,
|
||||
dimension_path=path,
|
||||
source_fingerprints=list(row.source_fingerprints or []),
|
||||
policy_provenance=dict(
|
||||
execution_payload.get("delivery_authorization") or {}
|
||||
),
|
||||
expires_at=utc_now() + DRILL_CONTEXT_TTL,
|
||||
)
|
||||
item.policy_provenance["limit"] = bounded_limit
|
||||
session.add(item)
|
||||
session.flush()
|
||||
return {
|
||||
"token": token,
|
||||
"drill_context_id": drill_context_id,
|
||||
"execution_id": execution_id,
|
||||
"dimension_path": path,
|
||||
"expires_at": _datetime_text(item.expires_at),
|
||||
}
|
||||
|
||||
|
||||
def resolve_drill_context(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
token: str,
|
||||
) -> dict[str, object]:
|
||||
context_id, separator, _secret = token.partition(".")
|
||||
if not separator or not context_id:
|
||||
raise ReportingDrillError("The drill-through context token is invalid.")
|
||||
item = (
|
||||
session.query(ReportingDrillContext)
|
||||
.filter(
|
||||
ReportingDrillContext.tenant_id == _tenant(principal),
|
||||
ReportingDrillContext.drill_context_id == context_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if item is None or not hmac.compare_digest(item.token_sha256, _sha256(token)):
|
||||
raise LookupError("Reporting drill-through context not found.")
|
||||
if item.actor_id != _actor(principal):
|
||||
raise PermissionError(
|
||||
"This drill-through context belongs to another account."
|
||||
)
|
||||
if _aware(item.expires_at) <= utc_now():
|
||||
raise ReportingDrillError("The drill-through context has expired.")
|
||||
row = _execution(session, item.tenant_id, item.execution_id)
|
||||
expected_context = {
|
||||
"execution_id": row.execution_id,
|
||||
"output_hash": row.output_hash,
|
||||
"actor_id": item.actor_id,
|
||||
"dimension_path": list(item.dimension_path or []),
|
||||
"source_fingerprints": list(item.source_fingerprints or []),
|
||||
"limit": int((item.policy_provenance or {}).get("limit", 200)),
|
||||
}
|
||||
if not hmac.compare_digest(item.context_sha256, _sha256(expected_context)):
|
||||
raise ReportingDrillError(
|
||||
"The persisted drill-through context failed its integrity check."
|
||||
)
|
||||
execution_payload = get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=row.execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
if execution_payload is None:
|
||||
raise LookupError("Reporting execution not found.")
|
||||
report_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="report",
|
||||
definition_id=row.report_id,
|
||||
revision=row.report_revision,
|
||||
)
|
||||
semantic_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="semantic_model",
|
||||
definition_id=row.semantic_model_id,
|
||||
revision=row.semantic_model_revision,
|
||||
)
|
||||
dataset_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="dataset",
|
||||
definition_id=row.dataset_id,
|
||||
revision=row.dataset_revision,
|
||||
)
|
||||
if report_record is None or semantic_record is None or dataset_record is None:
|
||||
raise PermissionError(
|
||||
"The report source graph is no longer accessible for drill-through."
|
||||
)
|
||||
report = ReportDefinition.model_validate(report_record.payload)
|
||||
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
|
||||
dataset = DatasetDefinition.model_validate(dataset_record.payload)
|
||||
source = _read_dataset(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
dataset=dataset,
|
||||
parameters=dict(row.parameters or {}),
|
||||
)
|
||||
if not _fingerprints_equal(
|
||||
item.source_fingerprints or [], source.source_fingerprints
|
||||
):
|
||||
raise ReportingExecutionError(
|
||||
"The source fingerprint changed after the aggregate execution; run the report again before drilling through."
|
||||
)
|
||||
normalized_rows = tuple(_json_value(dict(source_row)) for source_row in source.rows)
|
||||
_validate_schema(dataset, normalized_rows)
|
||||
authorized_rows, row_policy = _apply_row_policy(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
dataset_id=dataset_record.definition_id,
|
||||
dataset_revision=dataset_record.revision,
|
||||
dataset=dataset,
|
||||
rows=normalized_rows,
|
||||
)
|
||||
original = ReportQuery.model_validate(row.query or {})
|
||||
hidden_dimensions = _strings(report.access_policy.get("hidden_dimensions"))
|
||||
visible_dimensions = [
|
||||
dimension.key
|
||||
for dimension in semantic.dimensions
|
||||
if dimension.key not in hidden_dimensions
|
||||
]
|
||||
filters = list(original.filters)
|
||||
filters.extend(
|
||||
FilterClause(
|
||||
dimension=str(path_item["dimension"]),
|
||||
operator="eq",
|
||||
value=path_item.get("value"),
|
||||
)
|
||||
for path_item in item.dimension_path or []
|
||||
)
|
||||
detail_query = ReportQuery(
|
||||
mode="detail",
|
||||
dimensions=visible_dimensions,
|
||||
filters=filters,
|
||||
limit=int((item.policy_provenance or {}).get("limit", 200)),
|
||||
)
|
||||
result = execute_postgres_query(
|
||||
session,
|
||||
rows=authorized_rows,
|
||||
dataset=dataset,
|
||||
semantic_model=semantic,
|
||||
query=detail_query,
|
||||
) or execute_semantic_query(authorized_rows, semantic, detail_query)
|
||||
item.last_accessed_at = utc_now()
|
||||
item.policy_provenance = {
|
||||
**dict(item.policy_provenance or {}),
|
||||
"resolved_row_policy": dict(row_policy),
|
||||
"delivery_authorization": dict(
|
||||
execution_payload.get("delivery_authorization") or {}
|
||||
),
|
||||
}
|
||||
session.flush()
|
||||
return {
|
||||
"drill_context_id": item.drill_context_id,
|
||||
"execution_id": item.execution_id,
|
||||
"dimension_path": list(item.dimension_path or []),
|
||||
"rows": list(result.rows),
|
||||
"schema": list(result.schema),
|
||||
"total_rows": result.total_rows,
|
||||
"truncated": result.truncated or source.truncated,
|
||||
"source_fingerprints": list(source.source_fingerprints),
|
||||
"policy_provenance": dict(item.policy_provenance or {}),
|
||||
"expires_at": _datetime_text(item.expires_at),
|
||||
}
|
||||
|
||||
|
||||
def _drill_dimensions(
|
||||
query: ReportQuery,
|
||||
semantic: SemanticModelDefinition,
|
||||
) -> tuple[str, ...]:
|
||||
if query.mode == "pivot" and query.pivot is not None:
|
||||
return tuple(dict.fromkeys((*query.pivot.rows, *query.pivot.columns)))
|
||||
return tuple(query.dimensions or semantic.default_dimensions)
|
||||
|
||||
|
||||
def _execution(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
execution_id: str,
|
||||
) -> ReportingExecution:
|
||||
row = (
|
||||
session.query(ReportingExecution)
|
||||
.filter(
|
||||
ReportingExecution.tenant_id == tenant_id,
|
||||
ReportingExecution.execution_id == execution_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
raise LookupError("Reporting execution not found.")
|
||||
return row
|
||||
|
||||
|
||||
def _fingerprints_equal(
|
||||
expected: Sequence[Mapping[str, object]],
|
||||
actual: Sequence[Mapping[str, object]],
|
||||
) -> bool:
|
||||
normalize = lambda values: sorted( # noqa: E731 - compact canonicalizer
|
||||
json.dumps(
|
||||
_json_value(dict(item)),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
)
|
||||
for item in values
|
||||
)
|
||||
return normalize(expected) == normalize(actual)
|
||||
|
||||
|
||||
def _tenant(principal: object) -> str:
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id:
|
||||
raise ReportingDrillError("Drill-through requires a tenant-bound principal.")
|
||||
return tenant_id
|
||||
|
||||
|
||||
def _actor(principal: object) -> str | None:
|
||||
for value in (
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
):
|
||||
if str(value or "").strip():
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def _strings(value: object) -> set[str]:
|
||||
if not isinstance(value, (list, tuple, set, frozenset)):
|
||||
return set()
|
||||
return {str(item) for item in value if str(item).strip()}
|
||||
|
||||
|
||||
def _sha256(value: object) -> str:
|
||||
payload = value if isinstance(value, str) else json.dumps(
|
||||
_json_value(value),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _json_value(value: object) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return _aware(value).isoformat()
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _datetime_text(value: datetime) -> str:
|
||||
return _aware(value).isoformat()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DRILL_CONTEXT_TTL",
|
||||
"ReportingDrillError",
|
||||
"create_drill_context",
|
||||
"resolve_drill_context",
|
||||
]
|
||||
@@ -37,6 +37,12 @@ from govoplan_reporting.backend.db.models import (
|
||||
ReportingQualityResult,
|
||||
)
|
||||
from govoplan_reporting.backend.definitions import get_definition, list_definitions
|
||||
from govoplan_reporting.backend.domain import ReportingDefinitionRecord
|
||||
from govoplan_reporting.backend.governance import require_definition_action
|
||||
from govoplan_reporting.backend.postgres_planner import (
|
||||
POSTGRES_PLANNER_VERSION,
|
||||
execute_postgres_query,
|
||||
)
|
||||
from govoplan_reporting.backend.query_engine import (
|
||||
QUERY_ENGINE_VERSION,
|
||||
DefaultChartRenderer,
|
||||
@@ -99,7 +105,12 @@ class SqlReportingRunner:
|
||||
*,
|
||||
execution_id: str,
|
||||
) -> Mapping[str, object] | None:
|
||||
return get_execution(_session(session), principal, execution_id=execution_id)
|
||||
return get_execution(
|
||||
_session(session),
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
|
||||
def execute_report(
|
||||
@@ -126,6 +137,13 @@ def execute_report(
|
||||
if report_record.status != "active":
|
||||
raise ReportingExecutionError("Only active report definitions can run.")
|
||||
report = ReportDefinition.model_validate(report_record.payload)
|
||||
report_decision = require_definition_action(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
record=report_record,
|
||||
action="run",
|
||||
)
|
||||
semantic_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
@@ -138,6 +156,13 @@ def execute_report(
|
||||
"The report's pinned semantic model is unavailable or inactive."
|
||||
)
|
||||
semantic = SemanticModelDefinition.model_validate(semantic_record.payload)
|
||||
semantic_decision = require_definition_action(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
record=semantic_record,
|
||||
action="view",
|
||||
)
|
||||
dataset_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
@@ -150,8 +175,15 @@ def execute_report(
|
||||
"The report's pinned analytical dataset is unavailable or inactive."
|
||||
)
|
||||
dataset = DatasetDefinition.model_validate(dataset_record.payload)
|
||||
dataset_decision = require_definition_action(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
record=dataset_record,
|
||||
action="view",
|
||||
)
|
||||
bound_parameters = _bind_parameters(report, parameters)
|
||||
effective_query = query or report.default_query
|
||||
effective_query = _enforce_query_access(report, query or report.default_query)
|
||||
clean_idempotency_key = _required(
|
||||
idempotency_key,
|
||||
"Reporting execution idempotency key",
|
||||
@@ -176,7 +208,19 @@ def execute_report(
|
||||
request_sha256=request_sha256,
|
||||
)
|
||||
if replay is not None:
|
||||
return _execution_payload(replay, report=report, registry=registry)
|
||||
delivery = _authorize_execution_delivery(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
row=replay,
|
||||
report_record=report_record,
|
||||
)
|
||||
return _execution_payload(
|
||||
replay,
|
||||
report=report,
|
||||
registry=registry,
|
||||
delivery_authorization=delivery,
|
||||
)
|
||||
started_at = utc_now()
|
||||
execution = ReportingExecution(
|
||||
tenant_id=_tenant(principal),
|
||||
@@ -232,7 +276,14 @@ def execute_report(
|
||||
output_hash=source.output_hash,
|
||||
source_fingerprints=source.source_fingerprints,
|
||||
)
|
||||
result = execute_semantic_query(authorized_rows, semantic, effective_query)
|
||||
result = execute_postgres_query(
|
||||
session,
|
||||
rows=authorized_rows,
|
||||
dataset=dataset,
|
||||
semantic_model=semantic,
|
||||
query=effective_query,
|
||||
) or execute_semantic_query(authorized_rows, semantic, effective_query)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
output_hash = _sha256(
|
||||
{
|
||||
"rows": result.rows,
|
||||
@@ -244,7 +295,12 @@ def execute_report(
|
||||
execution.status = "succeeded"
|
||||
execution.source_fingerprints = _json_value(source.source_fingerprints)
|
||||
execution.output_hash = output_hash
|
||||
execution.executor_version = f"{QUERY_ENGINE_VERSION}+{source.executor_version}"
|
||||
planner_version = (
|
||||
POSTGRES_PLANNER_VERSION
|
||||
if any(item.get("code") == "postgresql_semantic_plan" for item in result.diagnostics)
|
||||
else QUERY_ENGINE_VERSION
|
||||
)
|
||||
execution.executor_version = f"{planner_version}+{source.executor_version}"
|
||||
execution.result_schema = list(result.schema)
|
||||
execution.result_rows = list(result.rows)
|
||||
execution.total_rows = result.total_rows
|
||||
@@ -259,11 +315,35 @@ def execute_report(
|
||||
"report_content_hash": report_record.content_hash,
|
||||
"semantic_model_content_hash": semantic_record.content_hash,
|
||||
"dataset_content_hash": dataset_record.content_hash,
|
||||
"definition_governance": {
|
||||
"report": report_decision.to_dict(),
|
||||
"semantic_model": semantic_decision.to_dict(),
|
||||
"dataset": dataset_decision.to_dict(),
|
||||
},
|
||||
"access_explanation": _access_explanation(
|
||||
report,
|
||||
effective_query,
|
||||
source_rows=len(normalized_rows),
|
||||
authorized_rows=len(authorized_rows),
|
||||
row_policy=policy_provenance,
|
||||
),
|
||||
}
|
||||
execution.finished_at = utc_now()
|
||||
session.flush()
|
||||
_emit_execution_event(session, execution, report_record.name)
|
||||
return _execution_payload(execution, report=report, registry=registry)
|
||||
delivery = _authorize_execution_delivery(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
row=execution,
|
||||
report_record=report_record,
|
||||
)
|
||||
return _execution_payload(
|
||||
execution,
|
||||
report=report,
|
||||
registry=registry,
|
||||
delivery_authorization=delivery,
|
||||
)
|
||||
except Exception as exc:
|
||||
execution.status = "failed"
|
||||
execution.finished_at = utc_now()
|
||||
@@ -284,6 +364,7 @@ def get_execution(
|
||||
principal: object,
|
||||
*,
|
||||
execution_id: str,
|
||||
registry: object | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
row = (
|
||||
session.query(ReportingExecution)
|
||||
@@ -304,10 +385,18 @@ def get_execution(
|
||||
)
|
||||
if report_record is None:
|
||||
return None
|
||||
delivery = _authorize_execution_delivery(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
row=row,
|
||||
report_record=report_record,
|
||||
)
|
||||
return _execution_payload(
|
||||
row,
|
||||
report=ReportDefinition.model_validate(report_record.payload),
|
||||
registry=None,
|
||||
registry=registry,
|
||||
delivery_authorization=delivery,
|
||||
)
|
||||
|
||||
|
||||
@@ -317,16 +406,15 @@ def list_executions(
|
||||
*,
|
||||
report_id: str,
|
||||
limit: int = 100,
|
||||
registry: object | None = None,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
if (
|
||||
get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="report",
|
||||
definition_id=report_id,
|
||||
)
|
||||
is None
|
||||
):
|
||||
current_report = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="report",
|
||||
definition_id=report_id,
|
||||
)
|
||||
if current_report is None:
|
||||
return ()
|
||||
rows = (
|
||||
session.query(ReportingExecution)
|
||||
@@ -338,7 +426,46 @@ def list_executions(
|
||||
.limit(max(1, min(limit, 200)))
|
||||
.all()
|
||||
)
|
||||
return tuple(_execution_payload(row, report=None, registry=None) for row in rows)
|
||||
authorization_cache: dict[tuple[int, int, int], dict[str, object]] = {}
|
||||
payloads: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
key = (
|
||||
row.report_revision,
|
||||
row.semantic_model_revision,
|
||||
row.dataset_revision,
|
||||
)
|
||||
report_record = (
|
||||
current_report
|
||||
if current_report.revision == row.report_revision
|
||||
else get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="report",
|
||||
definition_id=row.report_id,
|
||||
revision=row.report_revision,
|
||||
)
|
||||
)
|
||||
if report_record is None:
|
||||
continue
|
||||
delivery = authorization_cache.get(key)
|
||||
if delivery is None:
|
||||
delivery = _authorize_execution_delivery(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
row=row,
|
||||
report_record=report_record,
|
||||
)
|
||||
authorization_cache[key] = delivery
|
||||
payloads.append(
|
||||
_execution_payload(
|
||||
row,
|
||||
report=ReportDefinition.model_validate(report_record.payload),
|
||||
registry=registry,
|
||||
delivery_authorization=delivery,
|
||||
)
|
||||
)
|
||||
return tuple(payloads)
|
||||
|
||||
|
||||
def _read_dataset(
|
||||
@@ -717,11 +844,144 @@ def _evaluate_assertion(
|
||||
}
|
||||
|
||||
|
||||
def _enforce_query_access(
|
||||
report: ReportDefinition,
|
||||
query: ReportQuery,
|
||||
) -> ReportQuery:
|
||||
policy = report.access_policy
|
||||
hidden_dimensions = _policy_strings(policy, "hidden_dimensions")
|
||||
hidden_measures = _policy_strings(policy, "hidden_measures")
|
||||
requested_dimensions = set(query.dimensions)
|
||||
requested_dimensions.update(item.dimension for item in query.filters)
|
||||
if query.pivot is not None:
|
||||
requested_dimensions.update(query.pivot.rows)
|
||||
requested_dimensions.update(query.pivot.columns)
|
||||
requested_measures = set(query.measures)
|
||||
if query.pivot is not None:
|
||||
requested_measures.update(query.pivot.measures)
|
||||
blocked = (requested_dimensions & hidden_dimensions) | (
|
||||
requested_measures & hidden_measures
|
||||
)
|
||||
if blocked:
|
||||
raise PermissionError(
|
||||
"Policy hides requested Reporting fields: "
|
||||
+ ", ".join(sorted(blocked))
|
||||
)
|
||||
if "run" in _policy_strings(policy, "disabled_actions"):
|
||||
raise PermissionError(_policy_reason(policy, "run"))
|
||||
return query
|
||||
|
||||
|
||||
def _access_explanation(
|
||||
report: ReportDefinition,
|
||||
query: ReportQuery,
|
||||
*,
|
||||
source_rows: int,
|
||||
authorized_rows: int,
|
||||
row_policy: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
policy = report.access_policy
|
||||
hidden_dimensions = sorted(_policy_strings(policy, "hidden_dimensions"))
|
||||
hidden_measures = sorted(_policy_strings(policy, "hidden_measures"))
|
||||
disabled_actions = sorted(_policy_strings(policy, "disabled_actions"))
|
||||
reasons = policy.get("reasons")
|
||||
return {
|
||||
"hidden_dimensions": hidden_dimensions,
|
||||
"hidden_measures": hidden_measures,
|
||||
"hidden_rows": max(0, source_rows - authorized_rows),
|
||||
"disabled_actions": disabled_actions,
|
||||
"reasons": dict(reasons) if isinstance(reasons, Mapping) else {},
|
||||
"row_policy": dict(row_policy),
|
||||
"effective_query": query.model_dump(mode="json"),
|
||||
}
|
||||
|
||||
|
||||
def _authorize_execution_delivery(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
row: ReportingExecution,
|
||||
report_record: ReportingDefinitionRecord,
|
||||
) -> dict[str, object]:
|
||||
report_decision = require_definition_action(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
record=report_record,
|
||||
action="view",
|
||||
)
|
||||
semantic_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="semantic_model",
|
||||
definition_id=row.semantic_model_id,
|
||||
revision=row.semantic_model_revision,
|
||||
)
|
||||
dataset_record = get_definition(
|
||||
session,
|
||||
principal,
|
||||
definition_kind="dataset",
|
||||
definition_id=row.dataset_id,
|
||||
revision=row.dataset_revision,
|
||||
)
|
||||
if semantic_record is None or dataset_record is None:
|
||||
raise PermissionError(
|
||||
"The source definitions for this report result are no longer accessible."
|
||||
)
|
||||
semantic_decision = require_definition_action(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
record=semantic_record,
|
||||
action="view",
|
||||
)
|
||||
dataset_decision = require_definition_action(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
record=dataset_record,
|
||||
action="view",
|
||||
)
|
||||
dataset = DatasetDefinition.model_validate(dataset_record.payload)
|
||||
_empty, row_policy = _apply_row_policy(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
dataset_id=dataset_record.definition_id,
|
||||
dataset_revision=dataset_record.revision,
|
||||
dataset=dataset,
|
||||
rows=(),
|
||||
)
|
||||
return {
|
||||
"checked": True,
|
||||
"report": report_decision.to_dict(),
|
||||
"semantic_model": semantic_decision.to_dict(),
|
||||
"dataset": dataset_decision.to_dict(),
|
||||
"row_policy": dict(row_policy),
|
||||
}
|
||||
|
||||
|
||||
def _policy_strings(policy: Mapping[str, object], key: str) -> set[str]:
|
||||
raw = policy.get(key, ())
|
||||
if not isinstance(raw, (list, tuple, set, frozenset)):
|
||||
return set()
|
||||
return {str(item) for item in raw if str(item).strip()}
|
||||
|
||||
|
||||
def _policy_reason(policy: Mapping[str, object], action: str) -> str:
|
||||
reasons = policy.get("reasons")
|
||||
if isinstance(reasons, Mapping) and str(reasons.get(action) or "").strip():
|
||||
return str(reasons[action])
|
||||
return f"Policy disables the Reporting {action} action."
|
||||
|
||||
|
||||
def _execution_payload(
|
||||
row: ReportingExecution,
|
||||
*,
|
||||
report: ReportDefinition | None,
|
||||
registry: object | None,
|
||||
delivery_authorization: Mapping[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"execution_id": row.execution_id,
|
||||
@@ -747,6 +1007,7 @@ def _execution_payload(
|
||||
"started_at": _datetime_text(row.started_at),
|
||||
"finished_at": _datetime_text(row.finished_at),
|
||||
"actor_id": row.actor_id,
|
||||
"delivery_authorization": dict(delivery_authorization or {}),
|
||||
}
|
||||
if row.status == "succeeded" and report is not None:
|
||||
result = QueryResult(
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Literal, cast
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.policy import (
|
||||
DefinitionGovernanceAction,
|
||||
DefinitionGovernanceRequest,
|
||||
DefinitionScopeRef,
|
||||
PolicyDecision,
|
||||
PolicySourceStep,
|
||||
definition_governance_policy,
|
||||
)
|
||||
from govoplan_reporting.backend.domain import ReportingDefinitionRecord
|
||||
from govoplan_reporting.backend.schemas import DefinitionGovernance
|
||||
|
||||
|
||||
_LIMITS = (
|
||||
"inherit_to_lower_scopes",
|
||||
"allow_run",
|
||||
"allow_reuse",
|
||||
"allow_automation",
|
||||
)
|
||||
_SCOPE_RANK = {"system": 0, "tenant": 1, "group": 2, "user": 3}
|
||||
|
||||
|
||||
class ReportingGovernanceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_definition_governance(
|
||||
payload: Mapping[str, object],
|
||||
principal: object,
|
||||
*,
|
||||
administrative: bool,
|
||||
) -> dict[str, object]:
|
||||
result = dict(payload)
|
||||
raw = result.get("governance")
|
||||
governance = DefinitionGovernance.model_validate(
|
||||
raw if isinstance(raw, Mapping) else {}
|
||||
)
|
||||
scope_type = governance.scope_type
|
||||
scope_id = str(governance.scope_id or "").strip() or None
|
||||
tenant_id = _tenant(principal)
|
||||
if scope_type == "system":
|
||||
if not _has_scope(principal, "system:governance:write"):
|
||||
raise PermissionError(
|
||||
"System Reporting definitions require system governance permission."
|
||||
)
|
||||
elif scope_type == "tenant":
|
||||
if scope_id not in {None, tenant_id}:
|
||||
raise PermissionError(
|
||||
"Reporting definitions can only target the active tenant."
|
||||
)
|
||||
scope_id = tenant_id
|
||||
elif scope_type == "group":
|
||||
if scope_id not in _string_set(getattr(principal, "group_ids", ())):
|
||||
if not administrative:
|
||||
raise PermissionError(
|
||||
"Group Reporting definitions require membership in that group."
|
||||
)
|
||||
elif scope_type == "user":
|
||||
own_ids = {
|
||||
str(getattr(principal, "account_id", "") or ""),
|
||||
str(getattr(principal, "membership_id", "") or ""),
|
||||
}
|
||||
if scope_id not in own_ids and not administrative:
|
||||
raise PermissionError(
|
||||
"User Reporting definitions can only target the current account."
|
||||
)
|
||||
if scope_id == str(getattr(principal, "membership_id", "") or ""):
|
||||
scope_id = str(getattr(principal, "account_id", "") or "")
|
||||
effective = _effective_limits(governance)
|
||||
result["governance"] = governance.model_copy(
|
||||
update={
|
||||
"scope_id": scope_id,
|
||||
"inherit_to_lower_scopes": effective["inherit_to_lower_scopes"],
|
||||
"allow_run": effective["allow_run"],
|
||||
"allow_reuse": effective["allow_reuse"],
|
||||
"allow_automation": effective["allow_automation"],
|
||||
"source_effective_limits": dict(effective),
|
||||
}
|
||||
).model_dump(mode="json")
|
||||
return result
|
||||
|
||||
|
||||
def validate_parent_governance(
|
||||
child_payload: Mapping[str, object],
|
||||
parent_payload: Mapping[str, object],
|
||||
) -> None:
|
||||
child = _governance(child_payload)
|
||||
parent = _governance(parent_payload)
|
||||
child_scope = _scope(child)
|
||||
parent_scope = _scope(parent)
|
||||
if _SCOPE_RANK[child_scope.scope_type] < _SCOPE_RANK[parent_scope.scope_type]:
|
||||
raise ReportingGovernanceError(
|
||||
"A Reporting definition cannot broaden the scope of its parent."
|
||||
)
|
||||
if child_scope != parent_scope and not parent.inherit_to_lower_scopes:
|
||||
raise ReportingGovernanceError(
|
||||
"The parent Reporting definition is not inherited by lower scopes."
|
||||
)
|
||||
parent_limits = _effective_limits(parent)
|
||||
child_limits = _effective_limits(child)
|
||||
broadened = [key for key in _LIMITS if child_limits[key] and not parent_limits[key]]
|
||||
if broadened:
|
||||
raise ReportingGovernanceError(
|
||||
"A child Reporting definition cannot broaden inherited limits: "
|
||||
+ ", ".join(sorted(broadened))
|
||||
)
|
||||
|
||||
|
||||
def apply_parent_governance(
|
||||
child_payload: Mapping[str, object],
|
||||
parent_payload: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Persist the effective parent restriction and its immediate provenance."""
|
||||
|
||||
validate_parent_governance(child_payload, parent_payload)
|
||||
child = _governance(child_payload)
|
||||
parent = _governance(parent_payload)
|
||||
parent_limits = _effective_limits(parent)
|
||||
effective = {
|
||||
key: bool(getattr(child, key)) and parent_limits[key] for key in _LIMITS
|
||||
}
|
||||
parent_scope = {
|
||||
"scope_type": parent.scope_type,
|
||||
"scope_id": parent.scope_id,
|
||||
}
|
||||
if parent.source_scope:
|
||||
parent_scope["inherited_from"] = dict(parent.source_scope)
|
||||
result = dict(child_payload)
|
||||
result["governance"] = child.model_copy(
|
||||
update={
|
||||
"inherit_to_lower_scopes": effective["inherit_to_lower_scopes"],
|
||||
"allow_run": effective["allow_run"],
|
||||
"allow_reuse": effective["allow_reuse"],
|
||||
"allow_automation": effective["allow_automation"],
|
||||
"source_scope": parent_scope,
|
||||
"source_effective_limits": effective,
|
||||
"derivation_provenance": {
|
||||
**dict(child.derivation_provenance),
|
||||
"parent_scope": parent_scope,
|
||||
"restriction_mode": "intersection",
|
||||
},
|
||||
}
|
||||
).model_dump(mode="json")
|
||||
return result
|
||||
|
||||
|
||||
def definition_decision(
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
record: ReportingDefinitionRecord,
|
||||
action: DefinitionGovernanceAction,
|
||||
) -> PolicyDecision:
|
||||
governance = _governance(record.payload)
|
||||
source = _scope(governance)
|
||||
target = _target_scope(source, principal)
|
||||
request = DefinitionGovernanceRequest(
|
||||
module_id="reporting",
|
||||
definition_ref=f"{record.definition_kind}:{record.definition_id}:{record.revision}",
|
||||
tenant_id=_tenant(principal),
|
||||
definition_scope=source,
|
||||
target_scope=target,
|
||||
definition_kind=cast(Literal["flow", "template"], "flow"),
|
||||
action=action,
|
||||
actor=_principal_ref(principal),
|
||||
status=record.status,
|
||||
inherit_to_lower_scopes=governance.inherit_to_lower_scopes,
|
||||
allow_run=governance.allow_run,
|
||||
allow_reuse=governance.allow_reuse,
|
||||
allow_automation=governance.allow_automation,
|
||||
context={
|
||||
"ancestor_limits": dict(governance.source_effective_limits),
|
||||
"ancestor_source": dict(governance.source_scope or {}),
|
||||
"reporting_definition_kind": record.definition_kind,
|
||||
},
|
||||
)
|
||||
provider = definition_governance_policy(registry)
|
||||
if provider is not None:
|
||||
return provider.resolve_definition_action(session, request=request)
|
||||
return _fallback_decision(request)
|
||||
|
||||
|
||||
def require_definition_action(
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
registry: object | None,
|
||||
record: ReportingDefinitionRecord,
|
||||
action: DefinitionGovernanceAction,
|
||||
) -> PolicyDecision:
|
||||
decision = definition_decision(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
record=record,
|
||||
action=action,
|
||||
)
|
||||
if not decision.allowed:
|
||||
raise PermissionError(
|
||||
decision.reason or f"Reporting definition action is denied: {action}."
|
||||
)
|
||||
return decision
|
||||
|
||||
|
||||
def governance_payload(payload: Mapping[str, object]) -> dict[str, object]:
|
||||
governance = _governance(payload)
|
||||
return {
|
||||
**governance.model_dump(mode="json"),
|
||||
"effective_limits": _effective_limits(governance),
|
||||
}
|
||||
|
||||
|
||||
def scope_visible(payload: Mapping[str, object], principal: object) -> bool:
|
||||
governance = _governance(payload)
|
||||
scope = _scope(governance)
|
||||
if scope.scope_type == "system":
|
||||
return governance.inherit_to_lower_scopes or _has_scope(
|
||||
principal, "reporting:definition:admin"
|
||||
)
|
||||
if scope.scope_type == "tenant":
|
||||
return scope.scope_id in {None, _tenant(principal)}
|
||||
if scope.scope_type == "group":
|
||||
return scope.scope_id in _string_set(getattr(principal, "group_ids", ()))
|
||||
return scope.scope_id in {
|
||||
str(getattr(principal, "account_id", "") or ""),
|
||||
str(getattr(principal, "membership_id", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _fallback_decision(request: DefinitionGovernanceRequest) -> PolicyDecision:
|
||||
source = request.definition_scope
|
||||
target = request.target_scope
|
||||
same_scope = source == target
|
||||
inherited = (
|
||||
_SCOPE_RANK[target.scope_type] >= _SCOPE_RANK[source.scope_type]
|
||||
and request.inherit_to_lower_scopes
|
||||
)
|
||||
visible = same_scope or inherited
|
||||
if request.action == "view":
|
||||
allowed = visible
|
||||
elif request.action == "edit":
|
||||
allowed = same_scope
|
||||
elif request.action == "run":
|
||||
allowed = visible and request.status == "active" and request.allow_run
|
||||
elif request.action == "reuse":
|
||||
allowed = visible and request.allow_reuse
|
||||
elif request.action == "automate":
|
||||
allowed = visible and request.allow_automation
|
||||
else:
|
||||
allowed = visible and request.allow_reuse
|
||||
reason = (
|
||||
None
|
||||
if allowed
|
||||
else (
|
||||
"The Reporting definition's scope or inherited limits do not allow this action."
|
||||
)
|
||||
)
|
||||
return PolicyDecision(
|
||||
allowed=allowed,
|
||||
reason=reason,
|
||||
source_path=(
|
||||
PolicySourceStep(
|
||||
scope_type=source.scope_type,
|
||||
scope_id=source.scope_id,
|
||||
label="Reporting definition governance",
|
||||
applied_fields=_LIMITS,
|
||||
policy={
|
||||
"inherit_to_lower_scopes": request.inherit_to_lower_scopes,
|
||||
"allow_run": request.allow_run,
|
||||
"allow_reuse": request.allow_reuse,
|
||||
"allow_automation": request.allow_automation,
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=() if allowed else (f"reporting.definition.{request.action}",),
|
||||
details={
|
||||
"provider": "reporting.conservative_fallback",
|
||||
"definition_scope": source.path,
|
||||
"target_scope": target.path,
|
||||
"action": request.action,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _governance(payload: Mapping[str, object]) -> DefinitionGovernance:
|
||||
raw = payload.get("governance")
|
||||
return DefinitionGovernance.model_validate(raw if isinstance(raw, Mapping) else {})
|
||||
|
||||
|
||||
def _scope(governance: DefinitionGovernance) -> DefinitionScopeRef:
|
||||
return DefinitionScopeRef(
|
||||
scope_type=governance.scope_type,
|
||||
scope_id=governance.scope_id,
|
||||
)
|
||||
|
||||
|
||||
def _target_scope(source: DefinitionScopeRef, principal: object) -> DefinitionScopeRef:
|
||||
if source.scope_type == "group" and source.scope_id in _string_set(
|
||||
getattr(principal, "group_ids", ())
|
||||
):
|
||||
return source
|
||||
own_ids = {
|
||||
str(getattr(principal, "account_id", "") or ""),
|
||||
str(getattr(principal, "membership_id", "") or ""),
|
||||
}
|
||||
if source.scope_type == "user" and source.scope_id in own_ids:
|
||||
return source
|
||||
return DefinitionScopeRef("tenant", _tenant(principal))
|
||||
|
||||
|
||||
def _effective_limits(governance: DefinitionGovernance) -> dict[str, bool]:
|
||||
source = governance.source_effective_limits
|
||||
return {
|
||||
key: bool(getattr(governance, key)) and source.get(key, True) is True
|
||||
for key in _LIMITS
|
||||
}
|
||||
|
||||
|
||||
def _principal_ref(principal: object) -> PrincipalRef:
|
||||
converter = getattr(principal, "to_platform_principal", None)
|
||||
if callable(converter):
|
||||
converted = converter()
|
||||
if isinstance(converted, PrincipalRef):
|
||||
return converted
|
||||
return PrincipalRef(
|
||||
account_id=str(getattr(principal, "account_id", "") or "system"),
|
||||
membership_id=_optional(getattr(principal, "membership_id", None)),
|
||||
tenant_id=_tenant(principal),
|
||||
identity_id=_optional(getattr(principal, "identity_id", None)),
|
||||
scopes=frozenset(_string_set(getattr(principal, "scopes", ()))),
|
||||
group_ids=frozenset(_string_set(getattr(principal, "group_ids", ()))),
|
||||
role_ids=frozenset(_string_set(getattr(principal, "role_ids", ()))),
|
||||
function_assignment_ids=frozenset(
|
||||
_string_set(getattr(principal, "function_assignment_ids", ()))
|
||||
),
|
||||
service_account_id=_optional(getattr(principal, "service_account_id", None)),
|
||||
acting_assignment_id=_optional(
|
||||
getattr(principal, "acting_assignment_id", None)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _has_scope(principal: object, scope: str) -> bool:
|
||||
method = getattr(principal, "has", None)
|
||||
if callable(method):
|
||||
return bool(method(scope))
|
||||
return scope in _string_set(getattr(principal, "scopes", ()))
|
||||
|
||||
|
||||
def _tenant(principal: object) -> str:
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id:
|
||||
raise ReportingGovernanceError(
|
||||
"Reporting governance requires a tenant-bound principal."
|
||||
)
|
||||
return tenant_id
|
||||
|
||||
|
||||
def _string_set(value: object) -> set[str]:
|
||||
if isinstance(value, (str, bytes)):
|
||||
return {str(value)} if value else set()
|
||||
try:
|
||||
return {str(item) for item in value or () if str(item).strip()} # type: ignore[union-attr]
|
||||
except TypeError:
|
||||
return set()
|
||||
|
||||
|
||||
def _optional(value: object) -> str | None:
|
||||
clean = str(value or "").strip()
|
||||
return clean or None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ReportingGovernanceError",
|
||||
"apply_parent_governance",
|
||||
"definition_decision",
|
||||
"governance_payload",
|
||||
"normalize_definition_governance",
|
||||
"require_definition_action",
|
||||
"scope_visible",
|
||||
"validate_parent_governance",
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ReportingGovernanceError",
|
||||
"definition_decision",
|
||||
"governance_payload",
|
||||
"normalize_definition_governance",
|
||||
"require_definition_action",
|
||||
"scope_visible",
|
||||
"validate_parent_governance",
|
||||
]
|
||||
@@ -7,6 +7,8 @@ from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_DATASET_OUTPUT
|
||||
from govoplan_core.core.files import CAPABILITY_FILES_ARTIFACT_STORE
|
||||
from govoplan_core.core.mail import CAPABILITY_MAIL_NOTIFICATION_DELIVERY
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
@@ -41,6 +43,8 @@ from govoplan_core.db.base import Base
|
||||
from govoplan_reporting.backend.acl import ReportingScopeAclProvider
|
||||
from govoplan_reporting.backend.contracts import (
|
||||
CAPABILITY_REPORTING_CHART_RENDERER,
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||
CAPABILITY_REPORTING_PUBLICATION_MAIL,
|
||||
CAPABILITY_REPORTING_REGISTRY,
|
||||
CAPABILITY_REPORTING_RUNNER,
|
||||
CAPABILITY_REPORTING_SCHEDULER,
|
||||
@@ -63,6 +67,10 @@ from govoplan_reporting.backend.operations import (
|
||||
SqlReportingScheduler,
|
||||
)
|
||||
from govoplan_reporting.backend.query_engine import DefaultChartRenderer
|
||||
from govoplan_reporting.backend.publication_targets import (
|
||||
FilesReportingPublicationTarget,
|
||||
MailReportingPublicationTarget,
|
||||
)
|
||||
from govoplan_reporting.backend.registry import SqlReportingRegistry
|
||||
from govoplan_reporting.backend.search_source import create_reporting_search_source
|
||||
|
||||
@@ -175,6 +183,14 @@ def _chart_renderer(context: ModuleContext) -> DefaultChartRenderer:
|
||||
return DefaultChartRenderer()
|
||||
|
||||
|
||||
def _files_publication(context: ModuleContext) -> FilesReportingPublicationTarget:
|
||||
return FilesReportingPublicationTarget(context.registry)
|
||||
|
||||
|
||||
def _mail_publication(context: ModuleContext) -> MailReportingPublicationTarget:
|
||||
return MailReportingPublicationTarget(context.registry)
|
||||
|
||||
|
||||
def _retention(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_reporting.backend.retention import ReportingRetentionService
|
||||
@@ -246,6 +262,8 @@ manifest = ModuleManifest(
|
||||
optional_capabilities=(
|
||||
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
|
||||
CAPABILITY_POLICY_REPORTING_GOVERNANCE,
|
||||
CAPABILITY_FILES_ARTIFACT_STORE,
|
||||
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
@@ -306,6 +324,13 @@ manifest = ModuleManifest(
|
||||
parent_id="reporting.workspace",
|
||||
order=40,
|
||||
),
|
||||
ViewSurface(
|
||||
id="reporting.widget.reports",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Reports dashboard widget",
|
||||
order=75,
|
||||
),
|
||||
),
|
||||
),
|
||||
provides_interfaces=(
|
||||
@@ -313,6 +338,12 @@ manifest = ModuleManifest(
|
||||
ModuleInterfaceProvider(name="reporting.runner", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="reporting.scheduler", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="reporting.chart_renderer", version="0.1.0"),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_REPORTING_PUBLICATION_FILES, version="1.0.0"
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_REPORTING_PUBLICATION_MAIL, version="1.0.0"
|
||||
),
|
||||
ModuleInterfaceProvider(name=CAPABILITY_REPORTING_RETENTION, version="1.0.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
@@ -328,12 +359,26 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_FILES_ARTIFACT_STORE,
|
||||
version_min="0.1.14",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="mail.notification_delivery",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_REPORTING_REGISTRY: _registry,
|
||||
CAPABILITY_REPORTING_RUNNER: _runner,
|
||||
CAPABILITY_REPORTING_SCHEDULER: _scheduler,
|
||||
CAPABILITY_REPORTING_CHART_RENDERER: _chart_renderer,
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES: _files_publication,
|
||||
CAPABILITY_REPORTING_PUBLICATION_MAIL: _mail_publication,
|
||||
CAPABILITY_REPORTING_RETENTION: _retention,
|
||||
},
|
||||
capability_documentation={
|
||||
@@ -357,6 +402,16 @@ manifest = ModuleManifest(
|
||||
summary="Builds provider-neutral chart models with an accessible tabular fallback.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES: CapabilityDocumentation(
|
||||
label="Files report publication",
|
||||
summary="Stores an immutable authorized report output through Files managed artifact storage.",
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
CAPABILITY_REPORTING_PUBLICATION_MAIL: CapabilityDocumentation(
|
||||
label="Mail report publication",
|
||||
summary="Submits an idempotent report notice through Mail's durable delivery outbox.",
|
||||
contract_version="1.0.0",
|
||||
),
|
||||
CAPABILITY_REPORTING_RETENTION: CapabilityDocumentation(
|
||||
label="Reporting result retention",
|
||||
summary="Minimizes expired provider-report detail while retaining audit hashes and provenance.",
|
||||
@@ -384,6 +439,7 @@ manifest = ModuleManifest(
|
||||
reporting_models.ReportingSavedView,
|
||||
reporting_models.ReportingDefinitionGrant,
|
||||
reporting_models.ReportingExecution,
|
||||
reporting_models.ReportingDrillContext,
|
||||
reporting_models.ReportingProviderExport,
|
||||
reporting_models.ReportingProviderExecution,
|
||||
reporting_models.ReportingDefinitionRevision,
|
||||
@@ -401,6 +457,7 @@ manifest = ModuleManifest(
|
||||
reporting_models.ReportingDefinitionRevision,
|
||||
reporting_models.ReportingDefinitionGrant,
|
||||
reporting_models.ReportingExecution,
|
||||
reporting_models.ReportingDrillContext,
|
||||
reporting_models.ReportingProviderExecution,
|
||||
reporting_models.ReportingProviderExport,
|
||||
reporting_models.ReportingSavedView,
|
||||
@@ -429,7 +486,9 @@ manifest = ModuleManifest(
|
||||
"authorized result rows, diagnostics, and output hashes. Safe dimensions, "
|
||||
"aggregations, typed expressions, filters, pivots, saved views, chart models, "
|
||||
"schedules, exports, and publication providers replace unchecked SQL in the "
|
||||
"presentation layer. Dataflow and module read models remain source owners."
|
||||
"presentation layer. PostgreSQL executes bounded semantic plans when available. "
|
||||
"Signed drill contexts reauthorize contributor rows, and Files/Mail publication "
|
||||
"adapters retain idempotent evidence. Dataflow and module read models remain source owners."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -481,9 +540,9 @@ manifest = ModuleManifest(
|
||||
),
|
||||
known_limits=(
|
||||
"Dataflow is the first live dataset adapter; additional module read models use the provider-neutral contract.",
|
||||
"Direct browser export supports CSV and JSON; XLSX, PDF, Files, Mail, and DMS delivery require an optional publication provider.",
|
||||
"Direct browser export supports CSV and JSON. Files supports CSV, JSON, and HTML publication; Mail submits a bounded report notice. XLSX/PDF require a renderer provider.",
|
||||
"Import assessment produces blocking diagnostics but does not execute source SQL or automatically activate generated definitions.",
|
||||
"The initial chart provider emits a renderer-neutral model and accessible table; richer visual renderers remain replaceable adapters.",
|
||||
"The built-in chart catalogue covers bounded bar, column, line, area, pie, donut, and metric views; specialized visual renderers remain replaceable adapters.",
|
||||
),
|
||||
owned_concepts=(
|
||||
"analytical dataset binding",
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
"""Add authorization-bound Reporting drill contexts.
|
||||
|
||||
Revision ID: c8d5e2f6a9b3
|
||||
Revises: b7c4e1a9d2f6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c8d5e2f6a9b3"
|
||||
down_revision = "b7c4e1a9d2f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"reporting_drill_contexts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("drill_context_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("execution_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("token_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("context_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("dimension_path", sa.JSON(), nullable=False),
|
||||
sa.Column("source_fingerprints", sa.JSON(), nullable=False),
|
||||
sa.Column("policy_provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_accessed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_reporting_drill_contexts")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"drill_context_id",
|
||||
name="uq_reporting_drill_context",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"drill_context_id",
|
||||
"execution_id",
|
||||
"actor_id",
|
||||
"expires_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_reporting_drill_contexts_{column}"),
|
||||
"reporting_drill_contexts",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_drill_context_expiry",
|
||||
"reporting_drill_contexts",
|
||||
["tenant_id", "expires_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reporting_drill_context_execution",
|
||||
"reporting_drill_contexts",
|
||||
["tenant_id", "execution_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("reporting_drill_contexts")
|
||||
@@ -396,7 +396,12 @@ def publish_execution(
|
||||
options: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
_require_scope(principal, PUBLISH_SCOPE)
|
||||
execution_payload = get_execution(session, principal, execution_id=execution_id)
|
||||
execution_payload = get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
if execution_payload is None:
|
||||
raise LookupError("Reporting execution not found.")
|
||||
if execution_payload["status"] != "succeeded":
|
||||
@@ -497,14 +502,54 @@ def publish_execution(
|
||||
return _publication_payload(publication)
|
||||
|
||||
|
||||
def list_publications(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
execution_id: str | None = None,
|
||||
limit: int = 100,
|
||||
registry: object | None = None,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
_require_scope(principal, PUBLISH_SCOPE)
|
||||
statement = session.query(ReportingPublication).filter(
|
||||
ReportingPublication.tenant_id == _tenant(principal)
|
||||
)
|
||||
if execution_id:
|
||||
if (
|
||||
get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
is None
|
||||
):
|
||||
return ()
|
||||
statement = statement.filter(
|
||||
ReportingPublication.execution_id == execution_id
|
||||
)
|
||||
rows = (
|
||||
statement.order_by(ReportingPublication.created_at.desc())
|
||||
.limit(max(1, min(limit, 200)))
|
||||
.all()
|
||||
)
|
||||
return tuple(_publication_payload(row) for row in rows)
|
||||
|
||||
|
||||
def export_execution(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
execution_id: str,
|
||||
format: str,
|
||||
registry: object | None = None,
|
||||
) -> tuple[bytes, str, str]:
|
||||
payload = get_execution(session, principal, execution_id=execution_id)
|
||||
payload = get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
if payload is None:
|
||||
raise LookupError("Reporting execution not found.")
|
||||
if payload["status"] != "succeeded":
|
||||
@@ -850,6 +895,7 @@ __all__ = [
|
||||
"dispatch_due_schedules",
|
||||
"export_execution",
|
||||
"list_import_assessments",
|
||||
"list_publications",
|
||||
"list_saved_views",
|
||||
"list_schedules",
|
||||
"publish_execution",
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_reporting.backend.query_engine import (
|
||||
QueryResult,
|
||||
ReportingQueryError,
|
||||
infer_query_schema,
|
||||
)
|
||||
from govoplan_reporting.backend.schemas import (
|
||||
DatasetDefinition,
|
||||
DimensionDefinition,
|
||||
FilterClause,
|
||||
MeasureDefinition,
|
||||
ReportQuery,
|
||||
SemanticModelDefinition,
|
||||
TypedExpression,
|
||||
)
|
||||
|
||||
|
||||
POSTGRES_PLANNER_VERSION = "reporting-postgresql-v1"
|
||||
_IDENTIFIER = re.compile(r"^[a-z0-9._-]{1,120}$")
|
||||
|
||||
|
||||
class PostgresPlanningError(ReportingQueryError):
|
||||
pass
|
||||
|
||||
|
||||
def execute_postgres_query(
|
||||
session: Session,
|
||||
*,
|
||||
rows: Sequence[Mapping[str, object]],
|
||||
dataset: DatasetDefinition,
|
||||
semantic_model: SemanticModelDefinition,
|
||||
query: ReportQuery,
|
||||
) -> QueryResult | None:
|
||||
"""Execute a bounded semantic plan in PostgreSQL, or return None for fallback."""
|
||||
|
||||
if session.bind is None or session.bind.dialect.name != "postgresql":
|
||||
return None
|
||||
if query.mode == "pivot":
|
||||
return None
|
||||
plan = compile_postgres_query(dataset, semantic_model, query)
|
||||
parameters = {
|
||||
**plan.parameters,
|
||||
"rows_json": json.dumps(
|
||||
[_json_value(dict(item)) for item in rows],
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
),
|
||||
"result_limit": query.limit,
|
||||
"result_offset": query.offset,
|
||||
}
|
||||
result = session.execute(text(plan.sql), parameters).mappings().all()
|
||||
total_rows = int(result[0]["__reporting_total"]) if result else 0
|
||||
output = tuple(
|
||||
{
|
||||
str(key): _json_value(value)
|
||||
for key, value in item.items()
|
||||
if key != "__reporting_total"
|
||||
}
|
||||
for item in result
|
||||
)
|
||||
return QueryResult(
|
||||
rows=output,
|
||||
total_rows=total_rows,
|
||||
schema=infer_query_schema(output),
|
||||
truncated=query.offset + len(output) < total_rows,
|
||||
diagnostics=(
|
||||
{
|
||||
"severity": "info",
|
||||
"code": "postgresql_semantic_plan",
|
||||
"message": "Filters, grouping, measures, ordering, and bounds were executed by the PostgreSQL Reporting planner.",
|
||||
"planner_version": POSTGRES_PLANNER_VERSION,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CompiledPostgresPlan:
|
||||
__slots__ = ("sql", "parameters")
|
||||
|
||||
def __init__(self, sql: str, parameters: Mapping[str, object]) -> None:
|
||||
self.sql = sql
|
||||
self.parameters = dict(parameters)
|
||||
|
||||
|
||||
def compile_postgres_query(
|
||||
dataset: DatasetDefinition,
|
||||
semantic_model: SemanticModelDefinition,
|
||||
query: ReportQuery,
|
||||
) -> CompiledPostgresPlan:
|
||||
dimensions = {item.key: item for item in semantic_model.dimensions}
|
||||
measures = {item.key: item for item in semantic_model.measures}
|
||||
selected_dimensions = tuple(query.dimensions or semantic_model.default_dimensions)
|
||||
selected_measures = tuple(query.measures or semantic_model.default_measures)
|
||||
_known(selected_dimensions, dimensions, "dimensions")
|
||||
_known(selected_measures, measures, "measures")
|
||||
_known(
|
||||
tuple(item.dimension for item in query.filters), dimensions, "filter dimensions"
|
||||
)
|
||||
selected_keys = set(selected_dimensions)
|
||||
if query.mode != "detail":
|
||||
selected_keys.update(selected_measures)
|
||||
_known(
|
||||
tuple(item.key for item in query.sort),
|
||||
{key: True for key in selected_keys},
|
||||
"sort fields",
|
||||
)
|
||||
|
||||
parameters: dict[str, object] = {}
|
||||
source = (
|
||||
"WITH source AS ("
|
||||
"SELECT value AS source_row "
|
||||
"FROM jsonb_array_elements(CAST(:rows_json AS jsonb)) AS source_items(value)"
|
||||
")"
|
||||
)
|
||||
where = _filter_sql(query.filters, dimensions, parameters)
|
||||
if query.mode == "detail":
|
||||
fields = selected_dimensions
|
||||
if not fields:
|
||||
if not dataset.fields:
|
||||
raise PostgresPlanningError(
|
||||
"PostgreSQL detail planning requires selected dimensions or a pinned dataset schema."
|
||||
)
|
||||
field_types = {item.name: item.type for item in dataset.fields}
|
||||
projections = [
|
||||
f"{_source_value(item.name, item.type, parameters, f'detail_{index}')} AS {_quote(item.name)}"
|
||||
for index, item in enumerate(dataset.fields)
|
||||
]
|
||||
selected_keys = set(field_types)
|
||||
else:
|
||||
projections = [
|
||||
f"{_dimension_value(dimensions[key], parameters, f'detail_{index}')} AS {_quote(key)}"
|
||||
for index, key in enumerate(fields)
|
||||
]
|
||||
body = "SELECT " + ", ".join(projections) + " FROM source" + where
|
||||
else:
|
||||
dimension_projections = [
|
||||
(
|
||||
key,
|
||||
_dimension_value(dimensions[key], parameters, f"dimension_{index}"),
|
||||
)
|
||||
for index, key in enumerate(selected_dimensions)
|
||||
]
|
||||
selected_base_keys = [
|
||||
key
|
||||
for key in selected_measures
|
||||
if measures[key].aggregation != "calculated"
|
||||
]
|
||||
calculated = [
|
||||
measures[key]
|
||||
for key in selected_measures
|
||||
if measures[key].aggregation == "calculated"
|
||||
]
|
||||
dependency_keys = list(
|
||||
dict.fromkeys(
|
||||
dependency
|
||||
for item in calculated
|
||||
for dependency in _calculated_dependencies(
|
||||
item.expression, measures, stack=(item.key,)
|
||||
)
|
||||
)
|
||||
)
|
||||
base_measure_keys = list(dict.fromkeys((*selected_base_keys, *dependency_keys)))
|
||||
base_measures = [measures[key] for key in base_measure_keys]
|
||||
grouped_select = [
|
||||
f"{expression} AS {_quote(key)}"
|
||||
for key, expression in dimension_projections
|
||||
] + [
|
||||
f"{_aggregate_sql(item, parameters, index)} AS {_quote(item.key)}"
|
||||
for index, item in enumerate(base_measures)
|
||||
]
|
||||
if not grouped_select:
|
||||
raise PostgresPlanningError(
|
||||
"Summary queries require at least one dimension or measure."
|
||||
)
|
||||
grouped = "SELECT " + ", ".join(grouped_select) + " FROM source" + where
|
||||
if dimension_projections:
|
||||
grouped += " GROUP BY " + ", ".join(
|
||||
expression for _key, expression in dimension_projections
|
||||
)
|
||||
if calculated:
|
||||
outer = [_quote(key) for key in selected_dimensions] + [
|
||||
_quote(key) for key in selected_base_keys
|
||||
]
|
||||
outer.extend(
|
||||
f"{_calculated_sql(item.expression, parameters, f'calculated_{index}', measures=measures, stack=(item.key,))} AS {_quote(item.key)}"
|
||||
for index, item in enumerate(calculated)
|
||||
)
|
||||
body = "SELECT " + ", ".join(outer) + f" FROM ({grouped}) AS grouped"
|
||||
else:
|
||||
body = grouped
|
||||
order = ""
|
||||
if query.sort:
|
||||
order = " ORDER BY " + ", ".join(
|
||||
f"{_quote(item.key)} {item.direction.upper()} NULLS LAST"
|
||||
for item in query.sort
|
||||
)
|
||||
sql = (
|
||||
source
|
||||
+ " SELECT planned.*, COUNT(*) OVER() AS __reporting_total FROM ("
|
||||
+ body
|
||||
+ ") AS planned"
|
||||
+ order
|
||||
+ " LIMIT :result_limit OFFSET :result_offset"
|
||||
)
|
||||
return CompiledPostgresPlan(sql, parameters)
|
||||
|
||||
|
||||
def _filter_sql(
|
||||
filters: Sequence[FilterClause],
|
||||
dimensions: Mapping[str, DimensionDefinition],
|
||||
parameters: dict[str, object],
|
||||
) -> str:
|
||||
clauses: list[str] = []
|
||||
for index, clause in enumerate(filters):
|
||||
value = _dimension_value(
|
||||
dimensions[clause.dimension], parameters, f"filter_field_{index}"
|
||||
)
|
||||
prefix = f"filter_{index}"
|
||||
if clause.operator == "is_null":
|
||||
clauses.append(f"{value} IS NULL")
|
||||
continue
|
||||
if clause.operator == "not_null":
|
||||
clauses.append(f"{value} IS NOT NULL")
|
||||
continue
|
||||
if clause.operator in {"in", "not_in"}:
|
||||
if not isinstance(clause.value, (list, tuple)):
|
||||
raise PostgresPlanningError("Set filters require a list value.")
|
||||
if not clause.value or len(clause.value) > 500:
|
||||
raise PostgresPlanningError(
|
||||
"Set filters require between 1 and 500 values."
|
||||
)
|
||||
names: list[str] = []
|
||||
for item_index, item in enumerate(clause.value):
|
||||
name = f"{prefix}_{item_index}"
|
||||
parameters[name] = item
|
||||
names.append(f":{name}")
|
||||
operator = "NOT IN" if clause.operator == "not_in" else "IN"
|
||||
clauses.append(f"{value} {operator} ({', '.join(names)})")
|
||||
continue
|
||||
if clause.operator == "between":
|
||||
if not isinstance(clause.value, (list, tuple)) or len(clause.value) != 2:
|
||||
raise PostgresPlanningError("Between filters require two values.")
|
||||
parameters[f"{prefix}_low"] = clause.value[0]
|
||||
parameters[f"{prefix}_high"] = clause.value[1]
|
||||
clauses.append(f"{value} BETWEEN :{prefix}_low AND :{prefix}_high")
|
||||
continue
|
||||
parameters[prefix] = clause.value
|
||||
if clause.operator == "contains":
|
||||
parameters[prefix] = f"%{_like(str(clause.value or ''))}%"
|
||||
clauses.append(
|
||||
f"LOWER(CAST({value} AS text)) LIKE LOWER(:{prefix}) ESCAPE '\\'"
|
||||
)
|
||||
elif clause.operator == "starts_with":
|
||||
parameters[prefix] = f"{_like(str(clause.value or ''))}%"
|
||||
clauses.append(
|
||||
f"LOWER(CAST({value} AS text)) LIKE LOWER(:{prefix}) ESCAPE '\\'"
|
||||
)
|
||||
else:
|
||||
operator = {
|
||||
"eq": "=",
|
||||
"ne": "<>",
|
||||
"gt": ">",
|
||||
"gte": ">=",
|
||||
"lt": "<",
|
||||
"lte": "<=",
|
||||
}.get(clause.operator)
|
||||
if operator is None:
|
||||
raise PostgresPlanningError(
|
||||
f"Unsupported PostgreSQL filter operator: {clause.operator}."
|
||||
)
|
||||
clauses.append(f"{value} {operator} :{prefix}")
|
||||
return " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
|
||||
|
||||
def _aggregate_sql(
|
||||
measure: MeasureDefinition,
|
||||
parameters: dict[str, object],
|
||||
index: int,
|
||||
) -> str:
|
||||
if measure.aggregation == "count" and measure.field is None:
|
||||
return "COUNT(*)"
|
||||
field = _source_value(
|
||||
measure.field or "",
|
||||
"number" if measure.aggregation in {"sum", "average"} else "string",
|
||||
parameters,
|
||||
f"measure_{index}",
|
||||
)
|
||||
if measure.aggregation == "count":
|
||||
return f"COUNT({field})"
|
||||
if measure.aggregation == "count_distinct":
|
||||
return f"COUNT(DISTINCT {field})"
|
||||
function = {
|
||||
"sum": "SUM",
|
||||
"average": "AVG",
|
||||
"minimum": "MIN",
|
||||
"maximum": "MAX",
|
||||
}.get(measure.aggregation)
|
||||
if function is None:
|
||||
raise PostgresPlanningError(
|
||||
f"Unsupported PostgreSQL aggregation: {measure.aggregation}."
|
||||
)
|
||||
return f"{function}({field})"
|
||||
|
||||
|
||||
def _calculated_sql(
|
||||
expression: TypedExpression | None,
|
||||
parameters: dict[str, object],
|
||||
prefix: str,
|
||||
*,
|
||||
measures: Mapping[str, MeasureDefinition],
|
||||
stack: tuple[str, ...],
|
||||
) -> str:
|
||||
if expression is None:
|
||||
return "NULL"
|
||||
if expression.op == "literal":
|
||||
parameters[prefix] = expression.value
|
||||
return f":{prefix}"
|
||||
if expression.op == "measure":
|
||||
reference = expression.ref or ""
|
||||
target = measures.get(reference)
|
||||
if target is None:
|
||||
raise PostgresPlanningError(
|
||||
f"Calculated measure references unknown measure: {reference}."
|
||||
)
|
||||
if target.aggregation != "calculated":
|
||||
return _quote(reference)
|
||||
if reference in stack:
|
||||
raise PostgresPlanningError(
|
||||
"Calculated measure dependency cycle: "
|
||||
+ " -> ".join((*stack, reference))
|
||||
)
|
||||
return _calculated_sql(
|
||||
target.expression,
|
||||
parameters,
|
||||
prefix + "_" + reference,
|
||||
measures=measures,
|
||||
stack=(*stack, reference),
|
||||
)
|
||||
if expression.op == "field":
|
||||
raise PostgresPlanningError(
|
||||
"Calculated aggregate measures may reference measures, not source fields."
|
||||
)
|
||||
values = [
|
||||
_calculated_sql(
|
||||
item,
|
||||
parameters,
|
||||
f"{prefix}_{index}",
|
||||
measures=measures,
|
||||
stack=stack,
|
||||
)
|
||||
for index, item in enumerate(expression.args)
|
||||
]
|
||||
if expression.op in {"add", "multiply", "and", "or"}:
|
||||
operator = {"add": "+", "multiply": "*", "and": "AND", "or": "OR"}[
|
||||
expression.op
|
||||
]
|
||||
return "(" + f" {operator} ".join(values) + ")"
|
||||
if expression.op in {"subtract", "divide", "eq", "ne", "gt", "gte", "lt", "lte"}:
|
||||
if len(values) != 2:
|
||||
raise PostgresPlanningError(
|
||||
f"Expression {expression.op} requires exactly two arguments."
|
||||
)
|
||||
operator = {
|
||||
"subtract": "-",
|
||||
"divide": "/",
|
||||
"eq": "=",
|
||||
"ne": "<>",
|
||||
"gt": ">",
|
||||
"gte": ">=",
|
||||
"lt": "<",
|
||||
"lte": "<=",
|
||||
}[expression.op]
|
||||
right = f"NULLIF({values[1]}, 0)" if expression.op == "divide" else values[1]
|
||||
return f"({values[0]} {operator} {right})"
|
||||
if expression.op == "not":
|
||||
if len(values) != 1:
|
||||
raise PostgresPlanningError("Expression not requires one argument.")
|
||||
return f"(NOT {values[0]})"
|
||||
if expression.op == "coalesce":
|
||||
return "COALESCE(" + ", ".join(values) + ")"
|
||||
if expression.op == "case":
|
||||
if len(values) < 3 or len(values) % 2 == 0:
|
||||
raise PostgresPlanningError(
|
||||
"Case expressions require condition/value pairs and a default."
|
||||
)
|
||||
branches = " ".join(
|
||||
f"WHEN {values[index]} THEN {values[index + 1]}"
|
||||
for index in range(0, len(values) - 1, 2)
|
||||
)
|
||||
return f"(CASE {branches} ELSE {values[-1]} END)"
|
||||
raise PostgresPlanningError(
|
||||
f"Unsupported PostgreSQL expression operator: {expression.op}."
|
||||
)
|
||||
|
||||
|
||||
def _calculated_dependencies(
|
||||
expression: TypedExpression | None,
|
||||
measures: Mapping[str, MeasureDefinition],
|
||||
*,
|
||||
stack: tuple[str, ...],
|
||||
) -> tuple[str, ...]:
|
||||
if expression is None:
|
||||
return ()
|
||||
if expression.op == "measure":
|
||||
reference = expression.ref or ""
|
||||
target = measures.get(reference)
|
||||
if target is None:
|
||||
raise PostgresPlanningError(
|
||||
f"Calculated measure references unknown measure: {reference}."
|
||||
)
|
||||
if target.aggregation != "calculated":
|
||||
return (reference,)
|
||||
if reference in stack:
|
||||
raise PostgresPlanningError(
|
||||
"Calculated measure dependency cycle: "
|
||||
+ " -> ".join((*stack, reference))
|
||||
)
|
||||
return _calculated_dependencies(
|
||||
target.expression,
|
||||
measures,
|
||||
stack=(*stack, reference),
|
||||
)
|
||||
dependencies: list[str] = []
|
||||
for item in expression.args:
|
||||
dependencies.extend(_calculated_dependencies(item, measures, stack=stack))
|
||||
return tuple(dict.fromkeys(dependencies))
|
||||
|
||||
|
||||
def _dimension_value(
|
||||
dimension: DimensionDefinition,
|
||||
parameters: dict[str, object],
|
||||
prefix: str,
|
||||
) -> str:
|
||||
return _source_value(dimension.field, dimension.type, parameters, prefix)
|
||||
|
||||
|
||||
def _source_value(
|
||||
field: str,
|
||||
field_type: str,
|
||||
parameters: dict[str, object],
|
||||
prefix: str,
|
||||
) -> str:
|
||||
parameters[prefix] = field
|
||||
raw = f"source_row ->> :{prefix}"
|
||||
if field_type == "integer":
|
||||
return f"NULLIF({raw}, '')::bigint"
|
||||
if field_type == "number":
|
||||
return f"NULLIF({raw}, '')::numeric"
|
||||
if field_type == "boolean":
|
||||
return f"NULLIF({raw}, '')::boolean"
|
||||
if field_type == "date":
|
||||
return f"NULLIF({raw}, '')::date"
|
||||
if field_type == "datetime":
|
||||
return f"NULLIF({raw}, '')::timestamptz"
|
||||
if field_type == "json":
|
||||
return f"source_row -> :{prefix}"
|
||||
return raw
|
||||
|
||||
|
||||
def _known(keys: Sequence[str], available: Mapping[str, object], label: str) -> None:
|
||||
unknown = set(keys) - set(available)
|
||||
if unknown:
|
||||
raise PostgresPlanningError(
|
||||
f"Report query references unknown {label}: " + ", ".join(sorted(unknown))
|
||||
)
|
||||
|
||||
|
||||
def _quote(value: str) -> str:
|
||||
if not _IDENTIFIER.fullmatch(value):
|
||||
raise PostgresPlanningError(f"Unsafe Reporting identifier: {value!r}.")
|
||||
return '"' + value.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def _like(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _json_value(value: object) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
integral = value.to_integral_value()
|
||||
return int(integral) if value == integral else float(value)
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"POSTGRES_PLANNER_VERSION",
|
||||
"CompiledPostgresPlan",
|
||||
"PostgresPlanningError",
|
||||
"compile_postgres_query",
|
||||
"execute_postgres_query",
|
||||
]
|
||||
@@ -0,0 +1,303 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
import csv
|
||||
from html import escape
|
||||
from io import StringIO
|
||||
import json
|
||||
import re
|
||||
|
||||
from govoplan_core.core.files import (
|
||||
CAPABILITY_FILES_ARTIFACT_STORE,
|
||||
ManagedArtifactStore,
|
||||
ManagedArtifactWriteRequest,
|
||||
)
|
||||
from govoplan_core.core.mail import (
|
||||
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
|
||||
NotificationMailDeliveryProvider,
|
||||
NotificationMailDeliveryRequest,
|
||||
)
|
||||
from govoplan_reporting.backend.contracts import (
|
||||
CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||
CAPABILITY_REPORTING_PUBLICATION_MAIL,
|
||||
ReportingPublicationPayload,
|
||||
capability,
|
||||
)
|
||||
|
||||
|
||||
class FilesReportingPublicationTarget:
|
||||
def __init__(self, registry: object | None) -> None:
|
||||
self.registry = registry
|
||||
|
||||
def publish_report(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
payload: ReportingPublicationPayload,
|
||||
) -> Mapping[str, object]:
|
||||
provider = capability(self.registry, CAPABILITY_FILES_ARTIFACT_STORE)
|
||||
if not isinstance(provider, ManagedArtifactStore):
|
||||
raise RuntimeError(
|
||||
"Files publication requires the enabled files.artifact_store capability."
|
||||
)
|
||||
content, content_type, extension = _serialize(payload)
|
||||
filename = _filename(payload, extension)
|
||||
folder = str(payload.target_ref or "Generated/Reports").strip()
|
||||
stored = provider.store_artifact(
|
||||
session,
|
||||
principal,
|
||||
request=ManagedArtifactWriteRequest(
|
||||
filename=filename,
|
||||
payload=content,
|
||||
content_type=content_type,
|
||||
folder=folder,
|
||||
description=(
|
||||
f"Reporting publication for {payload.report_id} revision "
|
||||
f"{payload.report_revision}."
|
||||
),
|
||||
idempotency_key=f"reporting:{payload.publication_id}",
|
||||
metadata={
|
||||
"producer_module": "reporting",
|
||||
"publication_id": payload.publication_id,
|
||||
"execution_id": payload.execution_id,
|
||||
"report_id": payload.report_id,
|
||||
"report_revision": payload.report_revision,
|
||||
"output_hash": payload.output_hash,
|
||||
},
|
||||
),
|
||||
)
|
||||
return {
|
||||
"provider": CAPABILITY_FILES_ARTIFACT_STORE,
|
||||
"status": "stored",
|
||||
"file_asset_id": stored.file_asset_id,
|
||||
"file_version_id": stored.file_version_id,
|
||||
"filename": stored.filename,
|
||||
"display_path": stored.display_path,
|
||||
"sha256": stored.sha256,
|
||||
"size_bytes": stored.size_bytes,
|
||||
"output_hash": payload.output_hash,
|
||||
}
|
||||
|
||||
|
||||
class MailReportingPublicationTarget:
|
||||
def __init__(self, registry: object | None) -> None:
|
||||
self.registry = registry
|
||||
|
||||
def publish_report(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
payload: ReportingPublicationPayload,
|
||||
) -> Mapping[str, object]:
|
||||
provider = capability(self.registry, CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
|
||||
if not isinstance(provider, NotificationMailDeliveryProvider):
|
||||
raise RuntimeError(
|
||||
"Mail publication requires the enabled mail.notificationDelivery capability."
|
||||
)
|
||||
recipient = str(payload.target_ref or "").strip()
|
||||
if not recipient:
|
||||
raise ValueError("Mail publication requires a recipient address.")
|
||||
options = dict(payload.options)
|
||||
profile_id = _required_option(options, "mail_profile_id", "Mail profile")
|
||||
from_address = _required_option(options, "from_address", "Sender address")
|
||||
subject = str(
|
||||
options.get("subject")
|
||||
or f"Report {payload.report_id} revision {payload.report_revision}"
|
||||
).strip()
|
||||
action_url = str(options.get("action_url") or "").strip() or None
|
||||
preview = _text_preview(payload.rows, payload.schema)
|
||||
result = provider.submit_notification_mail(
|
||||
session,
|
||||
NotificationMailDeliveryRequest(
|
||||
tenant_id=payload.tenant_id,
|
||||
notification_id=f"reporting-publication:{payload.publication_id}",
|
||||
recipient=recipient,
|
||||
subject=subject,
|
||||
body_text=(
|
||||
f"Report: {payload.report_id}\n"
|
||||
f"Revision: {payload.report_revision}\n"
|
||||
f"Rows: {len(payload.rows)}\n"
|
||||
f"Output hash: {payload.output_hash}\n\n"
|
||||
f"{preview}"
|
||||
),
|
||||
action_url=action_url,
|
||||
mail_profile_id=profile_id,
|
||||
from_address=from_address,
|
||||
smtp_server_id=_optional(options.get("smtp_server_id")),
|
||||
smtp_credential_id=_optional(options.get("smtp_credential_id")),
|
||||
metadata={
|
||||
"producer_module": "reporting",
|
||||
"publication_id": payload.publication_id,
|
||||
"execution_id": payload.execution_id,
|
||||
"report_id": payload.report_id,
|
||||
"report_revision": payload.report_revision,
|
||||
"output_hash": payload.output_hash,
|
||||
},
|
||||
),
|
||||
)
|
||||
status = str(result.get("status") or "").casefold()
|
||||
if status not in {"accepted", "queued", "submitted", "succeeded"}:
|
||||
raise RuntimeError(
|
||||
str(result.get("error") or "Mail did not accept the report publication.")
|
||||
)
|
||||
return {
|
||||
**dict(result),
|
||||
"publication_id": payload.publication_id,
|
||||
"recipient": recipient,
|
||||
"output_hash": payload.output_hash,
|
||||
}
|
||||
|
||||
|
||||
def publication_target_catalog(registry: object | None) -> tuple[dict[str, object], ...]:
|
||||
files_available = isinstance(
|
||||
capability(registry, CAPABILITY_FILES_ARTIFACT_STORE), ManagedArtifactStore
|
||||
)
|
||||
mail_available = isinstance(
|
||||
capability(registry, CAPABILITY_MAIL_NOTIFICATION_DELIVERY),
|
||||
NotificationMailDeliveryProvider,
|
||||
)
|
||||
return (
|
||||
{
|
||||
"capability": CAPABILITY_REPORTING_PUBLICATION_FILES,
|
||||
"label": "Files",
|
||||
"available": files_available,
|
||||
"reason": None
|
||||
if files_available
|
||||
else "Enable Files with managed artifact storage to publish durable report files.",
|
||||
"formats": ["csv", "json", "html"],
|
||||
"target_label": "Folder",
|
||||
"target_required": False,
|
||||
"required_options": [],
|
||||
},
|
||||
{
|
||||
"capability": CAPABILITY_REPORTING_PUBLICATION_MAIL,
|
||||
"label": "Mail",
|
||||
"available": mail_available,
|
||||
"reason": None
|
||||
if mail_available
|
||||
else "Enable Mail and configure its notification-delivery capability to publish report notices.",
|
||||
"formats": ["html"],
|
||||
"target_label": "Recipient",
|
||||
"target_required": True,
|
||||
"required_options": ["mail_profile_id", "from_address"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _serialize(payload: ReportingPublicationPayload) -> tuple[bytes, str, str]:
|
||||
if payload.format == "json":
|
||||
content = json.dumps(
|
||||
{
|
||||
"report_id": payload.report_id,
|
||||
"report_revision": payload.report_revision,
|
||||
"execution_id": payload.execution_id,
|
||||
"output_hash": payload.output_hash,
|
||||
"schema": list(payload.schema),
|
||||
"rows": list(payload.rows),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return content, "application/json", "json"
|
||||
if payload.format == "csv":
|
||||
fields = _fields(payload.rows, payload.schema)
|
||||
stream = StringIO(newline="")
|
||||
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for row in payload.rows:
|
||||
writer.writerow({key: _safe_csv(row.get(key)) for key in fields})
|
||||
return (
|
||||
stream.getvalue().encode("utf-8-sig"),
|
||||
"text/csv; charset=utf-8",
|
||||
"csv",
|
||||
)
|
||||
if payload.format == "html":
|
||||
fields = _fields(payload.rows, payload.schema)
|
||||
headers = "".join(f"<th scope=\"col\">{escape(key)}</th>" for key in fields)
|
||||
body = "".join(
|
||||
"<tr>"
|
||||
+ "".join(
|
||||
f"<td>{escape(_display(row.get(key)))}</td>" for key in fields
|
||||
)
|
||||
+ "</tr>"
|
||||
for row in payload.rows
|
||||
)
|
||||
content = (
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>"
|
||||
+ escape(payload.report_id)
|
||||
+ "</title></head><body><h1>"
|
||||
+ escape(payload.report_id)
|
||||
+ f"</h1><p>Revision {payload.report_revision}; output {escape(payload.output_hash)}</p>"
|
||||
+ f"<table><thead><tr>{headers}</tr></thead><tbody>{body}</tbody></table>"
|
||||
+ "</body></html>"
|
||||
)
|
||||
return content.encode("utf-8"), "text/html; charset=utf-8", "html"
|
||||
raise ValueError(
|
||||
"This publication target supports CSV, JSON, and accessible HTML. "
|
||||
"XLSX and PDF require a renderer provider."
|
||||
)
|
||||
|
||||
|
||||
def _filename(payload: ReportingPublicationPayload, extension: str) -> str:
|
||||
configured = str(payload.options.get("filename") or "").strip()
|
||||
stem = configured.rsplit(".", 1)[0] if configured else payload.report_id
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip(".-") or "report"
|
||||
return f"{safe}-r{payload.report_revision}.{extension}"
|
||||
|
||||
|
||||
def _fields(
|
||||
rows: Sequence[Mapping[str, object]], schema: Sequence[Mapping[str, object]]
|
||||
) -> list[str]:
|
||||
fields = [str(item.get("name")) for item in schema if item.get("name")]
|
||||
if fields:
|
||||
return fields
|
||||
return list(dict.fromkeys(str(key) for row in rows for key in row))
|
||||
|
||||
|
||||
def _safe_csv(value: object) -> object:
|
||||
if isinstance(value, (dict, list, tuple)):
|
||||
value = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if isinstance(value, str) and value.startswith(("=", "+", "-", "@")):
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
|
||||
def _display(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (dict, list, tuple)):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
return str(value)
|
||||
|
||||
|
||||
def _text_preview(
|
||||
rows: Sequence[Mapping[str, object]], schema: Sequence[Mapping[str, object]]
|
||||
) -> str:
|
||||
fields = _fields(rows, schema)[:8]
|
||||
lines = [" | ".join(fields)]
|
||||
lines.extend(" | ".join(_display(row.get(key)) for key in fields) for row in rows[:10])
|
||||
if len(rows) > 10:
|
||||
lines.append(f"... {len(rows) - 10} more rows")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _required_option(options: Mapping[str, object], key: str, label: str) -> str:
|
||||
value = str(options.get(key) or "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"{label} is required for Mail publication.")
|
||||
return value
|
||||
|
||||
|
||||
def _optional(value: object) -> str | None:
|
||||
clean = str(value or "").strip()
|
||||
return clean or None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FilesReportingPublicationTarget",
|
||||
"MailReportingPublicationTarget",
|
||||
"publication_target_catalog",
|
||||
]
|
||||
@@ -86,7 +86,7 @@ def execute_semantic_query(
|
||||
return QueryResult(
|
||||
rows=tuple(selected),
|
||||
total_rows=total,
|
||||
schema=_infer_schema(selected or sorted_rows[:1]),
|
||||
schema=infer_query_schema(selected or sorted_rows[:1]),
|
||||
truncated=query.offset + len(selected) < total,
|
||||
)
|
||||
|
||||
@@ -376,7 +376,7 @@ def _sort_rows(
|
||||
return result
|
||||
|
||||
|
||||
def _infer_schema(rows: Sequence[Mapping[str, object]]) -> tuple[dict[str, Any], ...]:
|
||||
def infer_query_schema(rows: Sequence[Mapping[str, object]]) -> tuple[dict[str, Any], ...]:
|
||||
names = tuple(dict.fromkeys(str(key) for row in rows for key in row))
|
||||
return tuple(
|
||||
{
|
||||
@@ -491,4 +491,5 @@ __all__ = [
|
||||
"QueryResult",
|
||||
"ReportingQueryError",
|
||||
"execute_semantic_query",
|
||||
"infer_query_schema",
|
||||
]
|
||||
|
||||
@@ -18,6 +18,11 @@ from govoplan_reporting.backend.definitions import (
|
||||
list_definitions,
|
||||
update_definition,
|
||||
)
|
||||
from govoplan_reporting.backend.drilldown import (
|
||||
ReportingDrillError,
|
||||
create_drill_context,
|
||||
resolve_drill_context,
|
||||
)
|
||||
from govoplan_reporting.backend.execution import (
|
||||
QUALITY_SCOPE,
|
||||
RUN_SCOPE,
|
||||
@@ -38,6 +43,7 @@ from govoplan_reporting.backend.operations import (
|
||||
dispatch_due_schedules,
|
||||
export_execution,
|
||||
list_import_assessments,
|
||||
list_publications,
|
||||
list_saved_views,
|
||||
list_schedules,
|
||||
publish_execution,
|
||||
@@ -53,10 +59,12 @@ from govoplan_reporting.backend.provider_reports import (
|
||||
list_provider_reports,
|
||||
provider_parameter_options,
|
||||
)
|
||||
from govoplan_reporting.backend.publication_targets import publication_target_catalog
|
||||
from govoplan_reporting.backend.query_engine import ReportingQueryError
|
||||
from govoplan_reporting.backend.schemas import (
|
||||
DefinitionUpdateRequest,
|
||||
DefinitionWriteRequest,
|
||||
DrillContextCreateRequest,
|
||||
ImportAssessmentRequest,
|
||||
PublicationRequest,
|
||||
ProviderReportExecutionRequest,
|
||||
@@ -432,7 +440,13 @@ def create_router(registry: object | None) -> APIRouter:
|
||||
_require(principal, RUN_SCOPE)
|
||||
return {
|
||||
"executions": list(
|
||||
list_executions(session, principal, report_id=report_id, limit=limit)
|
||||
list_executions(
|
||||
session,
|
||||
principal,
|
||||
report_id=report_id,
|
||||
limit=limit,
|
||||
registry=registry,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -443,11 +457,69 @@ def create_router(registry: object | None) -> APIRouter:
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
result = get_execution(session, principal, execution_id=execution_id)
|
||||
result = get_execution(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
registry=registry,
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Reporting execution not found")
|
||||
return result
|
||||
|
||||
@router.post("/executions/{execution_id}/drill-contexts", status_code=201)
|
||||
def api_create_drill_context(
|
||||
execution_id: str,
|
||||
payload: DrillContextCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
result = create_drill_context(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
execution_id=execution_id,
|
||||
aggregate_row=payload.aggregate_row,
|
||||
limit=payload.limit,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
ReportingDrillError,
|
||||
ReportingExecutionError,
|
||||
PermissionError,
|
||||
LookupError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/drill-contexts/{token}")
|
||||
def api_resolve_drill_context(
|
||||
token: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, RUN_SCOPE)
|
||||
try:
|
||||
result = resolve_drill_context(
|
||||
session,
|
||||
principal,
|
||||
registry=registry,
|
||||
token=token,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
ReportingDrillError,
|
||||
ReportingExecutionError,
|
||||
PermissionError,
|
||||
LookupError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/executions/{execution_id}/export")
|
||||
def api_export_execution(
|
||||
execution_id: str,
|
||||
@@ -462,6 +534,7 @@ def create_router(registry: object | None) -> APIRouter:
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
format=format,
|
||||
registry=registry,
|
||||
)
|
||||
except (ReportingOperationError, LookupError) as exc:
|
||||
raise _error(exc) from exc
|
||||
@@ -493,6 +566,33 @@ def create_router(registry: object | None) -> APIRouter:
|
||||
raise _error(exc) from exc
|
||||
return result
|
||||
|
||||
@router.get("/publication-targets")
|
||||
def api_publication_targets(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, PUBLISH_SCOPE)
|
||||
return {"targets": list(publication_target_catalog(registry))}
|
||||
|
||||
@router.get("/publications")
|
||||
def api_list_publications(
|
||||
execution_id: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, object]:
|
||||
_require(principal, PUBLISH_SCOPE)
|
||||
return {
|
||||
"publications": list(
|
||||
list_publications(
|
||||
session,
|
||||
principal,
|
||||
execution_id=execution_id,
|
||||
limit=limit,
|
||||
registry=registry,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@router.get("/reports/{report_id}/saved-views")
|
||||
def api_list_saved_views(
|
||||
report_id: str,
|
||||
|
||||
@@ -58,6 +58,43 @@ class FreshnessPolicy(BaseModel):
|
||||
require_source_fingerprints: bool = True
|
||||
|
||||
|
||||
class DefinitionGovernance(BaseModel):
|
||||
"""Versioned scope and restrictive inheritance metadata for a definition."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
scope_type: Literal["system", "tenant", "group", "user"] = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=255)
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_run: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
source_scope: dict[str, Any] | None = None
|
||||
source_effective_limits: dict[str, bool] = Field(default_factory=dict)
|
||||
derivation_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_scope(self) -> "DefinitionGovernance":
|
||||
if self.scope_type == "system":
|
||||
if self.scope_id:
|
||||
raise ValueError("System Reporting definitions do not carry a scope ID.")
|
||||
elif self.scope_type in {"group", "user"} and not str(self.scope_id or "").strip():
|
||||
raise ValueError(
|
||||
f"{self.scope_type.capitalize()} Reporting definitions require a scope ID."
|
||||
)
|
||||
unknown = set(self.source_effective_limits) - {
|
||||
"inherit_to_lower_scopes",
|
||||
"allow_run",
|
||||
"allow_reuse",
|
||||
"allow_automation",
|
||||
}
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
"Unknown inherited Reporting limits: " + ", ".join(sorted(unknown))
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class DatasetDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
@@ -87,6 +124,7 @@ class DatasetDefinition(BaseModel):
|
||||
default_factory=list,
|
||||
max_length=200,
|
||||
)
|
||||
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_source_pin(self) -> "DatasetDefinition":
|
||||
@@ -203,6 +241,7 @@ class SemanticModelDefinition(BaseModel):
|
||||
default_dimensions: list[str] = Field(default_factory=list, max_length=50)
|
||||
default_measures: list[str] = Field(default_factory=list, max_length=50)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_semantics(self) -> "SemanticModelDefinition":
|
||||
@@ -307,6 +346,7 @@ class VisualizationDefinition(BaseModel):
|
||||
"area",
|
||||
"column",
|
||||
"pie",
|
||||
"donut",
|
||||
"metric",
|
||||
] = "table"
|
||||
category_dimension: str | None = Field(default=None, max_length=120)
|
||||
@@ -333,6 +373,7 @@ class ReportDefinition(BaseModel):
|
||||
default_factory=list,
|
||||
max_length=200,
|
||||
)
|
||||
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||
|
||||
|
||||
class QualityAssertion(BaseModel):
|
||||
@@ -359,6 +400,7 @@ class QualityPlanDefinition(BaseModel):
|
||||
dataset_revision: int = Field(ge=1)
|
||||
assertions: list[QualityAssertion] = Field(min_length=1, max_length=200)
|
||||
block_report_execution: bool = True
|
||||
governance: DefinitionGovernance = Field(default_factory=DefinitionGovernance)
|
||||
|
||||
|
||||
DEFINITION_PAYLOAD_TYPES = {
|
||||
@@ -478,6 +520,13 @@ class PublicationRequest(BaseModel):
|
||||
options: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DrillContextCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
aggregate_row: dict[str, Any] = Field(max_length=500)
|
||||
limit: int = Field(default=200, ge=1, le=500)
|
||||
|
||||
|
||||
class QualityRunRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -499,9 +548,11 @@ TypedExpression.model_rebuild()
|
||||
|
||||
__all__ = [
|
||||
"DatasetDefinition",
|
||||
"DefinitionGovernance",
|
||||
"DefinitionUpdateRequest",
|
||||
"DefinitionWriteRequest",
|
||||
"DimensionDefinition",
|
||||
"DrillContextCreateRequest",
|
||||
"FilterClause",
|
||||
"ImportAssessmentRequest",
|
||||
"MeasureDefinition",
|
||||
|
||||
Reference in New Issue
Block a user