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"{escape(key)}" for key in fields) body = "".join( "" + "".join( f"{escape(_display(row.get(key)))}" for key in fields ) + "" for row in payload.rows ) content = ( "" + escape(payload.report_id) + "

" + escape(payload.report_id) + f"

Revision {payload.report_revision}; output {escape(payload.output_hash)}

" + f"{headers}{body}
" + "" ) 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", ]