Source code for ferrosoft.report

"""
PDF report generation via the WeasyPrint microservice.

This module provides a two-stage pipeline for producing reports:

1. **Template rendering** — a Django template is rendered to HTML with a
   standard context that includes the current UTC timestamp, a title, and an
   embeddability flag.
2. **PDF conversion** — the rendered HTML is POSTed to a running WeasyPrint
   HTTP microservice, which returns the PDF bytes.

The WeasyPrint service is configured via the ``WEASYPRINT_URL`` Django setting
and is typically started alongside the application in the development
environment (``make local``).  In production it is declared as a dependency in
``compose-production.yml``.

Callers can bypass PDF generation entirely by passing ``as_html=True`` (to get
rendered HTML bytes back) or ``as_text=True`` (to get the raw HTML string
before encoding), which is useful for preview modes or debugging.

Example::

    from ferrosoft.report import generate_report

    pdf_bytes = generate_report(
        "emiflow/report.html",
        context={"shipment": shipment},
        title="Emissions Report",
    )

    # HTML preview (embeddable in an iframe)
    html_bytes = generate_report(
        "emiflow/report.html",
        context={"shipment": shipment},
        title="Emissions Report",
        as_html=True,
    )
"""

import hashlib

import requests
from django.conf import settings
from django.template import loader, Template

from ferrosoft.apps.date_time import now_utc

#: Django setting key that holds the WeasyPrint service base URL.
_settings_key = "WEASYPRINT_URL"


[docs] class ReportError(RuntimeError): """Raised when the WeasyPrint service returns a non-200 HTTP response. Args: message: Human-readable description including the HTTP status code. """
[docs] def generate_report( template: str | Template, context: dict | None = None, title: str | None = None, as_html: bool | None = None, as_text: bool | None = None, ) -> bytes | str: """Render a Django template and optionally convert it to PDF. The template is rendered with a base context merged with any caller-supplied ``context``. The base context always includes: * ``timestamp`` — current UTC datetime from :func:`~ferrosoft.apps.date_time.now_utc`. * ``title`` — passed through from the ``title`` argument. * ``report_embeddable`` — set to the value of ``as_html`` so templates can adjust their output (e.g. omit ``<html>`` / ``<body>`` tags when embedded in an iframe). Args: template: Either a template name string (resolved via :func:`django.template.loader.get_template`) or an already-loaded :class:`~django.template.Template` instance. context: Additional template variables merged on top of the base context. Caller-supplied keys override the base context keys. title: Document title injected into the template as ``title``. as_html: If ``True``, return the rendered HTML encoded as ``bytes`` without sending it to WeasyPrint. The ``report_embeddable`` template variable is also set to ``True``. as_text: If ``True``, return the rendered HTML as a plain ``str`` without encoding or PDF conversion. Takes precedence over ``as_html`` for the encoding step. Returns: PDF bytes when neither ``as_html`` nor ``as_text`` is set. Encoded HTML bytes when ``as_html=True``. Raw HTML string when ``as_text=True``. """ if isinstance(template, str): template = loader.get_template(template) context = { "timestamp": now_utc(), "title": title, "report_embeddable": as_html, } | (context or {}) html = template.render(context) if not as_text: html = html.encode() if as_html: return html return generate_report_pdf(html)
[docs] def generate_report_pdf(html_content: bytes) -> bytes: """Convert an HTML document to PDF by calling the WeasyPrint microservice. The HTML is POSTed as ``text/html`` to the URL configured in ``settings.WEASYPRINT_URL``. A SHA-256 digest of the content is sent as the ``X-Trace-Id`` header so that WeasyPrint logs can be correlated with application logs. Args: html_content: The HTML document as UTF-8 encoded bytes. Returns: The PDF document as raw bytes returned by WeasyPrint. Raises: ValueError: If ``WEASYPRINT_URL`` is not present in Django settings. ReportError: If WeasyPrint returns any HTTP status other than 200. """ weasyprint_url = getattr(settings, _settings_key, None) if weasyprint_url is None: raise ValueError("%s is not defined" % _settings_key) h = hashlib.sha256() h.update(html_content) trace_id = h.hexdigest() response = requests.post( weasyprint_url, data=html_content, headers={ "Content-Type": "text/html", "X-Trace-Id": trace_id, }, ) if response.status_code != 200: raise ReportError( "Weasyprint request failed with code %d" % response.status_code ) return response.content