feat: define governed report provider contract
This commit is contained in:
@@ -823,6 +823,10 @@ WebUI modules receive only the core route context:
|
|||||||
- `auth`
|
- `auth`
|
||||||
|
|
||||||
A module should call its own API client and module-owned backend routes. Shared API helpers should live in core only when they are truly platform-level concerns.
|
A module should call its own API client and module-owned backend routes. Shared API helpers should live in core only when they are truly platform-level concerns.
|
||||||
|
For ordinary JSON mutations, use Core's `apiPostJson` and `apiPatchJson`
|
||||||
|
helpers. They preserve the shared authentication, CSRF, error, and request
|
||||||
|
invalidation behavior while leaving endpoint types and feature semantics in the
|
||||||
|
owning module.
|
||||||
|
|
||||||
Modules can also contribute named UI capabilities for explicit extension
|
Modules can also contribute named UI capabilities for explicit extension
|
||||||
points. Capability values must be narrow, typed contracts, not imports from a
|
points. Capability values must be narrow, typed contracts, not imports from a
|
||||||
@@ -1005,6 +1009,16 @@ Decision: templates and reporting are separate modules.
|
|||||||
and export targets
|
and export targets
|
||||||
- report permissions, report execution history, generated report evidence, and
|
- report permissions, report execution history, generated report evidence, and
|
||||||
report-specific retention inputs
|
report-specific retention inputs
|
||||||
|
|
||||||
|
Cross-module reports use Core's versioned
|
||||||
|
`reporting.report_provider.<provider-id>` contract. Source modules own
|
||||||
|
authorization, parameters, source revisions, effective scope, result schema,
|
||||||
|
and privacy transforms; Reporting owns discovery, validation, governed
|
||||||
|
execution, provenance, export history, and the global `/reports` route. The
|
||||||
|
optional `policy.reporting_governance` capability can only tighten execution,
|
||||||
|
retention, export, and re-identification-risk handling. Reporting exposes
|
||||||
|
`reporting.retention` so the Policy-owned retention run can minimize expired
|
||||||
|
provider results without importing Reporting models.
|
||||||
- downstream export handoff to files, dataflow, connectors, or publication
|
- downstream export handoff to files, dataflow, connectors, or publication
|
||||||
surfaces
|
surfaces
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""Provider-neutral contracts for cross-module governed reports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
REPORT_PROVIDER_CAPABILITY_PREFIX = "reporting.report_provider."
|
||||||
|
CAPABILITY_POLICY_REPORTING_GOVERNANCE = "policy.reporting_governance"
|
||||||
|
CAPABILITY_REPORTING_RETENTION = "reporting.retention"
|
||||||
|
REPORT_PROVIDER_CONTRACT_VERSION = "1.0"
|
||||||
|
|
||||||
|
ReportParameterType = Literal[
|
||||||
|
"string",
|
||||||
|
"integer",
|
||||||
|
"number",
|
||||||
|
"boolean",
|
||||||
|
"date",
|
||||||
|
"datetime",
|
||||||
|
"reference",
|
||||||
|
]
|
||||||
|
ReportFieldType = Literal[
|
||||||
|
"string",
|
||||||
|
"integer",
|
||||||
|
"number",
|
||||||
|
"boolean",
|
||||||
|
"date",
|
||||||
|
"datetime",
|
||||||
|
"object",
|
||||||
|
"suppressed_count",
|
||||||
|
]
|
||||||
|
ReidentificationRisk = Literal["low", "moderate", "high"]
|
||||||
|
ReportGovernanceAction = Literal["catalogue", "execute", "export"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReportParameterOption:
|
||||||
|
value: str
|
||||||
|
label: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"value": self.value,
|
||||||
|
"label": self.label,
|
||||||
|
"description": self.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReportParameterDescriptor:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
type: ReportParameterType = "string"
|
||||||
|
required: bool = False
|
||||||
|
description: str | None = None
|
||||||
|
options_from_provider: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"key": self.key,
|
||||||
|
"label": self.label,
|
||||||
|
"type": self.type,
|
||||||
|
"required": self.required,
|
||||||
|
"description": self.description,
|
||||||
|
"options_from_provider": self.options_from_provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReportResultField:
|
||||||
|
path: str
|
||||||
|
label: str
|
||||||
|
type: ReportFieldType
|
||||||
|
group: str
|
||||||
|
nullable: bool = False
|
||||||
|
sensitive: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"path": self.path,
|
||||||
|
"label": self.label,
|
||||||
|
"type": self.type,
|
||||||
|
"group": self.group,
|
||||||
|
"nullable": self.nullable,
|
||||||
|
"sensitive": self.sensitive,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReportPrivacyTransform:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
required: bool = True
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {"id": self.id, "label": self.label, "required": self.required}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReportDescriptor:
|
||||||
|
provider_id: str
|
||||||
|
report_id: str
|
||||||
|
revision: str
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
parameters: tuple[ReportParameterDescriptor, ...]
|
||||||
|
result_schema: tuple[ReportResultField, ...]
|
||||||
|
privacy_transforms: tuple[ReportPrivacyTransform, ...]
|
||||||
|
purpose_required: bool = True
|
||||||
|
audience_scope_required: bool = True
|
||||||
|
retention_class: str = "report_result"
|
||||||
|
export_formats: tuple[str, ...] = ("json",)
|
||||||
|
reidentification_risk: ReidentificationRisk = "moderate"
|
||||||
|
presentation: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = REPORT_PROVIDER_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"contract_version": self.contract_version,
|
||||||
|
"provider_id": self.provider_id,
|
||||||
|
"report_id": self.report_id,
|
||||||
|
"revision": self.revision,
|
||||||
|
"title": self.title,
|
||||||
|
"summary": self.summary,
|
||||||
|
"parameters": [item.to_dict() for item in self.parameters],
|
||||||
|
"result_schema": [item.to_dict() for item in self.result_schema],
|
||||||
|
"privacy_transforms": [item.to_dict() for item in self.privacy_transforms],
|
||||||
|
"purpose_required": self.purpose_required,
|
||||||
|
"audience_scope_required": self.audience_scope_required,
|
||||||
|
"retention_class": self.retention_class,
|
||||||
|
"export_formats": list(self.export_formats),
|
||||||
|
"reidentification_risk": self.reidentification_risk,
|
||||||
|
"presentation": dict(self.presentation),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReportProviderRequest:
|
||||||
|
report_id: str
|
||||||
|
parameters: Mapping[str, object]
|
||||||
|
purpose: str
|
||||||
|
audience_scope: Mapping[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReportProviderResult:
|
||||||
|
report_id: str
|
||||||
|
generated_at: datetime
|
||||||
|
payload: Mapping[str, object]
|
||||||
|
source_revisions: tuple[Mapping[str, object], ...]
|
||||||
|
effective_scope: Mapping[str, object]
|
||||||
|
applied_privacy_transforms: tuple[str, ...]
|
||||||
|
provenance: Mapping[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ReportProvider(Protocol):
|
||||||
|
provider_id: str
|
||||||
|
contract_version: str
|
||||||
|
|
||||||
|
def list_reports(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
) -> tuple[ReportDescriptor, ...]: ...
|
||||||
|
|
||||||
|
def parameter_options(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
report_id: str,
|
||||||
|
parameter_key: str,
|
||||||
|
query: str,
|
||||||
|
limit: int,
|
||||||
|
) -> tuple[ReportParameterOption, ...]: ...
|
||||||
|
|
||||||
|
def execute_report(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ReportProviderRequest,
|
||||||
|
) -> ReportProviderResult: ...
|
||||||
|
|
||||||
|
def authorize_result(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
report_id: str,
|
||||||
|
source_revisions: tuple[Mapping[str, object], ...],
|
||||||
|
effective_scope: Mapping[str, object],
|
||||||
|
) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReportingGovernanceRequest:
|
||||||
|
action: ReportGovernanceAction
|
||||||
|
tenant_id: str
|
||||||
|
provider_id: str
|
||||||
|
report_id: str
|
||||||
|
purpose: str | None
|
||||||
|
audience_scope: Mapping[str, object]
|
||||||
|
retention_class: str
|
||||||
|
export_format: str | None
|
||||||
|
reidentification_risk: ReidentificationRisk
|
||||||
|
declared_privacy_transforms: tuple[str, ...]
|
||||||
|
applied_privacy_transforms: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReportingGovernanceDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str | None
|
||||||
|
retention_days: int | None
|
||||||
|
export_formats: tuple[str, ...]
|
||||||
|
required_privacy_transforms: tuple[str, ...]
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ReportingGovernanceProvider(Protocol):
|
||||||
|
def decide_reporting_action(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ReportingGovernanceRequest,
|
||||||
|
) -> ReportingGovernanceDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ReportingRetentionProvider(Protocol):
|
||||||
|
def apply_retention(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
now: datetime,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> Mapping[str, int]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def report_providers(registry: object | None) -> tuple[tuple[str, ReportProvider], ...]:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "capability_names")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
providers: list[tuple[str, ReportProvider]] = []
|
||||||
|
for capability_name in registry.capability_names():
|
||||||
|
if not capability_name.startswith(REPORT_PROVIDER_CAPABILITY_PREFIX):
|
||||||
|
continue
|
||||||
|
provider_id = capability_name.removeprefix(REPORT_PROVIDER_CAPABILITY_PREFIX)
|
||||||
|
capability = registry.capability(capability_name)
|
||||||
|
if not isinstance(capability, ReportProvider):
|
||||||
|
raise TypeError(f"Invalid report provider capability: {capability_name}")
|
||||||
|
if capability.provider_id != provider_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Report provider id {capability.provider_id!r} does not match "
|
||||||
|
f"capability {capability_name!r}"
|
||||||
|
)
|
||||||
|
if capability.contract_version != REPORT_PROVIDER_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported report provider contract: {capability.contract_version}"
|
||||||
|
)
|
||||||
|
providers.append((provider_id, capability))
|
||||||
|
return tuple(providers)
|
||||||
|
|
||||||
|
|
||||||
|
def reporting_governance_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> ReportingGovernanceProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_POLICY_REPORTING_GOVERNANCE)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLICY_REPORTING_GOVERNANCE)
|
||||||
|
if not isinstance(capability, ReportingGovernanceProvider):
|
||||||
|
raise TypeError("Invalid reporting governance provider capability")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_POLICY_REPORTING_GOVERNANCE",
|
||||||
|
"CAPABILITY_REPORTING_RETENTION",
|
||||||
|
"REPORT_PROVIDER_CAPABILITY_PREFIX",
|
||||||
|
"REPORT_PROVIDER_CONTRACT_VERSION",
|
||||||
|
"ReportDescriptor",
|
||||||
|
"ReportParameterDescriptor",
|
||||||
|
"ReportParameterOption",
|
||||||
|
"ReportPrivacyTransform",
|
||||||
|
"ReportProvider",
|
||||||
|
"ReportProviderRequest",
|
||||||
|
"ReportProviderResult",
|
||||||
|
"ReportResultField",
|
||||||
|
"ReportingGovernanceDecision",
|
||||||
|
"ReportingGovernanceProvider",
|
||||||
|
"ReportingGovernanceRequest",
|
||||||
|
"ReportingRetentionProvider",
|
||||||
|
"report_providers",
|
||||||
|
"reporting_governance_provider",
|
||||||
|
]
|
||||||
@@ -232,7 +232,15 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
self.assertTrue(manifests["campaigns"].required_capabilities)
|
self.assertTrue(manifests["campaigns"].required_capabilities)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
manifests["campaigns"].optional_dependencies,
|
manifests["campaigns"].optional_dependencies,
|
||||||
("files", "mail", "notifications", "addresses", "postbox", "approvals"),
|
(
|
||||||
|
"files",
|
||||||
|
"mail",
|
||||||
|
"notifications",
|
||||||
|
"addresses",
|
||||||
|
"postbox",
|
||||||
|
"approvals",
|
||||||
|
"reporting",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(manifests["dashboard"].dependencies, ())
|
self.assertEqual(manifests["dashboard"].dependencies, ())
|
||||||
self.assertTrue(manifests["dashboard"].required_capabilities)
|
self.assertTrue(manifests["dashboard"].required_capabilities)
|
||||||
@@ -3436,7 +3444,15 @@ finally:
|
|||||||
"version_max_exclusive": "0.3.0",
|
"version_max_exclusive": "0.3.0",
|
||||||
}, modules["campaigns"]["requires_interfaces"])
|
}, modules["campaigns"]["requires_interfaces"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["files", "mail", "notifications", "addresses", "postbox", "approvals"],
|
[
|
||||||
|
"files",
|
||||||
|
"mail",
|
||||||
|
"notifications",
|
||||||
|
"addresses",
|
||||||
|
"postbox",
|
||||||
|
"approvals",
|
||||||
|
"reporting",
|
||||||
|
],
|
||||||
modules["campaigns"]["optional_dependencies"],
|
modules["campaigns"]["optional_dependencies"],
|
||||||
)
|
)
|
||||||
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.core.reporting import (
|
||||||
|
REPORT_PROVIDER_CAPABILITY_PREFIX,
|
||||||
|
REPORT_PROVIDER_CONTRACT_VERSION,
|
||||||
|
ReportProviderRequest,
|
||||||
|
report_providers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
provider_id = "example"
|
||||||
|
contract_version = REPORT_PROVIDER_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def list_reports(self, session, principal):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def parameter_options(self, session, principal, **kwargs):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def execute_report(self, session, principal, *, request: ReportProviderRequest):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def authorize_result(self, session, principal, **kwargs):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_provider_discovery_is_prefix_based_and_optional() -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1.0",
|
||||||
|
capability_factories={
|
||||||
|
REPORT_PROVIDER_CAPABILITY_PREFIX + "example": lambda _context: (
|
||||||
|
_Provider()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
|
||||||
|
providers = report_providers(registry)
|
||||||
|
|
||||||
|
assert len(providers) == 1
|
||||||
|
assert providers[0][0] == "example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_provider_id_must_match_capability_name() -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1.0",
|
||||||
|
capability_factories={
|
||||||
|
REPORT_PROVIDER_CAPABILITY_PREFIX + "other": lambda _context: (
|
||||||
|
_Provider()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
report_providers(registry)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "does not match" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("mismatched provider id was accepted")
|
||||||
Reference in New Issue
Block a user