Complete governed reporting execution and publication
This commit is contained in:
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user