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"
Revision {payload.report_revision}; output {escape(payload.output_hash)}
" + f"