- Certificate model: serial_number (unique), status (pending/rendering/completed/failed),
pdf_path, pdf_hash (SHA-256), verification_url, render_attempts, training_record FK
- renderer.py: LaTeX template → pdflatex subprocess → PDF; _escape_latex for XSS safety;
compute_pdf_hash for immutable verification metadata
- services.py: issue_certificate() generates serial, creates record, enqueues Celery task
- tasks.py: render_certificate_task (bind=True, autoretry 3x with 60s backoff);
sets RENDERING → COMPLETED with hash; FAILED with error on all retries exhausted
- API: /certificates/ (own list), /certificates/{id}/ (own detail)
- 15 unit + integration tests: hash consistency, LaTeX escaping, mocked render, retry behavior
Co-Authored-By: Paperclip <noreply@paperclip.ing>
109 lines
3.1 KiB
Python
109 lines
3.1 KiB
Python
"""
|
|
LaTeX certificate renderer.
|
|
|
|
Takes a certificate context dict and renders it to a PDF file via pdflatex.
|
|
Returns the path to the rendered PDF.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from string import Template
|
|
|
|
|
|
LATEX_TEMPLATE = r"""
|
|
\documentclass[12pt,a4paper]{article}
|
|
\usepackage[margin=2cm]{geometry}
|
|
\usepackage{fontenc}
|
|
\usepackage[utf8]{inputenc}
|
|
\usepackage{xcolor}
|
|
\usepackage{graphicx}
|
|
\pagestyle{empty}
|
|
\begin{document}
|
|
\begin{center}
|
|
{\Huge\bfseries Certificate of Completion}\\[1.5cm]
|
|
{\Large This certifies that}\\[0.8cm]
|
|
{\LARGE\bfseries $recipient_name}\\[0.8cm]
|
|
{\large has successfully completed}\\[0.5cm]
|
|
{\Large\bfseries $course_title}\\[0.5cm]
|
|
{\normalsize on $issued_date}\\[1.5cm]
|
|
{\small Serial: $serial_number}\\[0.3cm]
|
|
{\small Verify at: $verification_url}
|
|
\end{center}
|
|
\end{document}
|
|
"""
|
|
|
|
|
|
class RenderError(Exception):
|
|
pass
|
|
|
|
|
|
def _escape_latex(value: str) -> str:
|
|
"""Escape special LaTeX characters in user-supplied strings."""
|
|
special = {
|
|
"&": r"\&",
|
|
"%": r"\%",
|
|
"$": r"\$",
|
|
"#": r"\#",
|
|
"_": r"\_",
|
|
"{": r"\{",
|
|
"}": r"\}",
|
|
"~": r"\textasciitilde{}",
|
|
"^": r"\^{}",
|
|
"\\": r"\textbackslash{}",
|
|
}
|
|
return "".join(special.get(c, c) for c in value)
|
|
|
|
|
|
def render_certificate_pdf(context: dict, output_path: str) -> str:
|
|
"""
|
|
Render a certificate PDF from the given context dict.
|
|
Saves to output_path (must be a .pdf path).
|
|
Returns output_path on success.
|
|
Raises RenderError on failure.
|
|
"""
|
|
safe = {k: _escape_latex(str(v)) for k, v in context.items()}
|
|
latex_source = Template(LATEX_TEMPLATE).safe_substitute(safe)
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tex_file = os.path.join(tmpdir, "certificate.tex")
|
|
with open(tex_file, "w", encoding="utf-8") as f:
|
|
f.write(latex_source)
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["pdflatex", "-interaction=nonstopmode", "-output-directory", tmpdir, tex_file],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
except FileNotFoundError:
|
|
raise RenderError("pdflatex not found — ensure TeX Live is installed.")
|
|
except subprocess.TimeoutExpired:
|
|
raise RenderError("pdflatex timed out after 30 seconds.")
|
|
|
|
if result.returncode != 0:
|
|
raise RenderError(f"pdflatex failed:\n{result.stdout[-2000:]}")
|
|
|
|
rendered_pdf = os.path.join(tmpdir, "certificate.pdf")
|
|
if not os.path.exists(rendered_pdf):
|
|
raise RenderError("pdflatex ran successfully but no PDF was produced.")
|
|
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
shutil.copy2(rendered_pdf, output_path)
|
|
|
|
return output_path
|
|
|
|
|
|
def compute_pdf_hash(pdf_path: str) -> str:
|
|
"""Compute SHA-256 hex digest of a PDF file."""
|
|
sha = hashlib.sha256()
|
|
with open(pdf_path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(65536), b""):
|
|
sha.update(chunk)
|
|
return sha.hexdigest()
|