304 lines
11 KiB
Python
304 lines
11 KiB
Python
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",
|
|
]
|