38686-vm/core/utils.py
Konrad du Plessis 3c28387dd3 WIP: 2026-04-22 session checkpoint
Complete working state of the session. Will be split into two deploy
phases (safety scaffolding then feature release) before merging to ai-dev.

Includes:
- Security fixes (email creds / SECRET_KEY / DEBUG / CSRF)
- Backup + restore management commands and browser endpoints
- WeasyPrint migration (replaces xhtml2pdf)
- New Worker fields + WorkerCertificate + WorkerWarning models
- Worker / Team / Project friendly management UIs
- Dashboard cert-expiry card + Manage All buttons
- Bootstrap tooltips (global init + theme-aware CSS)
- Django admin template override (taller M2M pickers)
- Money filter for ZAR currency formatting
- Resources dropdown nav
- Massive CLAUDE.md expansion + deploy plan docs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:19:15 +02:00

133 lines
5.0 KiB
Python

# === PDF GENERATION ===
# Converts a Django HTML template into a PDF file using WeasyPrint.
# Used for payslip, receipt, and payroll report PDFs (both email and
# browser download).
#
# Why WeasyPrint?
# ----------------
# Migrated from xhtml2pdf in Nov 2026. WeasyPrint is a browser-grade
# HTML-to-PDF renderer — it supports modern CSS features that xhtml2pdf
# could not: flexbox, grid, @font-face (custom web fonts), box-shadow,
# border-radius, transforms, and proper CSS cascade handling.
#
# WeasyPrint has system dependencies (Pango, Cairo, GDK-PixBuf) that
# xhtml2pdf did not need. On Flatlogic's Linux environment these are
# already installed system-wide — no extra setup. On Windows dev
# machines, we install the GTK3 runtime (via `winget install
# tschoonj.GTKForWindows`) and then the `_ensure_gtk_on_windows()`
# helper below tells Python where to find the DLLs.
#
# Why the Windows DLL dance?
# --------------------------
# Since Python 3.8, `PATH` alone is no longer sufficient to load native
# DLLs — Python requires explicit `os.add_dll_directory()` calls for
# security reasons. This helper walks common GTK3 install locations
# and registers the first one found. On Linux it's a no-op.
#
# IMPORTANT: WeasyPrint is imported LAZILY (inside the function, not
# at the top of the file). This is intentional — if WeasyPrint or its
# system libraries are missing, the rest of the app still works;
# only PDF generation fails gracefully and returns None.
import logging
import os
import sys
from django.conf import settings
from django.template.loader import get_template
logger = logging.getLogger(__name__)
# Common install locations for the GTK3 runtime on Windows.
# Checked in order; first hit wins.
_WINDOWS_GTK_PATHS = (
r"C:\Program Files\GTK3-Runtime Win64\bin",
r"C:\Program Files (x86)\GTK3-Runtime Win64\bin",
)
# Module-level flag so we only add the DLL directory once per process.
_gtk_registered = False
def _ensure_gtk_on_windows():
"""Register the GTK3 runtime DLL directory on Windows.
WeasyPrint loads DLLs two ways:
1. `ctypes.util.find_library('gobject-2.0-0')` — reads os.environ['PATH']
2. `ctypes.CDLL(...)` — uses Windows DLL search, affected by
`os.add_dll_directory()` since Python 3.8
We hit both paths so the library is discoverable regardless of
which route WeasyPrint chooses. This is a no-op on Linux/macOS
and on Windows when GTK is already registered.
"""
global _gtk_registered
if _gtk_registered or sys.platform != "win32":
return
for path in _WINDOWS_GTK_PATHS:
if os.path.isdir(path):
# Prepend to PATH so find_library() locates the DLLs.
current_path = os.environ.get("PATH", "")
if path.lower() not in current_path.lower():
os.environ["PATH"] = path + os.pathsep + current_path
# Register via add_dll_directory for ctypes.CDLL() loads.
if hasattr(os, "add_dll_directory"):
os.add_dll_directory(path)
_gtk_registered = True
logger.debug("Registered GTK3 runtime directory: %s", path)
return
logger.debug(
"No GTK3 runtime directory found on Windows in %s", _WINDOWS_GTK_PATHS,
)
def render_to_pdf(template_src, context_dict=None):
"""
Render a Django template to PDF bytes using WeasyPrint.
Args:
template_src: Path to the template
(e.g. 'core/pdf/payslip_pdf.html')
context_dict: Template context variables
Returns:
PDF content as bytes, or None if there was an error.
"""
if context_dict is None:
context_dict = {}
# On Windows we need to tell Python where the GTK3 DLLs live
# BEFORE importing weasyprint. Harmless no-op on Linux/macOS.
_ensure_gtk_on_windows()
# --- Lazy import: only load WeasyPrint when actually generating a PDF ---
# ImportError covers the Python package being missing.
# OSError covers missing native libs (Pango, Cairo) at runtime.
try:
from weasyprint import HTML
except (ImportError, OSError) as e:
logger.error(
"WeasyPrint is not available — cannot generate PDF. "
"Install with: pip install weasyprint. "
"On Windows, also install the GTK3 runtime via "
"`winget install tschoonj.GTKForWindows`. "
"Underlying error: %s", e,
)
return None
# Render the Django template to HTML first
template = get_template(template_src)
html = template.render(context_dict)
# Convert HTML to PDF bytes.
# base_url lets WeasyPrint resolve relative paths to static files
# (e.g. fonts in static/fonts/, images in static/img/). We fall back
# to "." so the call still succeeds when STATIC_ROOT isn't set
# (e.g. during local dev before collectstatic has run).
base = getattr(settings, "STATIC_ROOT", None) or "."
try:
return HTML(string=html, base_url=base).write_pdf()
except Exception as e:
logger.exception("WeasyPrint render failed: %s", e)
return None