Complete governed reporting execution and publication

This commit is contained in:
2026-08-04 02:23:24 +02:00
parent a81151391b
commit 8337aec19a
29 changed files with 3611 additions and 81 deletions
@@ -0,0 +1,303 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
import csv
from html import escape
from io import StringIO
import json
import re
from govoplan_core.core.files import (
CAPABILITY_FILES_ARTIFACT_STORE,
ManagedArtifactStore,
ManagedArtifactWriteRequest,
)
from govoplan_core.core.mail import (
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
NotificationMailDeliveryProvider,
NotificationMailDeliveryRequest,
)
from govoplan_reporting.backend.contracts import (
CAPABILITY_REPORTING_PUBLICATION_FILES,
CAPABILITY_REPORTING_PUBLICATION_MAIL,
ReportingPublicationPayload,
capability,
)
class FilesReportingPublicationTarget:
def __init__(self, registry: object | None) -> None:
self.registry = registry
def publish_report(
self,
session: object,
principal: object,
*,
payload: ReportingPublicationPayload,
) -> Mapping[str, object]:
provider = capability(self.registry, CAPABILITY_FILES_ARTIFACT_STORE)
if not isinstance(provider, ManagedArtifactStore):
raise RuntimeError(
"Files publication requires the enabled files.artifact_store capability."
)
content, content_type, extension = _serialize(payload)
filename = _filename(payload, extension)
folder = str(payload.target_ref or "Generated/Reports").strip()
stored = provider.store_artifact(
session,
principal,
request=ManagedArtifactWriteRequest(
filename=filename,
payload=content,
content_type=content_type,
folder=folder,
description=(
f"Reporting publication for {payload.report_id} revision "
f"{payload.report_revision}."
),
idempotency_key=f"reporting:{payload.publication_id}",
metadata={
"producer_module": "reporting",
"publication_id": payload.publication_id,
"execution_id": payload.execution_id,
"report_id": payload.report_id,
"report_revision": payload.report_revision,
"output_hash": payload.output_hash,
},
),
)
return {
"provider": CAPABILITY_FILES_ARTIFACT_STORE,
"status": "stored",
"file_asset_id": stored.file_asset_id,
"file_version_id": stored.file_version_id,
"filename": stored.filename,
"display_path": stored.display_path,
"sha256": stored.sha256,
"size_bytes": stored.size_bytes,
"output_hash": payload.output_hash,
}
class MailReportingPublicationTarget:
def __init__(self, registry: object | None) -> None:
self.registry = registry
def publish_report(
self,
session: object,
principal: object,
*,
payload: ReportingPublicationPayload,
) -> Mapping[str, object]:
provider = capability(self.registry, CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
if not isinstance(provider, NotificationMailDeliveryProvider):
raise RuntimeError(
"Mail publication requires the enabled mail.notificationDelivery capability."
)
recipient = str(payload.target_ref or "").strip()
if not recipient:
raise ValueError("Mail publication requires a recipient address.")
options = dict(payload.options)
profile_id = _required_option(options, "mail_profile_id", "Mail profile")
from_address = _required_option(options, "from_address", "Sender address")
subject = str(
options.get("subject")
or f"Report {payload.report_id} revision {payload.report_revision}"
).strip()
action_url = str(options.get("action_url") or "").strip() or None
preview = _text_preview(payload.rows, payload.schema)
result = provider.submit_notification_mail(
session,
NotificationMailDeliveryRequest(
tenant_id=payload.tenant_id,
notification_id=f"reporting-publication:{payload.publication_id}",
recipient=recipient,
subject=subject,
body_text=(
f"Report: {payload.report_id}\n"
f"Revision: {payload.report_revision}\n"
f"Rows: {len(payload.rows)}\n"
f"Output hash: {payload.output_hash}\n\n"
f"{preview}"
),
action_url=action_url,
mail_profile_id=profile_id,
from_address=from_address,
smtp_server_id=_optional(options.get("smtp_server_id")),
smtp_credential_id=_optional(options.get("smtp_credential_id")),
metadata={
"producer_module": "reporting",
"publication_id": payload.publication_id,
"execution_id": payload.execution_id,
"report_id": payload.report_id,
"report_revision": payload.report_revision,
"output_hash": payload.output_hash,
},
),
)
status = str(result.get("status") or "").casefold()
if status not in {"accepted", "queued", "submitted", "succeeded"}:
raise RuntimeError(
str(result.get("error") or "Mail did not accept the report publication.")
)
return {
**dict(result),
"publication_id": payload.publication_id,
"recipient": recipient,
"output_hash": payload.output_hash,
}
def publication_target_catalog(registry: object | None) -> tuple[dict[str, object], ...]:
files_available = isinstance(
capability(registry, CAPABILITY_FILES_ARTIFACT_STORE), ManagedArtifactStore
)
mail_available = isinstance(
capability(registry, CAPABILITY_MAIL_NOTIFICATION_DELIVERY),
NotificationMailDeliveryProvider,
)
return (
{
"capability": CAPABILITY_REPORTING_PUBLICATION_FILES,
"label": "Files",
"available": files_available,
"reason": None
if files_available
else "Enable Files with managed artifact storage to publish durable report files.",
"formats": ["csv", "json", "html"],
"target_label": "Folder",
"target_required": False,
"required_options": [],
},
{
"capability": CAPABILITY_REPORTING_PUBLICATION_MAIL,
"label": "Mail",
"available": mail_available,
"reason": None
if mail_available
else "Enable Mail and configure its notification-delivery capability to publish report notices.",
"formats": ["html"],
"target_label": "Recipient",
"target_required": True,
"required_options": ["mail_profile_id", "from_address"],
},
)
def _serialize(payload: ReportingPublicationPayload) -> tuple[bytes, str, str]:
if payload.format == "json":
content = json.dumps(
{
"report_id": payload.report_id,
"report_revision": payload.report_revision,
"execution_id": payload.execution_id,
"output_hash": payload.output_hash,
"schema": list(payload.schema),
"rows": list(payload.rows),
},
ensure_ascii=False,
indent=2,
default=str,
).encode("utf-8")
return content, "application/json", "json"
if payload.format == "csv":
fields = _fields(payload.rows, payload.schema)
stream = StringIO(newline="")
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
for row in payload.rows:
writer.writerow({key: _safe_csv(row.get(key)) for key in fields})
return (
stream.getvalue().encode("utf-8-sig"),
"text/csv; charset=utf-8",
"csv",
)
if payload.format == "html":
fields = _fields(payload.rows, payload.schema)
headers = "".join(f"<th scope=\"col\">{escape(key)}</th>" for key in fields)
body = "".join(
"<tr>"
+ "".join(
f"<td>{escape(_display(row.get(key)))}</td>" for key in fields
)
+ "</tr>"
for row in payload.rows
)
content = (
"<!doctype html><html><head><meta charset=\"utf-8\"><title>"
+ escape(payload.report_id)
+ "</title></head><body><h1>"
+ escape(payload.report_id)
+ f"</h1><p>Revision {payload.report_revision}; output {escape(payload.output_hash)}</p>"
+ f"<table><thead><tr>{headers}</tr></thead><tbody>{body}</tbody></table>"
+ "</body></html>"
)
return content.encode("utf-8"), "text/html; charset=utf-8", "html"
raise ValueError(
"This publication target supports CSV, JSON, and accessible HTML. "
"XLSX and PDF require a renderer provider."
)
def _filename(payload: ReportingPublicationPayload, extension: str) -> str:
configured = str(payload.options.get("filename") or "").strip()
stem = configured.rsplit(".", 1)[0] if configured else payload.report_id
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip(".-") or "report"
return f"{safe}-r{payload.report_revision}.{extension}"
def _fields(
rows: Sequence[Mapping[str, object]], schema: Sequence[Mapping[str, object]]
) -> list[str]:
fields = [str(item.get("name")) for item in schema if item.get("name")]
if fields:
return fields
return list(dict.fromkeys(str(key) for row in rows for key in row))
def _safe_csv(value: object) -> object:
if isinstance(value, (dict, list, tuple)):
value = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if isinstance(value, str) and value.startswith(("=", "+", "-", "@")):
return "'" + value
return value
def _display(value: object) -> str:
if value is None:
return ""
if isinstance(value, (dict, list, tuple)):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
return str(value)
def _text_preview(
rows: Sequence[Mapping[str, object]], schema: Sequence[Mapping[str, object]]
) -> str:
fields = _fields(rows, schema)[:8]
lines = [" | ".join(fields)]
lines.extend(" | ".join(_display(row.get(key)) for key in fields) for row in rows[:10])
if len(rows) > 10:
lines.append(f"... {len(rows) - 10} more rows")
return "\n".join(lines)
def _required_option(options: Mapping[str, object], key: str, label: str) -> str:
value = str(options.get(key) or "").strip()
if not value:
raise ValueError(f"{label} is required for Mail publication.")
return value
def _optional(value: object) -> str | None:
clean = str(value or "").strip()
return clean or None
__all__ = [
"FilesReportingPublicationTarget",
"MailReportingPublicationTarget",
"publication_target_catalog",
]