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", ]