Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8253bd0dd6 | ||
|
|
89755e1924 | ||
|
|
f2e42cecfe | ||
|
|
d7e7890291 | ||
|
|
f85e0fe9ad | ||
|
|
eb6742a393 | ||
|
|
e932a3d0f7 | ||
|
|
0a606184b1 | ||
|
|
63ee29e222 | ||
|
|
e13d95c843 | ||
|
|
79fa45d14a | ||
|
|
c2c400a43a | ||
|
|
f930a6cff0 |
@@ -37,6 +37,23 @@ The canonical global route is `/reports`. `/reporting` remains a
|
|||||||
Reporting-owned compatibility route for saved links. Domain modules may keep
|
Reporting-owned compatibility route for saved links. Domain modules may keep
|
||||||
their own operational report routes, but do not register `/reports`.
|
their own operational report routes, but do not register `/reports`.
|
||||||
|
|
||||||
|
## Data-subject requests
|
||||||
|
|
||||||
|
Reporting publishes `privacy.dsar.reporting` for private saved views,
|
||||||
|
short-lived drill contexts, subject access grants, minimized staff
|
||||||
|
attribution, and explicitly identified retained executions, exports, and
|
||||||
|
publications. DSAR output never copies report rows, parameters, filters,
|
||||||
|
delivery targets, source payloads, diagnostics, provenance bodies, or hashes.
|
||||||
|
The source module remains responsible for locating and correcting subject
|
||||||
|
facts; arbitrary aggregate report output is not searched as if Reporting were
|
||||||
|
the authoritative owner.
|
||||||
|
|
||||||
|
Private views and drill contexts can be removed, grants revoked, and exact
|
||||||
|
retained result or publication detail minimized idempotently. Shared views,
|
||||||
|
definitions, schedules, quality/import evidence, and institutional attribution
|
||||||
|
require authorized review or retention. Source facts must be corrected before
|
||||||
|
rerunning or republishing a report.
|
||||||
|
|
||||||
See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary
|
See [docs/REPORTING_BOUNDARY.md](docs/REPORTING_BOUNDARY.md) for the boundary
|
||||||
decision. The behavior-level comparison with the supplied SuperX module set is
|
decision. The behavior-level comparison with the supplied SuperX module set is
|
||||||
recorded in
|
recorded in
|
||||||
@@ -50,3 +67,21 @@ are covered by [docs/USER_GUIDE.md](docs/USER_GUIDE.md) and
|
|||||||
The Reporting route, workspace, state, consequence, and accessibility mapping
|
The Reporting route, workspace, state, consequence, and accessibility mapping
|
||||||
is recorded in
|
is recorded in
|
||||||
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/reporting-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/reporting-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
+4
-1
@@ -31,7 +31,10 @@ does not publish a result.
|
|||||||
A report configured against an exact published Dataflow run reads that run's
|
A report configured against an exact published Dataflow run reads that run's
|
||||||
immutable Datasource materialization. It does not rerun the flow with current
|
immutable Datasource materialization. It does not rerun the flow with current
|
||||||
inputs. The evidence identifies the Dataflow run and materialization, and access
|
inputs. The evidence identifies the Dataflow run and materialization, and access
|
||||||
to both is checked again when the report runs.
|
to both is checked again when the report runs. CSV export therefore provides an
|
||||||
|
Excel-readable publication of the exact authorized Dataflow result, with
|
||||||
|
spreadsheet formula markers escaped and the run lineage retained on the
|
||||||
|
Reporting execution.
|
||||||
|
|
||||||
The **Effective access** explanation states when dimensions, measures, source
|
The **Effective access** explanation states when dimensions, measures, source
|
||||||
rows, or actions were removed by Policy. A result with no hidden elements says
|
rows, or actions were removed by Policy. A result with no hidden elements says
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/reporting-webui",
|
||||||
|
"version": "0.1.22",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/reporting.css": "./webui/src/styles/reporting.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
+3
-3
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-reporting"
|
name = "govoplan-reporting"
|
||||||
version = "0.1.17"
|
version = "0.1.22"
|
||||||
description = "GovOPlaN governed reporting and semantic BI module."
|
description = "GovOPlaN governed reporting and semantic BI module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { text = "AGPL-3.0-or-later" }
|
license = { text = "AGPL-3.0-or-later" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.17",
|
"govoplan-core>=0.1.46",
|
||||||
"govoplan-access>=0.1.17",
|
"govoplan-access>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN Reporting module."""
|
"""GovOPlaN Reporting module."""
|
||||||
|
|
||||||
__version__ = "0.1.17"
|
__version__ = "0.1.22"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import uuid
|
|||||||
from sqlalchemy import and_, exists, func, or_
|
from sqlalchemy import and_, exists, func, or_
|
||||||
from sqlalchemy.orm import Query, Session
|
from sqlalchemy.orm import Query, Session
|
||||||
|
|
||||||
|
from govoplan_core.core.principal_helpers import principal_actor_ids as _actor_ids
|
||||||
from govoplan_core.core.events import (
|
from govoplan_core.core.events import (
|
||||||
EventActorRef,
|
EventActorRef,
|
||||||
EventObjectRef,
|
EventObjectRef,
|
||||||
@@ -884,22 +885,6 @@ def _actor(principal: object) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _actor_ids(principal: object) -> tuple[str, ...]:
|
|
||||||
user = getattr(principal, "user", None)
|
|
||||||
return tuple(
|
|
||||||
dict.fromkeys(
|
|
||||||
str(value)
|
|
||||||
for value in (
|
|
||||||
getattr(principal, "account_id", None),
|
|
||||||
getattr(principal, "identity_id", None),
|
|
||||||
getattr(principal, "membership_id", None),
|
|
||||||
getattr(user, "id", None),
|
|
||||||
)
|
|
||||||
if str(value or "").strip()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _principal_tenant(principal: object) -> str:
|
def _principal_tenant(principal: object) -> str:
|
||||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||||
if not tenant_id:
|
if not tenant_id:
|
||||||
|
|||||||
@@ -0,0 +1,951 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_reporting.backend.db.models import (
|
||||||
|
ReportingDefinitionGrant,
|
||||||
|
ReportingDefinitionIdentity,
|
||||||
|
ReportingDefinitionRevision,
|
||||||
|
ReportingDrillContext,
|
||||||
|
ReportingExecution,
|
||||||
|
ReportingImportAssessment,
|
||||||
|
ReportingProviderExecution,
|
||||||
|
ReportingProviderExport,
|
||||||
|
ReportingPublication,
|
||||||
|
ReportingQualityResult,
|
||||||
|
ReportingSavedView,
|
||||||
|
ReportingSchedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
REPORTING_DSAR_CAPABILITY = dsar_capability_name("reporting")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
_CONFLICT = object()
|
||||||
|
_DIRECT_ALIASES = {
|
||||||
|
"definition_id": ("reporting.definition",),
|
||||||
|
"revision_id": (
|
||||||
|
"reporting.definition_revision",
|
||||||
|
"reporting.revision",
|
||||||
|
),
|
||||||
|
"execution_id": ("reporting.execution",),
|
||||||
|
"provider_execution_id": ("reporting.provider_execution",),
|
||||||
|
"provider_export_id": ("reporting.provider_export",),
|
||||||
|
"grant_id": ("reporting.definition_grant", "reporting.grant"),
|
||||||
|
"saved_view_id": ("reporting.saved_view",),
|
||||||
|
"schedule_id": ("reporting.schedule",),
|
||||||
|
"publication_id": ("reporting.publication",),
|
||||||
|
"drill_context_id": ("reporting.drill_context",),
|
||||||
|
"quality_result_id": ("reporting.quality_result",),
|
||||||
|
"import_assessment_id": ("reporting.import_assessment",),
|
||||||
|
}
|
||||||
|
_RESOURCE_MODELS = {
|
||||||
|
"reporting_definition": ReportingDefinitionIdentity,
|
||||||
|
"reporting_definition_revision": ReportingDefinitionRevision,
|
||||||
|
"reporting_execution": ReportingExecution,
|
||||||
|
"reporting_provider_execution": ReportingProviderExecution,
|
||||||
|
"reporting_provider_export": ReportingProviderExport,
|
||||||
|
"reporting_definition_grant": ReportingDefinitionGrant,
|
||||||
|
"reporting_saved_view": ReportingSavedView,
|
||||||
|
"reporting_schedule": ReportingSchedule,
|
||||||
|
"reporting_publication": ReportingPublication,
|
||||||
|
"reporting_drill_context": ReportingDrillContext,
|
||||||
|
"reporting_quality_result": ReportingQualityResult,
|
||||||
|
"reporting_import_assessment": ReportingImportAssessment,
|
||||||
|
}
|
||||||
|
_EXECUTABLE_KINDS = {
|
||||||
|
"reporting_execution": "anonymize",
|
||||||
|
"reporting_provider_execution": "anonymize",
|
||||||
|
"reporting_provider_export": "anonymize",
|
||||||
|
"reporting_publication": "anonymize",
|
||||||
|
"reporting_definition_grant": "revoke",
|
||||||
|
"reporting_saved_view": "delete",
|
||||||
|
"reporting_drill_context": "delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Selectors:
|
||||||
|
account_id: str | None
|
||||||
|
identity_id: str | None
|
||||||
|
membership_id: str | None
|
||||||
|
direct: dict[str, str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actor_ids(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
value
|
||||||
|
for value in (self.account_id, self.identity_id, self.membership_id)
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Match:
|
||||||
|
resource_type: str
|
||||||
|
row: Any
|
||||||
|
category: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingDsarProvider:
|
||||||
|
provider_id = "reporting"
|
||||||
|
module_id = "reporting"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _selectors(subject)
|
||||||
|
if selectors is None or not (selectors.actor_ids or selectors.direct):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
direct = _direct_matches(db, tenant_id=tenant_id, selectors=selectors)
|
||||||
|
if direct is None:
|
||||||
|
return ()
|
||||||
|
if direct:
|
||||||
|
if selectors.actor_ids and not all(
|
||||||
|
_correlates(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
match=match,
|
||||||
|
actor_ids=selectors.actor_ids,
|
||||||
|
)
|
||||||
|
for match in direct
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
matches = direct
|
||||||
|
else:
|
||||||
|
matches = _canonical_matches(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
actor_ids=selectors.actor_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for match in matches:
|
||||||
|
key = (match.resource_type, str(match.row.id))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
if len(records) >= _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Reporting DSAR result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
seen.add(key)
|
||||||
|
records.append(_record(match))
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _selectors(subject) is None:
|
||||||
|
raise ValueError("Reporting DSAR subject selectors conflict.")
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
kind = _planned_kind(record)
|
||||||
|
executable = kind in {"delete", "anonymize", "revoke"}
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=(
|
||||||
|
f"reporting:{kind}:{record.resource_type}:{record.resource_id}"
|
||||||
|
),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind=kind,
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=(
|
||||||
|
f"Minimize {record.title}"
|
||||||
|
if kind == "anonymize"
|
||||||
|
else f"{kind.replace('_', ' ').title()} {record.title}"
|
||||||
|
),
|
||||||
|
rationale=_rationale(record, kind=kind),
|
||||||
|
executable=executable,
|
||||||
|
irreversible=kind in {"delete", "anonymize"},
|
||||||
|
metadata={"record_category": record.category},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
raise ValueError("Reporting DSAR subject selectors conflict.")
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if not action.executable:
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Review institutional reporting evidence, shared "
|
||||||
|
"configuration, retention, and third-party impact."
|
||||||
|
),
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
model = _RESOURCE_MODELS[action.resource_type]
|
||||||
|
row = (
|
||||||
|
db.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, model.id == action.resource_id)
|
||||||
|
.with_for_update()
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
status = "unchanged"
|
||||||
|
summary = "Reporting row was already absent or minimized."
|
||||||
|
else:
|
||||||
|
match = _Match(action.resource_type, row, "execution")
|
||||||
|
if not (
|
||||||
|
_directly_targets(selectors, match)
|
||||||
|
or _correlates(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
match=match,
|
||||||
|
actor_ids=selectors.actor_ids,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Reporting DSAR action is not corroborated by the subject."
|
||||||
|
)
|
||||||
|
status, summary = _execute_action(
|
||||||
|
db,
|
||||||
|
row=row,
|
||||||
|
resource_type=action.resource_type,
|
||||||
|
kind=action.kind,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status=status,
|
||||||
|
summary=summary,
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_matches(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _Selectors,
|
||||||
|
) -> list[_Match] | None:
|
||||||
|
matches: list[_Match] = []
|
||||||
|
for selector, value in selectors.direct.items():
|
||||||
|
current: list[_Match]
|
||||||
|
if selector == "definition_id":
|
||||||
|
identities = _rows(
|
||||||
|
session,
|
||||||
|
ReportingDefinitionIdentity,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
field="definition_id",
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
revisions = _rows(
|
||||||
|
session,
|
||||||
|
ReportingDefinitionRevision,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
field="definition_id",
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
current = [
|
||||||
|
*(
|
||||||
|
_Match("reporting_definition", row, "reporting_configuration")
|
||||||
|
for row in identities
|
||||||
|
),
|
||||||
|
*(
|
||||||
|
_Match(
|
||||||
|
"reporting_definition_revision",
|
||||||
|
row,
|
||||||
|
"reporting_configuration",
|
||||||
|
)
|
||||||
|
for row in revisions
|
||||||
|
),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
model, field, resource_type = {
|
||||||
|
"revision_id": (
|
||||||
|
ReportingDefinitionRevision,
|
||||||
|
"id",
|
||||||
|
"reporting_definition_revision",
|
||||||
|
),
|
||||||
|
"execution_id": (
|
||||||
|
ReportingExecution,
|
||||||
|
"execution_id",
|
||||||
|
"reporting_execution",
|
||||||
|
),
|
||||||
|
"provider_execution_id": (
|
||||||
|
ReportingProviderExecution,
|
||||||
|
"execution_id",
|
||||||
|
"reporting_provider_execution",
|
||||||
|
),
|
||||||
|
"provider_export_id": (
|
||||||
|
ReportingProviderExport,
|
||||||
|
"export_id",
|
||||||
|
"reporting_provider_export",
|
||||||
|
),
|
||||||
|
"grant_id": (
|
||||||
|
ReportingDefinitionGrant,
|
||||||
|
"id",
|
||||||
|
"reporting_definition_grant",
|
||||||
|
),
|
||||||
|
"saved_view_id": (
|
||||||
|
ReportingSavedView,
|
||||||
|
"view_id",
|
||||||
|
"reporting_saved_view",
|
||||||
|
),
|
||||||
|
"schedule_id": (
|
||||||
|
ReportingSchedule,
|
||||||
|
"schedule_id",
|
||||||
|
"reporting_schedule",
|
||||||
|
),
|
||||||
|
"publication_id": (
|
||||||
|
ReportingPublication,
|
||||||
|
"publication_id",
|
||||||
|
"reporting_publication",
|
||||||
|
),
|
||||||
|
"drill_context_id": (
|
||||||
|
ReportingDrillContext,
|
||||||
|
"drill_context_id",
|
||||||
|
"reporting_drill_context",
|
||||||
|
),
|
||||||
|
"quality_result_id": (
|
||||||
|
ReportingQualityResult,
|
||||||
|
"result_id",
|
||||||
|
"reporting_quality_result",
|
||||||
|
),
|
||||||
|
"import_assessment_id": (
|
||||||
|
ReportingImportAssessment,
|
||||||
|
"assessment_id",
|
||||||
|
"reporting_import_assessment",
|
||||||
|
),
|
||||||
|
}[selector]
|
||||||
|
current = [
|
||||||
|
_Match(resource_type, row, _direct_category(resource_type, row))
|
||||||
|
for row in _rows(
|
||||||
|
session,
|
||||||
|
model,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
field=field,
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not current:
|
||||||
|
return None
|
||||||
|
matches.extend(current)
|
||||||
|
if len(matches) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Reporting DSAR result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_matches(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
actor_ids: tuple[str, ...],
|
||||||
|
) -> list[_Match]:
|
||||||
|
if not actor_ids:
|
||||||
|
return []
|
||||||
|
specs = (
|
||||||
|
(
|
||||||
|
ReportingDefinitionIdentity,
|
||||||
|
"created_by",
|
||||||
|
"reporting_definition",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingDefinitionRevision,
|
||||||
|
"changed_by",
|
||||||
|
"reporting_definition_revision",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingExecution,
|
||||||
|
"actor_id",
|
||||||
|
"reporting_execution",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingProviderExecution,
|
||||||
|
"actor_id",
|
||||||
|
"reporting_provider_execution",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingProviderExport,
|
||||||
|
"actor_id",
|
||||||
|
"reporting_provider_export",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingSavedView,
|
||||||
|
"owner_id",
|
||||||
|
"reporting_saved_view",
|
||||||
|
"subject_owned_reporting_view",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingSchedule,
|
||||||
|
"created_by",
|
||||||
|
"reporting_schedule",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingDrillContext,
|
||||||
|
"actor_id",
|
||||||
|
"reporting_drill_context",
|
||||||
|
"subject_owned_drill_context",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingQualityResult,
|
||||||
|
"actor_id",
|
||||||
|
"reporting_quality_result",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingImportAssessment,
|
||||||
|
"assessed_by",
|
||||||
|
"reporting_import_assessment",
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ReportingDefinitionGrant,
|
||||||
|
"subject_id",
|
||||||
|
"reporting_definition_grant",
|
||||||
|
"subject_access_grant",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
matches: list[_Match] = []
|
||||||
|
for model, field, resource_type, category in specs:
|
||||||
|
rows = (
|
||||||
|
session.query(model)
|
||||||
|
.filter(
|
||||||
|
model.tenant_id == tenant_id,
|
||||||
|
getattr(model, field).in_(actor_ids),
|
||||||
|
)
|
||||||
|
.order_by(model.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
matches.extend(
|
||||||
|
_Match(
|
||||||
|
resource_type,
|
||||||
|
row,
|
||||||
|
_direct_category(resource_type, row)
|
||||||
|
if resource_type == "reporting_saved_view"
|
||||||
|
else category,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
if len(matches) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Reporting DSAR result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(
|
||||||
|
session: Session,
|
||||||
|
model: Any,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
field: str,
|
||||||
|
value: str,
|
||||||
|
) -> list[Any]:
|
||||||
|
return (
|
||||||
|
session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, getattr(model, field) == value)
|
||||||
|
.order_by(model.id)
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _correlates(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
match: _Match,
|
||||||
|
actor_ids: tuple[str, ...],
|
||||||
|
) -> bool:
|
||||||
|
if not actor_ids:
|
||||||
|
return False
|
||||||
|
row = match.row
|
||||||
|
field = {
|
||||||
|
"reporting_definition": "created_by",
|
||||||
|
"reporting_definition_revision": "changed_by",
|
||||||
|
"reporting_execution": "actor_id",
|
||||||
|
"reporting_provider_execution": "actor_id",
|
||||||
|
"reporting_provider_export": "actor_id",
|
||||||
|
"reporting_definition_grant": "subject_id",
|
||||||
|
"reporting_saved_view": "owner_id",
|
||||||
|
"reporting_schedule": "created_by",
|
||||||
|
"reporting_drill_context": "actor_id",
|
||||||
|
"reporting_quality_result": "actor_id",
|
||||||
|
"reporting_import_assessment": "assessed_by",
|
||||||
|
}.get(match.resource_type)
|
||||||
|
if field and str(getattr(row, field, "") or "") in actor_ids:
|
||||||
|
return True
|
||||||
|
if match.resource_type == "reporting_definition_revision":
|
||||||
|
identity = session.get(ReportingDefinitionIdentity, row.identity_id)
|
||||||
|
return bool(
|
||||||
|
identity
|
||||||
|
and identity.tenant_id == tenant_id
|
||||||
|
and identity.created_by in actor_ids
|
||||||
|
)
|
||||||
|
if match.resource_type == "reporting_provider_export":
|
||||||
|
execution = session.get(
|
||||||
|
ReportingProviderExecution,
|
||||||
|
row.provider_execution_id,
|
||||||
|
)
|
||||||
|
return bool(
|
||||||
|
execution
|
||||||
|
and execution.tenant_id == tenant_id
|
||||||
|
and execution.actor_id in actor_ids
|
||||||
|
)
|
||||||
|
if match.resource_type == "reporting_publication":
|
||||||
|
execution = (
|
||||||
|
session.query(ReportingExecution)
|
||||||
|
.filter(
|
||||||
|
ReportingExecution.tenant_id == tenant_id,
|
||||||
|
ReportingExecution.execution_id == row.execution_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
return bool(execution and execution.actor_id in actor_ids)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _directly_targets(selectors: _Selectors, match: _Match) -> bool:
|
||||||
|
row = match.row
|
||||||
|
selector, field = {
|
||||||
|
"reporting_definition": ("definition_id", "definition_id"),
|
||||||
|
"reporting_definition_revision": ("revision_id", "id"),
|
||||||
|
"reporting_execution": ("execution_id", "execution_id"),
|
||||||
|
"reporting_provider_execution": (
|
||||||
|
"provider_execution_id",
|
||||||
|
"execution_id",
|
||||||
|
),
|
||||||
|
"reporting_provider_export": ("provider_export_id", "export_id"),
|
||||||
|
"reporting_definition_grant": ("grant_id", "id"),
|
||||||
|
"reporting_saved_view": ("saved_view_id", "view_id"),
|
||||||
|
"reporting_schedule": ("schedule_id", "schedule_id"),
|
||||||
|
"reporting_publication": ("publication_id", "publication_id"),
|
||||||
|
"reporting_drill_context": ("drill_context_id", "drill_context_id"),
|
||||||
|
"reporting_quality_result": ("quality_result_id", "result_id"),
|
||||||
|
"reporting_import_assessment": (
|
||||||
|
"import_assessment_id",
|
||||||
|
"assessment_id",
|
||||||
|
),
|
||||||
|
}[match.resource_type]
|
||||||
|
value = getattr(row, field)
|
||||||
|
if selectors.direct.get(selector) == str(value):
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
match.resource_type == "reporting_definition_revision"
|
||||||
|
and selectors.direct.get("definition_id") == row.definition_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_category(resource_type: str, row: Any) -> str:
|
||||||
|
if resource_type == "reporting_saved_view":
|
||||||
|
return "shared_reporting_view" if row.shared else "subject_owned_reporting_view"
|
||||||
|
return {
|
||||||
|
"reporting_execution": "derived_report_result",
|
||||||
|
"reporting_provider_execution": "derived_provider_report_result",
|
||||||
|
"reporting_provider_export": "derived_provider_report_export",
|
||||||
|
"reporting_publication": "derived_report_publication",
|
||||||
|
"reporting_definition_grant": "subject_access_grant",
|
||||||
|
"reporting_drill_context": "subject_owned_drill_context",
|
||||||
|
}.get(resource_type, "reporting_configuration")
|
||||||
|
|
||||||
|
|
||||||
|
def _record(match: _Match) -> DsarRecordRef:
|
||||||
|
row = match.row
|
||||||
|
data = _record_data(match.resource_type, row)
|
||||||
|
immutable = match.category == "reporting_operator_attribution"
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="reporting",
|
||||||
|
module_id="reporting",
|
||||||
|
resource_type=match.resource_type,
|
||||||
|
resource_id=str(row.id),
|
||||||
|
category=match.category,
|
||||||
|
title=_title(match.resource_type),
|
||||||
|
data={key: value for key, value in data.items() if value is not None},
|
||||||
|
observed_at=_observed_at(row),
|
||||||
|
immutable_evidence=immutable,
|
||||||
|
retention_reason=(
|
||||||
|
"Institutional reporting activity remains attributable for "
|
||||||
|
"governance and audit review."
|
||||||
|
if immutable
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
source_path="/reports",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_data(resource_type: str, row: Any) -> dict[str, object]:
|
||||||
|
if resource_type == "reporting_definition":
|
||||||
|
return {
|
||||||
|
"definition_kind": row.definition_kind,
|
||||||
|
"definition_id": row.definition_id,
|
||||||
|
"definition_key": row.definition_key,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_definition_revision":
|
||||||
|
return {
|
||||||
|
"definition_kind": row.definition_kind,
|
||||||
|
"definition_id": row.definition_id,
|
||||||
|
"revision": row.revision,
|
||||||
|
"status": row.status,
|
||||||
|
"visibility": row.visibility,
|
||||||
|
"recorded_at": _iso(row.recorded_at),
|
||||||
|
"superseded_at": _iso(row.superseded_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_execution":
|
||||||
|
return {
|
||||||
|
"execution_id": row.execution_id,
|
||||||
|
"report_id": row.report_id,
|
||||||
|
"report_revision": row.report_revision,
|
||||||
|
"semantic_model_id": row.semantic_model_id,
|
||||||
|
"semantic_model_revision": row.semantic_model_revision,
|
||||||
|
"dataset_id": row.dataset_id,
|
||||||
|
"dataset_revision": row.dataset_revision,
|
||||||
|
"status": row.status,
|
||||||
|
"total_rows": row.total_rows,
|
||||||
|
"truncated": row.truncated,
|
||||||
|
"has_retained_result": bool(row.result_rows),
|
||||||
|
"started_at": _iso(row.started_at),
|
||||||
|
"finished_at": _iso(row.finished_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_provider_execution":
|
||||||
|
return {
|
||||||
|
"execution_id": row.execution_id,
|
||||||
|
"provider_id": row.provider_id,
|
||||||
|
"report_id": row.report_id,
|
||||||
|
"report_revision": row.report_revision,
|
||||||
|
"contract_version": row.contract_version,
|
||||||
|
"privacy_transforms": list(row.privacy_transforms or []),
|
||||||
|
"retention_class": row.retention_class,
|
||||||
|
"retention_days": row.retention_days,
|
||||||
|
"expires_at": _iso(row.expires_at),
|
||||||
|
"retention_redacted_at": _iso(row.retention_redacted_at),
|
||||||
|
"has_retained_result": bool(row.result_payload),
|
||||||
|
"generated_at": _iso(row.generated_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_provider_export":
|
||||||
|
return {
|
||||||
|
"export_id": row.export_id,
|
||||||
|
"execution_id": row.execution_id,
|
||||||
|
"format": row.format,
|
||||||
|
"exported_at": _iso(row.exported_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_definition_grant":
|
||||||
|
return {
|
||||||
|
"definition_kind": row.definition_kind,
|
||||||
|
"definition_id": row.definition_id,
|
||||||
|
"subject_kind": row.subject_kind,
|
||||||
|
"permissions": list(row.permissions or []),
|
||||||
|
"active": row.active,
|
||||||
|
"source_revision": row.source_revision,
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_saved_view":
|
||||||
|
return {
|
||||||
|
"view_id": row.view_id,
|
||||||
|
"report_id": row.report_id,
|
||||||
|
"report_revision": row.report_revision,
|
||||||
|
"owner_kind": row.owner_kind,
|
||||||
|
"revision": row.revision,
|
||||||
|
"shared": row.shared,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
"updated_at": _iso(row.updated_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_schedule":
|
||||||
|
return {
|
||||||
|
"schedule_id": row.schedule_id,
|
||||||
|
"report_id": row.report_id,
|
||||||
|
"report_revision": row.report_revision,
|
||||||
|
"revision": row.revision,
|
||||||
|
"trigger_kind": row.trigger_kind,
|
||||||
|
"enabled": row.enabled,
|
||||||
|
"next_run_at": _iso(row.next_run_at),
|
||||||
|
"last_run_at": _iso(row.last_run_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_publication":
|
||||||
|
return {
|
||||||
|
"publication_id": row.publication_id,
|
||||||
|
"execution_id": row.execution_id,
|
||||||
|
"target_capability": row.target_capability,
|
||||||
|
"format": row.format,
|
||||||
|
"status": row.status,
|
||||||
|
"completed_at": _iso(row.completed_at),
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_drill_context":
|
||||||
|
return {
|
||||||
|
"drill_context_id": row.drill_context_id,
|
||||||
|
"execution_id": row.execution_id,
|
||||||
|
"expires_at": _iso(row.expires_at),
|
||||||
|
"last_accessed_at": _iso(row.last_accessed_at),
|
||||||
|
}
|
||||||
|
if resource_type == "reporting_quality_result":
|
||||||
|
return {
|
||||||
|
"result_id": row.result_id,
|
||||||
|
"quality_plan_id": row.quality_plan_id,
|
||||||
|
"quality_plan_revision": row.quality_plan_revision,
|
||||||
|
"dataset_id": row.dataset_id,
|
||||||
|
"dataset_revision": row.dataset_revision,
|
||||||
|
"status": row.status,
|
||||||
|
"evaluated_at": _iso(row.evaluated_at),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"assessment_id": row.assessment_id,
|
||||||
|
"source_system": row.source_system,
|
||||||
|
"status": row.status,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_kind(record: DsarRecordRef) -> str:
|
||||||
|
if record.category == "reporting_operator_attribution":
|
||||||
|
return "retain"
|
||||||
|
if record.category in {"reporting_configuration", "shared_reporting_view"}:
|
||||||
|
return "manual_review"
|
||||||
|
return _EXECUTABLE_KINDS.get(record.resource_type, "manual_review")
|
||||||
|
|
||||||
|
|
||||||
|
def _rationale(record: DsarRecordRef, *, kind: str) -> str:
|
||||||
|
if kind == "delete":
|
||||||
|
return "Remove subject-owned, non-authoritative Reporting workspace state."
|
||||||
|
if kind == "anonymize":
|
||||||
|
return (
|
||||||
|
"Clear retained result or delivery detail while preserving hashes and "
|
||||||
|
"minimal institutional execution evidence."
|
||||||
|
)
|
||||||
|
if kind == "revoke":
|
||||||
|
return "Disable the subject-specific Reporting access relationship."
|
||||||
|
if kind == "retain":
|
||||||
|
return record.retention_reason or "Retain institutional attribution evidence."
|
||||||
|
return (
|
||||||
|
"An authorized Reporting owner must review shared configuration, "
|
||||||
|
"dependencies, legal retention, and third-party impact."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_action(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
row: Any,
|
||||||
|
resource_type: str,
|
||||||
|
kind: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
expected = _EXECUTABLE_KINDS.get(resource_type)
|
||||||
|
if expected != kind:
|
||||||
|
raise ValueError("Reporting DSAR executable action is not supported.")
|
||||||
|
if resource_type == "reporting_saved_view":
|
||||||
|
if row.shared:
|
||||||
|
raise ValueError("Shared Reporting views require manual review.")
|
||||||
|
session.delete(row)
|
||||||
|
session.flush()
|
||||||
|
return "executed", "Subject-owned Reporting view removed."
|
||||||
|
if resource_type == "reporting_drill_context":
|
||||||
|
session.delete(row)
|
||||||
|
session.flush()
|
||||||
|
return "executed", "Ephemeral Reporting drill context removed."
|
||||||
|
if resource_type == "reporting_definition_grant":
|
||||||
|
if not row.active:
|
||||||
|
return "unchanged", "Reporting access grant was already inactive."
|
||||||
|
row.active = False
|
||||||
|
session.flush()
|
||||||
|
return "executed", "Subject-specific Reporting access grant revoked."
|
||||||
|
if resource_type == "reporting_execution":
|
||||||
|
fields = {
|
||||||
|
"parameters": {},
|
||||||
|
"query": {},
|
||||||
|
"source_fingerprints": [],
|
||||||
|
"result_rows": [],
|
||||||
|
"diagnostics": [],
|
||||||
|
"provenance": {},
|
||||||
|
}
|
||||||
|
changed = _replace_fields(row, fields)
|
||||||
|
elif resource_type == "reporting_provider_execution":
|
||||||
|
fields = {
|
||||||
|
"purpose": "Redacted by data-subject request.",
|
||||||
|
"audience_scope": {},
|
||||||
|
"parameters": {},
|
||||||
|
"result_payload": {},
|
||||||
|
"source_revisions": [],
|
||||||
|
"effective_scope": {},
|
||||||
|
"provenance": {},
|
||||||
|
"governance_provenance": {},
|
||||||
|
}
|
||||||
|
changed = _replace_fields(row, fields)
|
||||||
|
if row.retention_redacted_at is None:
|
||||||
|
row.retention_redacted_at = datetime.now(timezone.utc)
|
||||||
|
changed = True
|
||||||
|
elif resource_type == "reporting_provider_export":
|
||||||
|
changed = _replace_fields(
|
||||||
|
row,
|
||||||
|
{
|
||||||
|
"purpose": "Redacted by data-subject request.",
|
||||||
|
"audience_scope": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
changed = _replace_fields(
|
||||||
|
row,
|
||||||
|
{"target_ref": None, "evidence": {}, "error": None},
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
session.flush()
|
||||||
|
return "executed", "Retained Reporting detail minimized; hashes remain."
|
||||||
|
return "unchanged", "Retained Reporting detail was already minimized."
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_fields(row: Any, values: dict[str, object]) -> bool:
|
||||||
|
changed = False
|
||||||
|
for field, value in values.items():
|
||||||
|
if getattr(row, field) != value:
|
||||||
|
setattr(row, field, value)
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
|
||||||
|
references = subject.external_references
|
||||||
|
account_id = _coalesce(
|
||||||
|
subject.account_id,
|
||||||
|
references.get("reporting.account"),
|
||||||
|
references.get("access.account"),
|
||||||
|
)
|
||||||
|
identity_id = _coalesce(
|
||||||
|
subject.identity_id,
|
||||||
|
references.get("reporting.identity"),
|
||||||
|
references.get("identity.id"),
|
||||||
|
)
|
||||||
|
membership_id = _coalesce(
|
||||||
|
subject.membership_id,
|
||||||
|
references.get("reporting.membership"),
|
||||||
|
references.get("tenancy.membership"),
|
||||||
|
)
|
||||||
|
direct: dict[str, str] = {}
|
||||||
|
for selector, aliases in _DIRECT_ALIASES.items():
|
||||||
|
value = _coalesce(*(references.get(alias) for alias in aliases))
|
||||||
|
if value is _CONFLICT:
|
||||||
|
return None
|
||||||
|
if value:
|
||||||
|
direct[selector] = str(value)
|
||||||
|
if _CONFLICT in {account_id, identity_id, membership_id}:
|
||||||
|
return None
|
||||||
|
return _Selectors(
|
||||||
|
account_id=_optional(account_id),
|
||||||
|
identity_id=_optional(identity_id),
|
||||||
|
membership_id=_optional(membership_id),
|
||||||
|
direct=direct,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce(*values: str | None) -> str | None | object:
|
||||||
|
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||||
|
if len(normalized) > 1:
|
||||||
|
return _CONFLICT
|
||||||
|
return next(iter(normalized), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _title(resource_type: str) -> str:
|
||||||
|
return resource_type.removeprefix("reporting_").replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
def _observed_at(row: Any) -> datetime | None:
|
||||||
|
for field in (
|
||||||
|
"generated_at",
|
||||||
|
"exported_at",
|
||||||
|
"evaluated_at",
|
||||||
|
"recorded_at",
|
||||||
|
"started_at",
|
||||||
|
"completed_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_at",
|
||||||
|
):
|
||||||
|
value = getattr(row, field, None)
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return _aware(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
aware = _aware(value)
|
||||||
|
return aware.isoformat() if aware else None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Reporting DSAR requires a SQLAlchemy Session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "reporting" or record.module_id != "reporting":
|
||||||
|
raise ValueError("Reporting DSAR cannot plan a foreign provider record.")
|
||||||
|
if record.resource_type not in _RESOURCE_MODELS or not record.resource_id:
|
||||||
|
raise ValueError("Reporting DSAR record identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "reporting" or action.module_id != "reporting":
|
||||||
|
raise ValueError("Reporting DSAR cannot execute a foreign provider action.")
|
||||||
|
if action.resource_type not in _RESOURCE_MODELS or not action.action_id.startswith(
|
||||||
|
"reporting:"
|
||||||
|
):
|
||||||
|
raise ValueError("Reporting DSAR action identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["REPORTING_DSAR_CAPABILITY", "ReportingDsarProvider"]
|
||||||
@@ -15,6 +15,7 @@ from govoplan_core.core.module_guards import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -26,6 +27,7 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import (
|
from govoplan_core.core.provider_governance import (
|
||||||
@@ -50,6 +52,10 @@ from govoplan_reporting.backend.contracts import (
|
|||||||
CAPABILITY_REPORTING_SCHEDULER,
|
CAPABILITY_REPORTING_SCHEDULER,
|
||||||
)
|
)
|
||||||
from govoplan_reporting.backend.db import models as reporting_models
|
from govoplan_reporting.backend.db import models as reporting_models
|
||||||
|
from govoplan_reporting.backend.dsar_provider import (
|
||||||
|
REPORTING_DSAR_CAPABILITY,
|
||||||
|
ReportingDsarProvider,
|
||||||
|
)
|
||||||
from govoplan_reporting.backend.definitions import (
|
from govoplan_reporting.backend.definitions import (
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
@@ -77,7 +83,7 @@ from govoplan_reporting.backend.search_source import create_reporting_search_sou
|
|||||||
|
|
||||||
MODULE_ID = "reporting"
|
MODULE_ID = "reporting"
|
||||||
MODULE_NAME = "Reporting"
|
MODULE_NAME = "Reporting"
|
||||||
MODULE_VERSION = "0.1.17"
|
MODULE_VERSION = "0.1.22"
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
@@ -198,6 +204,11 @@ def _retention(context: ModuleContext):
|
|||||||
return ReportingRetentionService()
|
return ReportingRetentionService()
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(context: ModuleContext) -> ReportingDsarProvider:
|
||||||
|
del context
|
||||||
|
return ReportingDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
definitions = (
|
definitions = (
|
||||||
session.query(reporting_models.ReportingDefinitionRevision)
|
session.query(reporting_models.ReportingDefinitionRevision)
|
||||||
@@ -307,6 +318,21 @@ manifest = ModuleManifest(
|
|||||||
surface_id="reporting.navigation",
|
surface_id="reporting.navigation",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="data-assurance",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.data_assurance",
|
||||||
|
icon="database-zap",
|
||||||
|
description="i18n:govoplan-core.product_area.data_assurance_description",
|
||||||
|
surface_ids=(
|
||||||
|
"reporting.navigation",
|
||||||
|
"reporting.workspace",
|
||||||
|
"reporting.compatibility",
|
||||||
|
),
|
||||||
|
order=60,
|
||||||
|
),
|
||||||
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="reporting.parameters",
|
id="reporting.parameters",
|
||||||
@@ -345,6 +371,7 @@ manifest = ModuleManifest(
|
|||||||
name=CAPABILITY_REPORTING_PUBLICATION_MAIL, version="1.0.0"
|
name=CAPABILITY_REPORTING_PUBLICATION_MAIL, version="1.0.0"
|
||||||
),
|
),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_REPORTING_RETENTION, version="1.0.0"),
|
ModuleInterfaceProvider(name=CAPABILITY_REPORTING_RETENTION, version="1.0.0"),
|
||||||
|
ModuleInterfaceProvider(name=REPORTING_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -380,6 +407,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_REPORTING_PUBLICATION_FILES: _files_publication,
|
CAPABILITY_REPORTING_PUBLICATION_FILES: _files_publication,
|
||||||
CAPABILITY_REPORTING_PUBLICATION_MAIL: _mail_publication,
|
CAPABILITY_REPORTING_PUBLICATION_MAIL: _mail_publication,
|
||||||
CAPABILITY_REPORTING_RETENTION: _retention,
|
CAPABILITY_REPORTING_RETENTION: _retention,
|
||||||
|
REPORTING_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
CAPABILITY_REPORTING_REGISTRY: CapabilityDocumentation(
|
CAPABILITY_REPORTING_REGISTRY: CapabilityDocumentation(
|
||||||
@@ -419,6 +447,13 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("privacy_officer", "operator", "system_admin"),
|
audience=("privacy_officer", "operator", "system_admin"),
|
||||||
),
|
),
|
||||||
|
REPORTING_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Reporting data-subject request provider",
|
||||||
|
summary="Finds subject-owned Reporting state and minimizes derived report copies.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("privacy_officer", "operator", "user"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
search_sources=(
|
search_sources=(
|
||||||
SearchSourceProviderRegistration(
|
SearchSourceProviderRegistration(
|
||||||
@@ -476,6 +511,87 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="reporting.data-subject-requests",
|
||||||
|
title="Reporting data-subject requests",
|
||||||
|
summary="Review personal workspace state and derived report copies without confusing them with source-owned facts.",
|
||||||
|
body=(
|
||||||
|
"Reporting matches exact tenant-scoped artifact references and account, identity, or membership ownership and attribution. Access output is deliberately minimized: report rows, parameters, filters, delivery targets, source payloads, diagnostics, provenance bodies, and hashes are not copied into the DSAR result. Source modules remain responsible for finding and correcting subject facts; Reporting cannot safely infer a person by scanning arbitrary aggregate output. "
|
||||||
|
"Private saved views and short-lived drill contexts can be deleted, subject grants can be revoked, and explicitly identified retained execution or publication detail can be minimized idempotently while hashes remain. Shared views, definitions, schedules, quality/import evidence, and staff attribution require authorized review or retention. Correct the source before rerunning a report or republishing an output."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("core", "datasources", "dataflow", "policy"),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"reporting.data-subject-requests",
|
||||||
|
"reporting.workspace",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_minimized_attribution": (
|
||||||
|
"Returns ownership and lifecycle context without report rows, parameters, or payloads."
|
||||||
|
),
|
||||||
|
"retain_governed_evidence": (
|
||||||
|
"Shared definitions, schedules, quality evidence, and required staff attribution remain subject to authorized review and retention."
|
||||||
|
),
|
||||||
|
"correct_authoritative_source": (
|
||||||
|
"Source facts must be corrected in their owner module before reports are rerun or republished."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzanfragen im Reporting",
|
||||||
|
"summary": (
|
||||||
|
"Persönliche Arbeitsbereichsdaten und abgeleitete Berichtskopien prüfen, "
|
||||||
|
"ohne sie mit Fakten aus führenden Quellsystemen zu verwechseln."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Reporting gleicht innerhalb des exakten Mandanten nur ausdrückliche "
|
||||||
|
"Artefaktverweise sowie die Zuordnung oder Urheberschaft von Konten, "
|
||||||
|
"Identitäten und Mitgliedschaften ab. Die Auskunft ist bewusst minimiert: "
|
||||||
|
"Berichtszeilen, Parameter, Filter, Zustellziele, Quellinhalte, Diagnosen, "
|
||||||
|
"Provenienzinhalte und Prüfsummen werden nicht in das Ergebnis kopiert. "
|
||||||
|
"Die Quellmodule bleiben dafür verantwortlich, personenbezogene Fakten zu "
|
||||||
|
"finden und zu berichtigen; Reporting darf Personen nicht durch das Durchsuchen "
|
||||||
|
"beliebiger Aggregatergebnisse ableiten. Private gespeicherte Ansichten und "
|
||||||
|
"kurzlebige Drilldown-Kontexte können gelöscht, personenbezogene Freigaben "
|
||||||
|
"entzogen und ausdrücklich bestimmte aufbewahrte Ausführungs- oder "
|
||||||
|
"Veröffentlichungsdetails idempotent minimiert werden, während Prüfsummen "
|
||||||
|
"erhalten bleiben. Gemeinsame Ansichten, Definitionen, Zeitpläne, Qualitäts- "
|
||||||
|
"und Importnachweise sowie dienstliche Zuschreibungen erfordern eine befugte "
|
||||||
|
"Prüfung oder Aufbewahrung. Die Quelle ist zu berichtigen, bevor ein Bericht "
|
||||||
|
"erneut ausgeführt oder veröffentlicht wird."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_minimized_attribution": (
|
||||||
|
"Gibt Zuordnungs- und Lebenszykluskontext ohne Berichtszeilen, Parameter oder Inhalte zurück."
|
||||||
|
),
|
||||||
|
"retain_governed_evidence": (
|
||||||
|
"Gemeinsame Definitionen, Zeitpläne, Qualitätsnachweise und erforderliche dienstliche Zuschreibungen unterliegen weiterhin befugter Prüfung und Aufbewahrung."
|
||||||
|
),
|
||||||
|
"correct_authoritative_source": (
|
||||||
|
"Quellfakten müssen im führenden Modul berichtigt werden, bevor Berichte erneut ausgeführt oder veröffentlicht werden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Reporting governance and retention",
|
||||||
|
href="govoplan-reporting/README.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
order=9,
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="reporting.governed-bi",
|
id="reporting.governed-bi",
|
||||||
title="Governed reporting and semantic BI",
|
title="Governed reporting and semantic BI",
|
||||||
@@ -487,12 +603,111 @@ manifest = ModuleManifest(
|
|||||||
"aggregations, typed expressions, filters, pivots, saved views, chart models, "
|
"aggregations, typed expressions, filters, pivots, saved views, chart models, "
|
||||||
"schedules, exports, and publication providers replace unchecked SQL in the "
|
"schedules, exports, and publication providers replace unchecked SQL in the "
|
||||||
"presentation layer. PostgreSQL executes bounded semantic plans when available. "
|
"presentation layer. PostgreSQL executes bounded semantic plans when available. "
|
||||||
|
"Calculated measure keys may contain the documented dots and hyphens, including in nested references; generated bind names remain internal and values remain parameters, not SQL fragments. "
|
||||||
"Signed drill contexts reauthorize contributor rows, and Files/Mail publication "
|
"Signed drill contexts reauthorize contributor rows, and Files/Mail publication "
|
||||||
"adapters retain idempotent evidence. A dataset may pin one successful published Dataflow run, which is read from its exact Datasource materialization after both source boundaries reauthorize the current principal. Dataflow and module read models remain source owners. "
|
"adapters retain idempotent evidence. A dataset may pin one successful published Dataflow run, which is read from its exact Datasource materialization after both source boundaries reauthorize the current principal. Dataflow and module read models remain source owners. "
|
||||||
|
"The contributor drill-down action stays in a shared action column at the right edge of horizontally scrolled results; opening it still reauthorizes every contributor."
|
||||||
),
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "operator", "module_admin", "product_owner"),
|
audience=("user", "operator", "module_admin", "product_owner"),
|
||||||
|
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||||
|
related_modules=("datasources", "dataflow", "policy", "files", "mail"),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"reporting.workspace",
|
||||||
|
"reporting.definitions",
|
||||||
|
"reporting.executions",
|
||||||
|
"reporting.publications",
|
||||||
|
],
|
||||||
|
"purpose": (
|
||||||
|
"Create and run a reproducible report over an authorized, revision-pinned dataset."
|
||||||
|
),
|
||||||
|
"prerequisites": [
|
||||||
|
"The actor can read Reporting and has the additional scope required for each offered action.",
|
||||||
|
"The selected dataset and every contributing row remain authorized by their owner providers.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Select or create a dataset definition and pin an immutable source revision.",
|
||||||
|
"Define safe dimensions, measures, filters, pivots, and a report revision.",
|
||||||
|
"Run the pinned revision and review quality, policy, provenance, and diagnostics evidence.",
|
||||||
|
"Inspect authorized rows or use a signed drill context that reauthorizes every contributor.",
|
||||||
|
"Export, schedule, or publish only when the corresponding action and target are authorized.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Reporting never replaces source-module authorization or authoritative source correction.",
|
||||||
|
"XLSX and PDF output require an installed renderer provider; native browser export is CSV or JSON.",
|
||||||
|
],
|
||||||
|
"operational_consequences": {
|
||||||
|
"run": "Creates immutable execution, provenance, quality, diagnostic, and output-hash evidence.",
|
||||||
|
"publish": "Creates idempotent target-delivery evidence and may cause an external effect.",
|
||||||
|
"schedule": "Allows future executions under the then-current authorization and policy state.",
|
||||||
|
},
|
||||||
|
"verification": [
|
||||||
|
"The execution names the pinned definition and source fingerprints.",
|
||||||
|
"Quality and policy results are visible before publication evidence is accepted.",
|
||||||
|
"Drilldown and publication access are reauthorized for the current principal.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Gesteuertes Reporting und semantische BI",
|
||||||
|
"summary": (
|
||||||
|
"Reproduzierbare Berichte auf anbietergeführten Datensätzen erstellen, "
|
||||||
|
"ohne Modul- oder Zeilenberechtigungen zu umgehen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Reporting fixiert Revisionen von Datensätzen, semantischen Modellen und "
|
||||||
|
"Berichten. Ausführungen bewahren Definitionsprüfsummen, Quellfingerabdrücke, "
|
||||||
|
"Richtlinienherkunft, Qualitätsnachweise, berechtigte Ergebniszeilen, Diagnosen "
|
||||||
|
"und Ausgabeprüfsummen. Sichere Dimensionen, Aggregationen, typisierte Ausdrücke, "
|
||||||
|
"Filter, Pivotierungen, gespeicherte Ansichten, Diagrammmodelle, Zeitpläne, "
|
||||||
|
"Exporte und Veröffentlichungsanbieter ersetzen ungeprüftes SQL in der "
|
||||||
|
"Darstellungsschicht. PostgreSQL führt begrenzte semantische Pläne aus, sofern "
|
||||||
|
"verfügbar. Kennungen berechneter Kennzahlen dürfen auch in verschachtelten Verweisen die vorgesehenen Punkte und Bindestriche enthalten; "
|
||||||
|
"erzeugte Bindungsnamen bleiben intern, und Werte bleiben Parameter statt SQL-Fragmente. Signierte Drilldown-Kontexte autorisieren beitragende Zeilen erneut; "
|
||||||
|
"Adapter für Dateien und Mail bewahren idempotente Nachweise. Ein Datensatz kann "
|
||||||
|
"genau eine erfolgreiche veröffentlichte Dataflow-Ausführung fixieren, die nach "
|
||||||
|
"erneuter Autorisierung beider Quellgrenzen aus ihrer exakten Datasource-"
|
||||||
|
"Materialisierung gelesen wird. Dataflow und die Lesemodelle der Module bleiben "
|
||||||
|
"führende Quellen. Die Aktion zum Aufschlüsseln beitragender Zeilen bleibt in einer gemeinsamen Aktionsspalte am rechten Rand horizontal gescrollter Ergebnisse; beim Öffnen wird jeder Beitrag erneut autorisiert."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"purpose": (
|
||||||
|
"Einen reproduzierbaren Bericht über einen berechtigten, revisionsgenau fixierten Datensatz erstellen und ausführen."
|
||||||
|
),
|
||||||
|
"prerequisites": [
|
||||||
|
"Die handelnde Person darf Reporting lesen und besitzt für jede angebotene Aktion die zusätzlich erforderliche Berechtigung.",
|
||||||
|
"Der ausgewählte Datensatz und jede beitragende Zeile bleiben durch ihre führenden Anbieter autorisiert.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Eine Datensatzdefinition auswählen oder erstellen und eine unveränderliche Quellrevision fixieren.",
|
||||||
|
"Sichere Dimensionen, Kennzahlen, Filter, Pivotierungen und eine Berichtsrevision definieren.",
|
||||||
|
"Die fixierte Revision ausführen und Qualitäts-, Richtlinien-, Provenienz- und Diagnosenachweise prüfen.",
|
||||||
|
"Berechtigte Zeilen prüfen oder einen signierten Drilldown-Kontext verwenden, der jeden Beitrag erneut autorisiert.",
|
||||||
|
"Nur mit der jeweiligen Aktions- und Zielberechtigung exportieren, planen oder veröffentlichen.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Reporting ersetzt weder die Autorisierung der Quellmodule noch die Berichtigung in der führenden Quelle.",
|
||||||
|
"XLSX- und PDF-Ausgaben erfordern einen installierten Renderer-Anbieter; der native Browserexport unterstützt CSV und JSON.",
|
||||||
|
],
|
||||||
|
"operational_consequences": {
|
||||||
|
"run": "Erzeugt unveränderliche Nachweise zu Ausführung, Provenienz, Qualität, Diagnosen und Ausgabeprüfsumme.",
|
||||||
|
"publish": "Erzeugt idempotente Nachweise zur Zielzustellung und kann eine externe Wirkung auslösen.",
|
||||||
|
"schedule": "Erlaubt künftige Ausführungen unter dem dann gültigen Berechtigungs- und Richtlinienstand.",
|
||||||
|
},
|
||||||
|
"verification": [
|
||||||
|
"Die Ausführung nennt die fixierte Definition und die Quellfingerabdrücke.",
|
||||||
|
"Qualitäts- und Richtlinienergebnisse sind sichtbar, bevor ein Veröffentlichungsnachweis akzeptiert wird.",
|
||||||
|
"Drilldown- und Veröffentlichungszugriffe werden für die aktuelle Person erneut autorisiert.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Reporting module boundary",
|
label="Reporting module boundary",
|
||||||
|
|||||||
@@ -344,7 +344,9 @@ def _calculated_sql(
|
|||||||
return _calculated_sql(
|
return _calculated_sql(
|
||||||
target.expression,
|
target.expression,
|
||||||
parameters,
|
parameters,
|
||||||
prefix + "_" + reference,
|
# The expression position already makes this prefix unique. Model
|
||||||
|
# keys may contain dots or hyphens, which are not SQL bind names.
|
||||||
|
prefix + "_ref",
|
||||||
measures=measures,
|
measures=measures,
|
||||||
stack=(*stack, reference),
|
stack=(*stack, reference),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
documentation_structured_translation_issues,
|
||||||
|
user_workflow_scope_condition_issues,
|
||||||
|
)
|
||||||
|
from govoplan_reporting.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingDocumentationTests(unittest.TestCase):
|
||||||
|
def test_public_topics_have_complete_german_reference_content(self) -> None:
|
||||||
|
self.assertEqual(2, len(manifest.documentation))
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
translation = topic.translations.get("de", {})
|
||||||
|
self.assertTrue(
|
||||||
|
all(translation.get(key) for key in ("title", "summary", "body"))
|
||||||
|
)
|
||||||
|
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||||
|
|
||||||
|
def test_documentation_has_scope_conditioned_workflow_and_reference(self) -> None:
|
||||||
|
kinds = {topic.metadata.get("kind") for topic in manifest.documentation}
|
||||||
|
self.assertIn("workflow", kinds)
|
||||||
|
self.assertIn("reference", kinds)
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
self.assertEqual((), user_workflow_scope_condition_issues(topic))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
from govoplan_reporting.backend.db.models import (
|
||||||
|
ReportingDefinitionGrant,
|
||||||
|
ReportingDefinitionIdentity,
|
||||||
|
ReportingDefinitionRevision,
|
||||||
|
ReportingDrillContext,
|
||||||
|
ReportingExecution,
|
||||||
|
ReportingImportAssessment,
|
||||||
|
ReportingProviderExecution,
|
||||||
|
ReportingProviderExport,
|
||||||
|
ReportingPublication,
|
||||||
|
ReportingQualityResult,
|
||||||
|
ReportingSavedView,
|
||||||
|
ReportingSchedule,
|
||||||
|
)
|
||||||
|
from govoplan_reporting.backend.dsar_provider import (
|
||||||
|
REPORTING_DSAR_CAPABILITY,
|
||||||
|
ReportingDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_reporting.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 21, 18, 0, tzinfo=UTC)
|
||||||
|
SECRET = "personal-report-detail-do-not-export"
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: ReportingDsarProvider, *, active: bool = True) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (REPORTING_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "reporting"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
active = self.active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("reporting",) if active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "reporting"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != REPORTING_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = ReportingDsarProvider()
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
self._seed()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _seed(self) -> None:
|
||||||
|
identity = ReportingDefinitionIdentity(
|
||||||
|
id="definition-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_kind="report",
|
||||||
|
definition_id="report-1",
|
||||||
|
definition_key="resident-permits",
|
||||||
|
created_by="account-1",
|
||||||
|
)
|
||||||
|
self.session.add(identity)
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add(
|
||||||
|
ReportingDefinitionRevision(
|
||||||
|
id="revision-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id=identity.id,
|
||||||
|
definition_kind="report",
|
||||||
|
definition_id="report-1",
|
||||||
|
definition_key="resident-permits",
|
||||||
|
revision=1,
|
||||||
|
name=SECRET,
|
||||||
|
description=SECRET,
|
||||||
|
status="active",
|
||||||
|
visibility="restricted",
|
||||||
|
content_hash="a" * 64,
|
||||||
|
change_reason=SECRET,
|
||||||
|
idempotency_key=SECRET,
|
||||||
|
request_sha256="b" * 64,
|
||||||
|
event_id="event-1",
|
||||||
|
recorded_at=NOW,
|
||||||
|
payload={"secret": SECRET},
|
||||||
|
changed_by="account-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
self._execution(
|
||||||
|
row_id="execution-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
execution_id="execution-1",
|
||||||
|
actor_id="account-1",
|
||||||
|
),
|
||||||
|
self._execution(
|
||||||
|
row_id="execution-row-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
execution_id="execution-1",
|
||||||
|
actor_id="account-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider_execution = ReportingProviderExecution(
|
||||||
|
id="provider-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
execution_id="provider-execution-1",
|
||||||
|
provider_id="cases.reports",
|
||||||
|
report_id="resident-permits",
|
||||||
|
report_revision="1",
|
||||||
|
contract_version="1.0.0",
|
||||||
|
idempotency_key=SECRET,
|
||||||
|
request_sha256="c" * 64,
|
||||||
|
purpose=SECRET,
|
||||||
|
audience_scope={"secret": SECRET},
|
||||||
|
parameters={"secret": SECRET},
|
||||||
|
result_schema=[{"secret": SECRET}],
|
||||||
|
result_payload={"secret": SECRET},
|
||||||
|
source_revisions=[{"secret": SECRET}],
|
||||||
|
effective_scope={"secret": SECRET},
|
||||||
|
privacy_transforms=["small_cell_suppression"],
|
||||||
|
provenance={"secret": SECRET},
|
||||||
|
governance_provenance={"secret": SECRET},
|
||||||
|
retention_class="short",
|
||||||
|
retention_days=30,
|
||||||
|
expires_at=NOW + timedelta(days=30),
|
||||||
|
output_hash="d" * 64,
|
||||||
|
generated_at=NOW,
|
||||||
|
actor_id="account-1",
|
||||||
|
)
|
||||||
|
self.session.add(provider_execution)
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
ReportingProviderExport(
|
||||||
|
id="export-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
export_id="export-1",
|
||||||
|
provider_execution_id=provider_execution.id,
|
||||||
|
execution_id=provider_execution.execution_id,
|
||||||
|
format="json",
|
||||||
|
purpose=SECRET,
|
||||||
|
audience_scope={"secret": SECRET},
|
||||||
|
output_hash="e" * 64,
|
||||||
|
exported_at=NOW,
|
||||||
|
actor_id="account-1",
|
||||||
|
),
|
||||||
|
ReportingDefinitionGrant(
|
||||||
|
id="grant-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_kind="report",
|
||||||
|
definition_id="report-1",
|
||||||
|
subject_kind="account",
|
||||||
|
subject_id="account-1",
|
||||||
|
permissions=["view"],
|
||||||
|
active=True,
|
||||||
|
source_revision=1,
|
||||||
|
),
|
||||||
|
ReportingSavedView(
|
||||||
|
id="view-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
view_id="view-1",
|
||||||
|
report_id="report-1",
|
||||||
|
report_revision=1,
|
||||||
|
owner_kind="account",
|
||||||
|
owner_id="account-1",
|
||||||
|
name=SECRET,
|
||||||
|
state={"secret": SECRET},
|
||||||
|
shared=False,
|
||||||
|
access={"secret": SECRET},
|
||||||
|
),
|
||||||
|
ReportingSavedView(
|
||||||
|
id="view-row-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
view_id="view-shared",
|
||||||
|
report_id="report-1",
|
||||||
|
report_revision=1,
|
||||||
|
owner_kind="account",
|
||||||
|
owner_id="account-1",
|
||||||
|
name=SECRET,
|
||||||
|
state={"secret": SECRET},
|
||||||
|
shared=True,
|
||||||
|
access={"secret": SECRET},
|
||||||
|
),
|
||||||
|
ReportingSchedule(
|
||||||
|
id="schedule-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
schedule_id="schedule-1",
|
||||||
|
report_id="report-1",
|
||||||
|
report_revision=1,
|
||||||
|
name=SECRET,
|
||||||
|
trigger_kind="interval",
|
||||||
|
trigger_config={"secret": SECRET},
|
||||||
|
parameters={"secret": SECRET},
|
||||||
|
query={"secret": SECRET},
|
||||||
|
publication_target={"secret": SECRET},
|
||||||
|
enabled=True,
|
||||||
|
next_run_at=NOW + timedelta(days=1),
|
||||||
|
created_by="account-1",
|
||||||
|
),
|
||||||
|
ReportingPublication(
|
||||||
|
id="publication-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
publication_id="publication-1",
|
||||||
|
execution_id="execution-1",
|
||||||
|
target_capability="files.artifact_store",
|
||||||
|
target_ref=SECRET,
|
||||||
|
format="json",
|
||||||
|
status="succeeded",
|
||||||
|
idempotency_key=SECRET,
|
||||||
|
evidence={"secret": SECRET},
|
||||||
|
error=SECRET,
|
||||||
|
completed_at=NOW,
|
||||||
|
),
|
||||||
|
ReportingDrillContext(
|
||||||
|
id="drill-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
drill_context_id="drill-1",
|
||||||
|
execution_id="execution-1",
|
||||||
|
token_sha256="f" * 64,
|
||||||
|
context_sha256="0" * 64,
|
||||||
|
actor_id="account-1",
|
||||||
|
dimension_path=[{"secret": SECRET}],
|
||||||
|
source_fingerprints=[{"secret": SECRET}],
|
||||||
|
policy_provenance={"secret": SECRET},
|
||||||
|
expires_at=NOW + timedelta(minutes=10),
|
||||||
|
),
|
||||||
|
ReportingQualityResult(
|
||||||
|
id="quality-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
result_id="quality-1",
|
||||||
|
quality_plan_id="quality-plan-1",
|
||||||
|
quality_plan_revision=1,
|
||||||
|
dataset_id="dataset-1",
|
||||||
|
dataset_revision=1,
|
||||||
|
status="passed",
|
||||||
|
output_hash="1" * 64,
|
||||||
|
assertions=[{"secret": SECRET}],
|
||||||
|
source_fingerprints=[{"secret": SECRET}],
|
||||||
|
evaluated_at=NOW,
|
||||||
|
actor_id="account-1",
|
||||||
|
),
|
||||||
|
ReportingImportAssessment(
|
||||||
|
id="assessment-row-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
assessment_id="assessment-1",
|
||||||
|
source_system="legacy-bi",
|
||||||
|
source_id=SECRET,
|
||||||
|
source_fingerprint="2" * 64,
|
||||||
|
mapping_report={"secret": SECRET},
|
||||||
|
status="blocked",
|
||||||
|
accepted_approximations=[SECRET],
|
||||||
|
assessed_by="account-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _execution(
|
||||||
|
*,
|
||||||
|
row_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
execution_id: str,
|
||||||
|
actor_id: str,
|
||||||
|
) -> ReportingExecution:
|
||||||
|
return ReportingExecution(
|
||||||
|
id=row_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
execution_id=execution_id,
|
||||||
|
report_id="report-1",
|
||||||
|
report_revision=1,
|
||||||
|
semantic_model_id="semantic-1",
|
||||||
|
semantic_model_revision=1,
|
||||||
|
dataset_id="dataset-1",
|
||||||
|
dataset_revision=1,
|
||||||
|
status="succeeded",
|
||||||
|
idempotency_key=SECRET,
|
||||||
|
request_sha256="3" * 64,
|
||||||
|
parameters={"secret": SECRET},
|
||||||
|
query={"secret": SECRET},
|
||||||
|
source_fingerprints=[{"secret": SECRET}],
|
||||||
|
definition_hashes={"secret": SECRET},
|
||||||
|
output_hash="4" * 64,
|
||||||
|
executor_version="reporting-v1",
|
||||||
|
result_schema=[{"secret": SECRET}],
|
||||||
|
result_rows=[{"secret": SECRET}],
|
||||||
|
total_rows=1,
|
||||||
|
truncated=False,
|
||||||
|
diagnostics=[{"secret": SECRET}],
|
||||||
|
provenance={"secret": SECRET},
|
||||||
|
started_at=NOW,
|
||||||
|
finished_at=NOW,
|
||||||
|
actor_id=actor_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_canonical_selector_returns_minimized_owned_and_attribution_rows(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(12, len(records))
|
||||||
|
self.assertIn(
|
||||||
|
"subject_owned_reporting_view",
|
||||||
|
{record.category for record in records},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"reporting_operator_attribution",
|
||||||
|
{record.category for record in records},
|
||||||
|
)
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
self.assertNotIn(SECRET, exported)
|
||||||
|
self.assertNotIn("account-1", exported)
|
||||||
|
self.assertNotIn("execution-row-2", exported)
|
||||||
|
|
||||||
|
def test_direct_references_are_exact_tenant_scoped_and_corroborated(self) -> None:
|
||||||
|
direct = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={"reporting.execution": "execution-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mismatch = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-2",
|
||||||
|
external_references={"reporting.execution": "execution-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
wrong_tenant = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={"reporting.saved_view": "view-1"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"reporting.definition_revision": "revision-row-1",
|
||||||
|
"reporting.revision": "different-revision",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["execution-row-1"], [item.resource_id for item in direct])
|
||||||
|
self.assertEqual("derived_report_result", direct[0].category)
|
||||||
|
self.assertEqual((), mismatch)
|
||||||
|
self.assertEqual((), wrong_tenant)
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
|
||||||
|
def test_every_exact_artifact_reference_is_supported(self) -> None:
|
||||||
|
references = {
|
||||||
|
"reporting.definition": ("report-1", 2),
|
||||||
|
"reporting.definition_revision": ("revision-row-1", 1),
|
||||||
|
"reporting.provider_execution": ("provider-execution-1", 1),
|
||||||
|
"reporting.provider_export": ("export-1", 1),
|
||||||
|
"reporting.definition_grant": ("grant-row-1", 1),
|
||||||
|
"reporting.saved_view": ("view-1", 1),
|
||||||
|
"reporting.schedule": ("schedule-1", 1),
|
||||||
|
"reporting.publication": ("publication-1", 1),
|
||||||
|
"reporting.drill_context": ("drill-1", 1),
|
||||||
|
"reporting.quality_result": ("quality-1", 1),
|
||||||
|
"reporting.import_assessment": ("assessment-1", 1),
|
||||||
|
}
|
||||||
|
for key, (value, expected) in references.items():
|
||||||
|
with self.subTest(key=key):
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(external_references={key: value}),
|
||||||
|
)
|
||||||
|
self.assertEqual(expected, len(records))
|
||||||
|
|
||||||
|
def test_planning_and_execution_preserve_governed_boundaries(self) -> None:
|
||||||
|
subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
by_resource = {action.resource_id: action for action in actions}
|
||||||
|
self.assertEqual("delete", by_resource["view-row-1"].kind)
|
||||||
|
self.assertEqual("manual_review", by_resource["view-row-2"].kind)
|
||||||
|
self.assertEqual("delete", by_resource["drill-row-1"].kind)
|
||||||
|
self.assertEqual("revoke", by_resource["grant-row-1"].kind)
|
||||||
|
self.assertEqual("retain", by_resource["execution-row-1"].kind)
|
||||||
|
|
||||||
|
first = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
second = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1-retry",
|
||||||
|
)
|
||||||
|
self.assertIn("executed", {result.status for result in first})
|
||||||
|
self.assertIn("blocked", {result.status for result in first})
|
||||||
|
self.assertIn("unchanged", {result.status for result in second})
|
||||||
|
self.assertIsNone(self.session.get(ReportingSavedView, "view-row-1"))
|
||||||
|
self.assertIsNone(self.session.get(ReportingDrillContext, "drill-row-1"))
|
||||||
|
self.assertFalse(
|
||||||
|
self.session.get(ReportingDefinitionGrant, "grant-row-1").active
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[{"secret": SECRET}],
|
||||||
|
self.session.get(ReportingExecution, "execution-row-1").result_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exact_derived_detail_is_minimized_idempotently(self) -> None:
|
||||||
|
subject = DsarSubjectRef(
|
||||||
|
external_references={
|
||||||
|
"reporting.execution": "execution-1",
|
||||||
|
"reporting.provider_execution": "provider-execution-1",
|
||||||
|
"reporting.provider_export": "export-1",
|
||||||
|
"reporting.publication": "publication-1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
self.assertEqual({"anonymize"}, {action.kind for action in actions})
|
||||||
|
first = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-2",
|
||||||
|
)
|
||||||
|
second = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-2-retry",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(all(result.status == "executed" for result in first))
|
||||||
|
self.assertTrue(all(result.status == "unchanged" for result in second))
|
||||||
|
execution = self.session.get(ReportingExecution, "execution-row-1")
|
||||||
|
provider_execution = self.session.get(
|
||||||
|
ReportingProviderExecution,
|
||||||
|
"provider-row-1",
|
||||||
|
)
|
||||||
|
publication = self.session.get(
|
||||||
|
ReportingPublication,
|
||||||
|
"publication-row-1",
|
||||||
|
)
|
||||||
|
provider_export = self.session.get(ReportingProviderExport, "export-row-1")
|
||||||
|
self.assertEqual([], execution.result_rows)
|
||||||
|
self.assertEqual({}, execution.parameters)
|
||||||
|
self.assertEqual({}, provider_execution.result_payload)
|
||||||
|
self.assertIsNotNone(provider_execution.retention_redacted_at)
|
||||||
|
self.assertEqual({}, provider_export.audience_scope)
|
||||||
|
self.assertEqual(
|
||||||
|
"Redacted by data-subject request.",
|
||||||
|
provider_export.purpose,
|
||||||
|
)
|
||||||
|
self.assertIsNone(publication.target_ref)
|
||||||
|
self.assertEqual({}, publication.evidence)
|
||||||
|
self.assertIsNone(publication.error)
|
||||||
|
|
||||||
|
def test_foreign_records_and_actions_are_rejected(self) -> None:
|
||||||
|
subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||||
|
self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=(
|
||||||
|
DsarRecordRef(
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
category="case",
|
||||||
|
title="Case",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id="cases:delete:case:case-1",
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
title="Delete case",
|
||||||
|
rationale="Foreign",
|
||||||
|
executable=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
request_id="dsar-3",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
|
||||||
|
row = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-REPORTING-1",
|
||||||
|
request_kind="access_and_erasure",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 and 17 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=row,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[REPORTING_DSAR_CAPABILITY],
|
||||||
|
row.coverage["provider_capabilities"],
|
||||||
|
)
|
||||||
|
self.assertEqual(12, row.search_result["record_count"])
|
||||||
|
|
||||||
|
inactive = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-REPORTING-2",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
purpose="Respond to a verified request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, active=False),
|
||||||
|
row=inactive,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||||
|
self.assertEqual(
|
||||||
|
[REPORTING_DSAR_CAPABILITY],
|
||||||
|
inactive.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
self.assertEqual(0, inactive.search_result["record_count"])
|
||||||
|
|
||||||
|
def test_manifest_registers_and_documents_capability(self) -> None:
|
||||||
|
self.assertIn(REPORTING_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(REPORTING_DSAR_CAPABILITY, manifest.capability_documentation)
|
||||||
|
self.assertIn(
|
||||||
|
REPORTING_DSAR_CAPABILITY,
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
topic.id == "reporting.data-subject-requests"
|
||||||
|
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||||
|
for topic in manifest.documentation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -14,7 +14,7 @@ class ReportingManifestTests(unittest.TestCase):
|
|||||||
self.assertEqual("@govoplan/reporting-webui", manifest.frontend.package_name)
|
self.assertEqual("@govoplan/reporting-webui", manifest.frontend.package_name)
|
||||||
self.assertIsNotNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNotNone(manifest.migration_spec)
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
self.assertEqual(7, len(manifest.provides_interfaces))
|
self.assertEqual(8, len(manifest.provides_interfaces))
|
||||||
self.assertEqual(1, len(manifest.search_sources))
|
self.assertEqual(1, len(manifest.search_sources))
|
||||||
self.assertIn("dataflow", manifest.optional_dependencies)
|
self.assertIn("dataflow", manifest.optional_dependencies)
|
||||||
self.assertIn("policy", manifest.optional_dependencies)
|
self.assertIn("policy", manifest.optional_dependencies)
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from govoplan_reporting.backend.postgres_planner import compile_postgres_query
|
||||||
|
from govoplan_reporting.backend.schemas import (
|
||||||
|
DatasetDefinition,
|
||||||
|
ReportQuery,
|
||||||
|
SemanticModelDefinition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresBindNameTests(unittest.TestCase):
|
||||||
|
def test_allowed_measure_key_punctuation_never_becomes_bind_parameter_syntax(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
dataset = DatasetDefinition(
|
||||||
|
source_kind="static",
|
||||||
|
source_ref="fixture",
|
||||||
|
static_rows=[{"value": 10}],
|
||||||
|
purpose="Bound parameter fixture",
|
||||||
|
)
|
||||||
|
semantic = SemanticModelDefinition.model_validate(
|
||||||
|
{
|
||||||
|
"dataset_id": "fixture",
|
||||||
|
"dataset_revision": 1,
|
||||||
|
"measures": [
|
||||||
|
{
|
||||||
|
"key": "base",
|
||||||
|
"label": "Base",
|
||||||
|
"aggregation": "sum",
|
||||||
|
"field": "value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "extra-cost",
|
||||||
|
"label": "Extra",
|
||||||
|
"aggregation": "calculated",
|
||||||
|
"expression": {
|
||||||
|
"op": "add",
|
||||||
|
"args": [
|
||||||
|
{"op": "measure", "ref": "base"},
|
||||||
|
{"op": "literal", "value": 5},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "tax.factor",
|
||||||
|
"label": "Tax",
|
||||||
|
"aggregation": "calculated",
|
||||||
|
"expression": {
|
||||||
|
"op": "multiply",
|
||||||
|
"args": [
|
||||||
|
{"op": "measure", "ref": "base"},
|
||||||
|
{"op": "literal", "value": 9},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "grand-total",
|
||||||
|
"label": "Total",
|
||||||
|
"aggregation": "calculated",
|
||||||
|
"expression": {
|
||||||
|
"op": "add",
|
||||||
|
"args": [
|
||||||
|
{"op": "measure", "ref": "extra-cost"},
|
||||||
|
{"op": "measure", "ref": "tax.factor"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
plan = compile_postgres_query(
|
||||||
|
dataset,
|
||||||
|
semantic,
|
||||||
|
ReportQuery(measures=["extra-cost", "tax.factor", "grand-total"]),
|
||||||
|
)
|
||||||
|
binds = text(plan.sql).compile(dialect=postgresql.dialect()).params
|
||||||
|
self.assertEqual(
|
||||||
|
set(plan.parameters) | {"rows_json", "result_limit", "result_offset"},
|
||||||
|
set(binds),
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all("-" not in name and "." not in name for name in plan.parameters)
|
||||||
|
)
|
||||||
|
self.assertIn('AS "extra-cost"', plan.sql)
|
||||||
|
self.assertIn('AS "tax.factor"', plan.sql)
|
||||||
|
self.assertEqual(2, list(plan.parameters.values()).count(5))
|
||||||
|
self.assertEqual(2, list(plan.parameters.values()).count(9))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -92,7 +92,7 @@ class _Provider:
|
|||||||
del session
|
del session
|
||||||
return ReportProviderResult(
|
return ReportProviderResult(
|
||||||
report_id=request.report_id,
|
report_id=request.report_id,
|
||||||
generated_at=datetime(2026, 8, 2, 10, 0, tzinfo=UTC),
|
generated_at=datetime.now(UTC),
|
||||||
payload={"metric": 12},
|
payload={"metric": 12},
|
||||||
source_revisions=(
|
source_revisions=(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -384,7 +384,7 @@ class ReportingServiceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(raised.exception.execution_id)
|
self.assertTrue(raised.exception.execution_id)
|
||||||
|
|
||||||
def test_dataflow_dataset_can_pin_an_exact_published_run(self) -> None:
|
def test_dataflow_run_can_be_exported_as_formula_safe_csv(self) -> None:
|
||||||
payload = dataset_payload()
|
payload = dataset_payload()
|
||||||
rows = list(payload.pop("static_rows"))
|
rows = list(payload.pop("static_rows"))
|
||||||
payload.update(
|
payload.update(
|
||||||
@@ -410,7 +410,7 @@ class ReportingServiceTests(unittest.TestCase):
|
|||||||
report_id="report-1",
|
report_id="report-1",
|
||||||
report_revision=1,
|
report_revision=1,
|
||||||
parameters={},
|
parameters={},
|
||||||
query=None,
|
query=ReportQuery(mode="detail", dimensions=["note"]),
|
||||||
idempotency_key="published-dataflow-run",
|
idempotency_key="published-dataflow-run",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -421,6 +421,15 @@ class ReportingServiceTests(unittest.TestCase):
|
|||||||
provider.last_request.run_ref,
|
provider.last_request.run_ref,
|
||||||
)
|
)
|
||||||
self.assertTrue(result["provenance"]["source"]["immutable_run"])
|
self.assertTrue(result["provenance"]["source"]["immutable_run"])
|
||||||
|
content, content_type, filename = export_execution(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
execution_id=str(result["execution_id"]),
|
||||||
|
format="csv",
|
||||||
|
)
|
||||||
|
self.assertEqual("text/csv; charset=utf-8", content_type)
|
||||||
|
self.assertTrue(filename.endswith(".csv"))
|
||||||
|
self.assertIn("'=cmd", content.decode("utf-8-sig"))
|
||||||
|
|
||||||
def test_restricted_access_and_service_scope_guards(self) -> None:
|
def test_restricted_access_and_service_scope_guards(self) -> None:
|
||||||
self._create_report_graph(
|
self._create_report_graph(
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/reporting-webui",
|
"name": "@govoplan/reporting-webui",
|
||||||
"version": "0.1.17",
|
"version": "0.1.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.17",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ assert.ok(page.includes("PageScrollViewport"), "Reporting owns bounded catalogue
|
|||||||
assert.ok(page.includes("DataGrid"), "Tabular report results use the shared grid");
|
assert.ok(page.includes("DataGrid"), "Tabular report results use the shared grid");
|
||||||
assert.ok(page.includes("<Dialog"), "Save and schedule operations use shared dialogs");
|
assert.ok(page.includes("<Dialog"), "Save and schedule operations use shared dialogs");
|
||||||
assert.ok(page.includes("createDrillContext"), "Aggregate detail uses an actor-bound drill context");
|
assert.ok(page.includes("createDrillContext"), "Aggregate detail uses an actor-bound drill context");
|
||||||
|
assert.match(page, /id: "drill",\s*header: "Detail",\s*columnType: "actions",\s*sticky: "end"/, "Result drill-down controls use the shared pinned action-column contract");
|
||||||
|
assert.match(page, /<TableActionGroup actions=\{\[\{\s*id: "drill"/, "Drill-down renders the shared measurable action surface");
|
||||||
assert.ok(page.includes("AccessExplanation"), "Policy-hidden fields, rows, and actions are explained");
|
assert.ok(page.includes("AccessExplanation"), "Policy-hidden fields, rows, and actions are explained");
|
||||||
assert.ok(page.includes("PublishDialog"), "Publication targets use the shared dialog surface");
|
assert.ok(page.includes("PublishDialog"), "Publication targets use the shared dialog surface");
|
||||||
assert.ok(provider.includes("disabledReason={runDisabledReason}"), "Governed report blockers remain keyboard-explainable");
|
assert.ok(provider.includes("disabledReason={runDisabledReason}"), "Governed report blockers remain keyboard-explainable");
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { Download, FileJson, Play, ShieldCheck } from "lucide-react";
|
import { Download, FileJson, Play, ShieldCheck } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { ContentGrid,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
IconButton,
|
IconButton,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
WorkspaceActionBar,
|
||||||
hasScope,
|
hasScope,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo
|
type AuthInfo
|
||||||
@@ -174,20 +177,25 @@ export function ProviderReportWorkspace({ settings, auth, report }: {
|
|||||||
</div>
|
</div>
|
||||||
{execution ?
|
{execution ?
|
||||||
<>
|
<>
|
||||||
<div className="reporting-output-toolbar">
|
<WorkspaceActionBar
|
||||||
<span>Generated {formatDateTime(execution.generated_at)}</span>
|
scope="detail-pane"
|
||||||
|
variant="detail"
|
||||||
|
className="reporting-output-toolbar"
|
||||||
|
contextActions={<span>Generated {formatDateTime(execution.generated_at)}</span>}
|
||||||
|
primaryActions={<>
|
||||||
{report.export_formats.includes("csv") &&
|
{report.export_formats.includes("csv") &&
|
||||||
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void download("csv")} />
|
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void download("csv")} />
|
||||||
}
|
}
|
||||||
{report.export_formats.includes("json") &&
|
{report.export_formats.includes("json") &&
|
||||||
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void download("json")} />
|
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void download("json")} />
|
||||||
}
|
}
|
||||||
</div>
|
</>}
|
||||||
|
/>
|
||||||
<div className="reporting-provider-output">
|
<div className="reporting-provider-output">
|
||||||
<ProviderResult execution={execution} />
|
<ProviderResult execution={execution} />
|
||||||
</div>
|
</div>
|
||||||
</> :
|
</> :
|
||||||
<div className="reporting-empty">Select the parameters and run this governed report.</div>
|
<StatePanel size="fill" description="Select the parameters and run this governed report." />
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -227,33 +235,33 @@ function ProviderResult({ execution }: { execution: ProviderReportExecution }) {
|
|||||||
return (
|
return (
|
||||||
<Card title={group} key={group}>
|
<Card title={group} key={group}>
|
||||||
{metrics.length > 0 &&
|
{metrics.length > 0 &&
|
||||||
<div className="dashboard-grid reporting-provider-metrics">
|
<ContentGrid columns={2} collapseAt="workspace" className="reporting-provider-metrics">
|
||||||
{metrics.map((field) =>
|
{metrics.map((field) =>
|
||||||
<MetricCard key={field.path} label={field.label} value={displayValue(pathValue(execution.result, field.path))} />
|
<MetricCard key={field.path} label={field.label} value={displayValue(pathValue(execution.result, field.path))} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</ContentGrid>
|
||||||
}
|
}
|
||||||
{details.length > 0 &&
|
{details.length > 0 &&
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
{details.map((field) =>
|
{details.map((field) =>
|
||||||
<div key={field.path}>
|
<div key={field.path}>
|
||||||
<dt>{field.label}</dt>
|
<dt>{field.label}</dt>
|
||||||
<dd>{displayValue(pathValue(execution.result, field.path), field)}</dd>
|
<dd>{displayValue(pathValue(execution.result, field.path), field)}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
}
|
}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Card title="Provenance">
|
<Card title="Provenance">
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
<div><dt>Purpose</dt><dd>{execution.purpose}</dd></div>
|
<div><dt>Purpose</dt><dd>{execution.purpose}</dd></div>
|
||||||
<div><dt>Output hash</dt><dd title={execution.output_hash}>{shortHash(execution.output_hash)}</dd></div>
|
<div><dt>Output hash</dt><dd title={execution.output_hash}>{shortHash(execution.output_hash)}</dd></div>
|
||||||
<div><dt>Source revisions</dt><dd>{execution.source_revisions.length}</dd></div>
|
<div><dt>Source revisions</dt><dd>{execution.source_revisions.length}</dd></div>
|
||||||
<div><dt>Privacy transforms</dt><dd>{execution.privacy_transforms.join(", ")}</dd></div>
|
<div><dt>Privacy transforms</dt><dd>{execution.privacy_transforms.join(", ")}</dd></div>
|
||||||
<div><dt>Expires</dt><dd>{execution.expires_at ? formatDateTime(execution.expires_at) : "Policy managed"}</dd></div>
|
<div><dt>Expires</dt><dd>{execution.expires_at ? formatDateTime(execution.expires_at) : "Policy managed"}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
FolderOutput,
|
FolderOutput,
|
||||||
History,
|
History,
|
||||||
Play,
|
Play,
|
||||||
RefreshCw,
|
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
@@ -19,19 +18,27 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type FormEvent
|
type FormEvent
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import { FormGrid,
|
||||||
Button,
|
Button,
|
||||||
DataGrid,
|
DataGrid,
|
||||||
Dialog,
|
Dialog,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
FilterBar,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
|
StatePanel,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
hasScope,
|
hasScope,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
type DataGridColumn,
|
type DataGridColumn,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
@@ -249,9 +256,15 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="reporting-page">
|
<main className="reporting-page">
|
||||||
<div className="reporting-shell">
|
<WorkspaceFrame className="reporting-shell" label="Reporting workspace" interfaceId="reporting.workspace" helpContextId="reporting.page.workspace" helpModuleId="reporting">
|
||||||
<div className="reporting-toolbar">
|
<WorkspaceActionBar
|
||||||
<form className="reporting-search" onSubmit={submitSearch}>
|
scope="workspace"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void reload(), loading, label: "Reload reports" }}
|
||||||
|
className="reporting-toolbar"
|
||||||
|
contextActions={<>
|
||||||
|
<FilterBar as="form" surface="control" wrap="never" width="compact" className="reporting-search" onSubmit={submitSearch}>
|
||||||
<Search size={17} aria-hidden="true" />
|
<Search size={17} aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
@@ -259,19 +272,14 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
aria-label="Search reports"
|
aria-label="Search reports"
|
||||||
placeholder="Search reports"
|
placeholder="Search reports"
|
||||||
/>
|
/>
|
||||||
</form>
|
</FilterBar>
|
||||||
<span className="reporting-count">{reports.length + providerReports.length} reports</span>
|
<span className="reporting-count">{reports.length + providerReports.length} reports</span>
|
||||||
<DocumentationHelpLink
|
</>}
|
||||||
|
helpAction={<DocumentationHelpLink
|
||||||
reference={{ topicId: "reporting.governed-bi", documentationType: "user" }}
|
reference={{ topicId: "reporting.governed-bi", documentationType: "user" }}
|
||||||
label="Open reporting documentation"
|
label="Open reporting documentation"
|
||||||
|
/>}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
|
||||||
label="Reload reports"
|
|
||||||
icon={<RefreshCw size={17} />}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => void reload()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error &&
|
{error &&
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
{error}
|
{error}
|
||||||
@@ -280,38 +288,32 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
<div className="reporting-workspace">
|
<div className="reporting-workspace">
|
||||||
<PageScrollViewport className="reporting-catalogue">
|
<PageScrollViewport className="reporting-catalogue">
|
||||||
{loading && <LoadingIndicator label="Loading reports" />}
|
{loading && <LoadingIndicator label="Loading reports" />}
|
||||||
{!loading && reports.length + providerReports.length === 0 && <div className="reporting-empty">No active reports are available.</div>}
|
{!loading && reports.length + providerReports.length === 0 && <StatePanel size="compact" description="No active reports are available." />}
|
||||||
<div className="reporting-report-list" role="list">
|
<SelectionList variant="navigation" label="Reports">
|
||||||
{providerReports.length > 0 && <div className="reporting-list-heading">Module reports</div>}
|
{providerReports.length > 0 && <div className="reporting-list-heading" role="presentation">Module reports</div>}
|
||||||
{providerReports.map((item) => {
|
{providerReports.map((item) => {
|
||||||
const key = `${item.provider_id}:${item.report_id}`;
|
const key = `${item.provider_id}:${item.report_id}`;
|
||||||
return (
|
return (
|
||||||
<button
|
<SelectionListItem
|
||||||
type="button"
|
|
||||||
role="listitem"
|
|
||||||
key={key}
|
key={key}
|
||||||
className={`reporting-report-row${selectedProviderKey === key ? " is-selected" : ""}`}
|
selected={selectedProviderKey === key}
|
||||||
onClick={() => { setSelectedProviderKey(key); setSelectedId(""); }}>
|
onClick={() => { setSelectedProviderKey(key); setSelectedId(""); }}>
|
||||||
<BarChart3 size={17} aria-hidden="true" />
|
<SelectionListItemContent leading={<BarChart3 size={17} />} title={item.title} description={`${item.provider_id} · ${item.revision}`} />
|
||||||
<span><strong>{item.title}</strong><small>{item.provider_id} · {item.revision}</small></span>
|
|
||||||
<StatusBadge status={item.available ? "active" : "locked"} label={item.available ? "Available" : "Restricted"} />
|
<StatusBadge status={item.available ? "active" : "locked"} label={item.available ? "Available" : "Restricted"} />
|
||||||
</button>
|
</SelectionListItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{reports.length > 0 && <div className="reporting-list-heading">Semantic reports</div>}
|
{reports.length > 0 && <div className="reporting-list-heading" role="presentation">Semantic reports</div>}
|
||||||
{reports.map((item) =>
|
{reports.map((item) =>
|
||||||
<button
|
<SelectionListItem
|
||||||
type="button"
|
|
||||||
role="listitem"
|
|
||||||
key={item.definition_id}
|
key={item.definition_id}
|
||||||
className={`reporting-report-row${selectedId === item.definition_id ? " is-selected" : ""}`}
|
selected={selectedId === item.definition_id}
|
||||||
onClick={() => { setSelectedId(item.definition_id); setSelectedProviderKey(""); }}>
|
onClick={() => { setSelectedId(item.definition_id); setSelectedProviderKey(""); }}>
|
||||||
<BarChart3 size={17} aria-hidden="true" />
|
<SelectionListItemContent leading={<BarChart3 size={17} />} title={item.name} description={`${item.definition_key} · r${item.revision}`} />
|
||||||
<span><strong>{item.name}</strong><small>{item.definition_key} · r{item.revision}</small></span>
|
|
||||||
<StatusBadge status="active" label="Active" />
|
<StatusBadge status="active" label="Active" />
|
||||||
</button>
|
</SelectionListItem>
|
||||||
)}
|
)}
|
||||||
</div>
|
</SelectionList>
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
<section className="reporting-result-region">
|
<section className="reporting-result-region">
|
||||||
{selectedProvider ?
|
{selectedProvider ?
|
||||||
@@ -345,8 +347,11 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
onQueryChange={setQuery}
|
onQueryChange={setQuery}
|
||||||
onParametersChange={setParameters}
|
onParametersChange={setParameters}
|
||||||
/>
|
/>
|
||||||
<div className="reporting-output-toolbar">
|
<WorkspaceActionBar
|
||||||
<SegmentedControl
|
scope="detail-pane"
|
||||||
|
variant="detail"
|
||||||
|
className="reporting-output-toolbar"
|
||||||
|
contextActions={<SegmentedControl
|
||||||
ariaLabel="Report output"
|
ariaLabel="Report output"
|
||||||
value={outputMode}
|
value={outputMode}
|
||||||
onChange={setOutputMode}
|
onChange={setOutputMode}
|
||||||
@@ -354,25 +359,25 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
{ id: "visual", label: <><BarChart3 size={15} /> Visual</> },
|
{ id: "visual", label: <><BarChart3 size={15} /> Visual</> },
|
||||||
{ id: "table", label: <><Table2 size={15} /> Table</> }
|
{ id: "table", label: <><Table2 size={15} /> Table</> }
|
||||||
]}
|
]}
|
||||||
/>
|
/>}
|
||||||
{execution &&
|
primaryActions={execution ?
|
||||||
<>
|
<>
|
||||||
<span>{execution.total_rows} rows{execution.truncated ? " (truncated)" : ""}</span>
|
<span>{execution.total_rows} rows{execution.truncated ? " (truncated)" : ""}</span>
|
||||||
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "csv").catch((reason) => setError(message(reason)))} />
|
<IconButton label="Download CSV" icon={<Download size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "csv").catch((reason) => setError(message(reason)))} />
|
||||||
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "json").catch((reason) => setError(message(reason)))} />
|
<IconButton label="Download JSON" icon={<FileJson size={17} />} variant="ghost" onClick={() => void downloadExecution(settings, execution.execution_id, "json").catch((reason) => setError(message(reason)))} />
|
||||||
{canPublish &&
|
{canPublish &&
|
||||||
<IconButton label="Publish report" icon={<FolderOutput size={17} />} variant="ghost" onClick={() => setPublishDialogOpen(true)} />
|
<IconButton label="Publish report" helpContextId="reporting.publications" helpModuleId="reporting" icon={<FolderOutput size={17} />} variant="ghost" onClick={() => setPublishDialogOpen(true)} />
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
}
|
: undefined}
|
||||||
</div>
|
/>
|
||||||
<div className="reporting-output">
|
<div className="reporting-output">
|
||||||
{!execution && <div className="reporting-empty">Run the report or select a previous execution.</div>}
|
{!execution && <StatePanel size="fill" description="Run the report or select a previous execution." />}
|
||||||
{execution && outputMode === "visual" && <ReportVisual execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
|
{execution && outputMode === "visual" && <ReportVisual execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
|
||||||
{execution && outputMode === "table" && <ReportTable execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
|
{execution && outputMode === "table" && <ReportTable execution={execution} onDrill={execution.query.mode === "detail" ? undefined : drill} />}
|
||||||
</div>
|
</div>
|
||||||
</> :
|
</> :
|
||||||
<div className="reporting-empty">Select a report.</div>
|
<StatePanel size="fill" title="Reports" description="Select a report." />
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
<PageScrollViewport className="reporting-inspector">
|
<PageScrollViewport className="reporting-inspector">
|
||||||
@@ -398,7 +403,7 @@ export default function ReportingPage({ settings, auth }: PlatformRouteContext)
|
|||||||
/>}
|
/>}
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</WorkspaceFrame>
|
||||||
<SaveViewDialog
|
<SaveViewDialog
|
||||||
open={saveDialogOpen}
|
open={saveDialogOpen}
|
||||||
onClose={() => setSaveDialogOpen(false)}
|
onClose={() => setSaveDialogOpen(false)}
|
||||||
@@ -539,7 +544,7 @@ function ReportTable({ execution, onDrill }: { execution: ReportExecution; onDri
|
|||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
useEffect(() => setPage(0), [execution.execution_id]);
|
useEffect(() => setPage(0), [execution.execution_id]);
|
||||||
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => {
|
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => {
|
||||||
const result = execution.schema.map((field) => ({
|
const result: DataGridColumn<Record<string, unknown>>[] = execution.schema.map((field) => ({
|
||||||
id: field.name,
|
id: field.name,
|
||||||
header: humanize(field.name),
|
header: humanize(field.name),
|
||||||
width: "1fr",
|
width: "1fr",
|
||||||
@@ -555,16 +560,20 @@ function ReportTable({ execution, onDrill }: { execution: ReportExecution; onDri
|
|||||||
result.push({
|
result.push({
|
||||||
id: "drill",
|
id: "drill",
|
||||||
header: "Detail",
|
header: "Detail",
|
||||||
|
columnType: "actions",
|
||||||
|
sticky: "end",
|
||||||
|
resizable: false,
|
||||||
|
align: "right",
|
||||||
width: 74,
|
width: 74,
|
||||||
minWidth: 74,
|
minWidth: 74,
|
||||||
maxWidth: 74,
|
maxWidth: 74,
|
||||||
render: (row) => (
|
render: (row) => (
|
||||||
<IconButton
|
<TableActionGroup actions={[{
|
||||||
label="Show authorized contributing rows"
|
id: "drill",
|
||||||
icon={<ChevronRight size={16} />}
|
label: "Show authorized contributing rows",
|
||||||
variant="ghost"
|
icon: <ChevronRight size={16} aria-hidden="true" />,
|
||||||
onClick={() => onDrill(row)}
|
onClick: () => onDrill(row)
|
||||||
/>
|
}]} />
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -736,6 +745,8 @@ function PublishDialog({ open, targets, onClose, onPublish }: {
|
|||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
helpContextId="reporting.publications"
|
||||||
|
helpModuleId="reporting"
|
||||||
disabled={!valid || saving}
|
disabled={!valid || saving}
|
||||||
disabledReason={!target?.available ? target?.reason ?? "The selected target is unavailable." : undefined}
|
disabledReason={!target?.available ? target?.reason ?? "The selected target is unavailable." : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -757,7 +768,7 @@ function PublishDialog({ open, targets, onClose, onPublish }: {
|
|||||||
</Button>
|
</Button>
|
||||||
</>}>
|
</>}>
|
||||||
{dialogError && <DismissibleAlert tone="danger" resetKey={dialogError}>{dialogError}</DismissibleAlert>}
|
{dialogError && <DismissibleAlert tone="danger" resetKey={dialogError}>{dialogError}</DismissibleAlert>}
|
||||||
<div className="reporting-dialog-grid">
|
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||||
<label className="reporting-dialog-field">
|
<label className="reporting-dialog-field">
|
||||||
<span>Target</span>
|
<span>Target</span>
|
||||||
<select value={targetCapability} onChange={(event) => {
|
<select value={targetCapability} onChange={(event) => {
|
||||||
@@ -784,7 +795,7 @@ function PublishDialog({ open, targets, onClose, onPublish }: {
|
|||||||
<label className="reporting-dialog-field"><span>Sender address</span><input type="email" value={fromAddress} onChange={(event) => setFromAddress(event.target.value)} /></label>
|
<label className="reporting-dialog-field"><span>Sender address</span><input type="email" value={fromAddress} onChange={(event) => setFromAddress(event.target.value)} /></label>
|
||||||
<label className="reporting-dialog-field reporting-dialog-span"><span>Subject</span><input value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Generated from report name" /></label>
|
<label className="reporting-dialog-field reporting-dialog-span"><span>Subject</span><input value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Generated from report name" /></label>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</FormGrid>
|
||||||
{target?.reason && <DismissibleAlert tone="warning" dismissible={false}>{target.reason}</DismissibleAlert>}
|
{target?.reason && <DismissibleAlert tone="warning" dismissible={false}>{target.reason}</DismissibleAlert>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
@@ -936,10 +947,10 @@ function ScheduleDialog({ open, onClose, onSave }: { open: boolean; onClose: ()
|
|||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
<Button variant="primary" disabled={!name.trim() || saving} onClick={() => { setSaving(true); void onSave(name.trim(), Number(interval)).finally(() => setSaving(false)); }}>Schedule</Button>
|
<Button variant="primary" disabled={!name.trim() || saving} onClick={() => { setSaving(true); void onSave(name.trim(), Number(interval)).finally(() => setSaving(false)); }}>Schedule</Button>
|
||||||
</>}>
|
</>}>
|
||||||
<div className="reporting-dialog-grid">
|
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||||
<label className="reporting-dialog-field"><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
|
<label className="reporting-dialog-field"><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} autoFocus /></label>
|
||||||
<label className="reporting-dialog-field"><span>Interval</span><select value={interval} onChange={(event) => setIntervalValue(event.target.value)}><option value="3600">Hourly</option><option value="86400">Daily</option><option value="604800">Weekly</option><option value="2592000">Every 30 days</option></select></label>
|
<label className="reporting-dialog-field"><span>Interval</span><select value={interval} onChange={(event) => setIntervalValue(event.target.value)}><option value="3600">Hourly</option><option value="86400">Daily</option><option value="604800">Weekly</option><option value="2592000">Every 30 days</option></select></label>
|
||||||
</div>
|
</FormGrid>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-100
@@ -1,19 +1,10 @@
|
|||||||
.reporting-page,
|
.reporting-page {
|
||||||
.reporting-shell {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-shell {
|
.reporting-result-header {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-toolbar,
|
|
||||||
.reporting-result-header,
|
|
||||||
.reporting-output-toolbar {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -21,21 +12,8 @@
|
|||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-toolbar {
|
|
||||||
min-height: 56px;
|
|
||||||
padding: 9px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-search {
|
.reporting-search {
|
||||||
display: flex;
|
flex: 1 1 440px;
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: min(440px, 46vw);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-search input {
|
|
||||||
min-width: 140px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-count {
|
.reporting-count {
|
||||||
@@ -66,11 +44,10 @@
|
|||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-report-list,
|
|
||||||
.reporting-inspector-content section {
|
.reporting-inspector-content section {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-compact);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +61,6 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-report-row,
|
|
||||||
.reporting-inspector-content section > button {
|
.reporting-inspector-content section > button {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -97,45 +73,15 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-report-row {
|
|
||||||
grid-template-columns: 22px minmax(0, 1fr) auto;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 58px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-report-row:last-child,
|
|
||||||
.reporting-inspector-content section > button:last-child {
|
.reporting-inspector-content section > button:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-report-row:hover,
|
|
||||||
.reporting-report-row:focus-visible,
|
|
||||||
.reporting-report-row.is-selected,
|
|
||||||
.reporting-inspector-content section > button:hover,
|
.reporting-inspector-content section > button:hover,
|
||||||
.reporting-inspector-content section > button.is-selected {
|
.reporting-inspector-content section > button.is-selected {
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-report-row.is-selected {
|
|
||||||
box-shadow: inset 3px 0 0 var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-report-row > span:nth-child(2) {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-report-row strong,
|
|
||||||
.reporting-report-row small {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-report-row small,
|
|
||||||
.reporting-inspector-content small,
|
.reporting-inspector-content small,
|
||||||
.reporting-inspector-content p {
|
.reporting-inspector-content p {
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
@@ -211,7 +157,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 4px 8px 7px;
|
padding: 4px 8px 7px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-query-controls legend,
|
.reporting-query-controls legend,
|
||||||
@@ -251,11 +197,6 @@
|
|||||||
min-width: min(320px, 35vw);
|
min-width: min(320px, 35vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-output-toolbar {
|
|
||||||
min-height: 48px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-output-toolbar > span {
|
.reporting-output-toolbar > span {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
@@ -288,12 +229,6 @@
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-empty {
|
|
||||||
padding: 38px 12px;
|
|
||||||
color: var(--text-soft);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-inspector-content {
|
.reporting-inspector-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -402,7 +337,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 72%;
|
width: 72%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border-radius: 3px 3px 0 0;
|
border-radius: var(--radius-tight) var(--radius-tight) 0 0;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +346,7 @@
|
|||||||
height: 280px;
|
height: 280px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +382,7 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-compact);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +404,7 @@
|
|||||||
width: min(100%, 280px);
|
width: min(100%, 280px);
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-round);
|
||||||
box-shadow: inset 0 0 0 1px var(--border);
|
box-shadow: inset 0 0 0 1px var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,17 +430,17 @@
|
|||||||
.reporting-swatch {
|
.reporting-swatch {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 2px;
|
border-radius: var(--radius-hairline);
|
||||||
background: #2f7d6e;
|
background: var(--data-series-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-swatch-1 { background: #3366a8; }
|
.reporting-swatch-1 { background: var(--data-series-2); }
|
||||||
.reporting-swatch-2 { background: #c28b2c; }
|
.reporting-swatch-2 { background: var(--data-series-3); }
|
||||||
.reporting-swatch-3 { background: #9a4f71; }
|
.reporting-swatch-3 { background: var(--data-series-4); }
|
||||||
.reporting-swatch-4 { background: #5f7f3a; }
|
.reporting-swatch-4 { background: var(--data-series-5); }
|
||||||
.reporting-swatch-5 { background: #b85c3b; }
|
.reporting-swatch-5 { background: var(--data-series-6); }
|
||||||
.reporting-swatch-6 { background: #586176; }
|
.reporting-swatch-6 { background: var(--data-series-7); }
|
||||||
.reporting-swatch-7 { background: #2e8b9a; }
|
.reporting-swatch-7 { background: var(--data-series-8); }
|
||||||
|
|
||||||
.reporting-bar-row {
|
.reporting-bar-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -519,7 +454,7 @@
|
|||||||
height: 22px;
|
height: 22px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-subtle, var(--surface));
|
background: var(--surface-subtle, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +535,7 @@
|
|||||||
.reporting-drill-path span {
|
.reporting-drill-path span {
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-subtle, var(--surface));
|
background: var(--surface-subtle, var(--surface));
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
@@ -611,12 +546,6 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-dialog-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.reporting-workspace {
|
.reporting-workspace {
|
||||||
grid-template-columns: minmax(220px, 28%) minmax(0, 1fr);
|
grid-template-columns: minmax(220px, 28%) minmax(0, 1fr);
|
||||||
@@ -638,16 +567,8 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-result-header,
|
.reporting-result-header {
|
||||||
.reporting-toolbar {
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporting-search {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reporting-dialog-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user