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