Skip to content

Generate a PDF certificate and email it to the learner on issuance #705

Description

@payamnj

Summary

When a learner is issued a certificate (course has send_certificate enabled), generate a PDF version of the certificate — matching exactly what the learner sees on the in-browser certificate page — and email it to them. Today the certificate only exists as an in-browser view (Certificate.jsx, rendered client-side) the learner has to navigate to after submitting their name; there's no PDF, and nothing is emailed.

Status: PAUSED. See "Decision history" below for what's been tried and ruled out, and "Current direction" for the leading plan once work resumes. Not currently being worked on.

Current flow

  • Enrollment.graduate() (django_email_learning/models/enrollments.py:171-184) marks the enrollment COMPLETED and, if course.send_certificate is True (django_email_learning/models/courses.py:44), calls send_certificate_form() on commit.
  • send_certificate_form() (enrollments.py:186-218) emails a JWT-signed link to the "enter your name" form, using emails/certificate_form.html/.txt.
  • CertificateFormView (personalised/views.py:305-335) renders that form (templates/personalised/certificate_form.html).
  • SubmitCertificateFormView.post (personalised/api/views.py:524-566) validates the JWT, does Certificate.objects.get_or_create(...) (Certificate model at enrollments.py:266-292), and returns a URL to CertificateView.
  • CertificateView.get (personalised/views.py:418-478) looks up the Certificate, generates a QR code, and renders templates/personalised/certificate.html — which is just a shell that loads the actual certificate visuals client-side via the React component frontend/personalised/certificate/Certificate.jsx (gradients, blend modes, decorative border, all CSS-in-JS via MUI sx props). There is no server-rendered certificate HTML today.

Decision history

Attempt 1 — WeasyPrint, server-side (PR #706, closed without merging):

  • Built a full implementation: generate_certificate_pdf() (WeasyPrint, rendering a new simplified HTML/CSS template), a background job (send_certificate_pdfs) delivering via a swappable TaskQueueProtocol[Certificate] queue (mirroring the existing newsletter sendout architecture, CERTIFICATE_PDF_QUEUE setting), an on-demand download endpoint reusing the same generation function, Certificate PDF-status tracking fields, and a migration that marks pre-existing certificates as already-handled so upgrading doesn't retroactively email everyone.
  • Rejected for two reasons:
    1. WeasyPrint depends on native system libraries (Pango, Cairo, GObject, HarfBuzz) that pip does not install. Confirmed directly — running the job locally without Homebrew's pango installed threw OSError: cannot load library 'libgobject-2.0-0'. Requiring a system package manager install just to render a certificate is too much friction for a pip install-able library.
    2. The template was a simplified approximation of Certificate.jsx, not a faithful recreation — WeasyPrint's CSS support could get closer with more effort, but it can never reuse the actual React/MUI design, only re-implement an approximation of it server-side.

Attempt 2 (considered, not implemented) — reportlab or xhtml2pdf instead of WeasyPrint:

  • reportlab: pure Python, zero system dependencies, and its only real dependency (Pillow) is already used by this project — the lightest option by far. But (at the time) it meant a low-level drawing canvas (explicit x/y coordinates, no CSS), so replicating Certificate.jsx's gradients/blend-modes/flexbox layout was significantly more manual work than CSS. Revisited below once the design changed.
  • xhtml2pdf: pure Python, HTML/CSS-ish, confirmed it renders without any system library. But pip show xhtml2pdf reveals it pulls in 13 packages, including pyHanko (a full PDF digital-signing library with its own cryptography/requests/asn1crypto dependencies) that has nothing to do with generating a certificate — heavy for what we need, and its CSS support (no flexbox/grid) is weaker than WeasyPrint's.
  • Rejected: neither approach solved the actual requirement at the time — the PDF had to look exactly like what the learner sees, and no server-side redraw of the live gradient/blend-mode CSS design could reuse the React component.

Attempt 3 (considered, not implemented) — CDN-loaded client-side html2canvas/jsPDF:

  • Idea: generate the PDF client-side from the actual rendered Certificate.jsx output for pixel-perfect fidelity, loading the conversion library from a CDN <script> tag rather than bundling it into the package's own static assets (which — per the Makefile's cp -r dist/assets django_email_learning/static step and package-data config — get embedded directly into the published wheel).
  • Superseded by the SVG-background direction below before being fully designed, but the core problem it exposed remains valid context: a CDN script trades a pip dependency for supply-chain/CSP/availability/privacy risk, and — more fundamentally — client-side generation doesn't solve automated emailing, since a background job has no browser to run it in.

Current direction: static SVG background, shared by both Certificate.jsx and the PDF

The core insight: the actual visual complexity in Certificate.jsx — gradient bands, radial glow, background-blend-mode compositing — is a decorative backdrop, not the dynamic content. It doesn't need to be live CSS at all. Bake it once into a static SVG asset, and reference it identically from both Certificate.jsx (background-image: url(...)) and the PDF template. Since both sides render the same image instead of two different engines approximating the same design, the "must look exactly like the JSX" and "avoid a heavy/fragile PDF dependency" requirements stop being in tension — they were never actually solvable together as long as the background was live CSS.

  • SVG, not PNG: avoids the print-resolution/DPI problem a raster background would have (the current design is sized in print units, 277mm × 190mm A4 landscape) and is typically smaller for gradient-and-shapes graphics.
  • PDF engine: PlutoPrint (Python bindings for the PlutoBook rendering engine — a custom-built engine, not WebKit/Chromium/Gecko-based). Chosen over WeasyPrint/xhtml2pdf because:
    • Zero system dependencies — prebuilt binaries are bundled directly into the wheel for macOS/Linux/Windows. Confirmed empirically: pip install plutoprint and rendering worked immediately in this sandbox, no Homebrew/apt step, unlike WeasyPrint which failed with OSError: cannot load library 'libgobject-2.0-0' under the exact same conditions. Wheel is ~19MB (bundled engine), heavier than WeasyPrint's own wheel but self-contained — no external system-library requirement at all.
    • Actively maintained: the plutoprint package itself has 1,155 GitHub stars (not to be confused with the underlying plutobook engine repo, which has 334 — different repos), both created ~May 2024, both pushed to within the last day or two as of this writing.
    • CSS gaps confirmed empirically (rendered test pages, converted to PNG, visually inspected): flexbox, border-radius, borders, solid background-color, and SVG-as-background-image all render correctly. linear-gradient()/radial-gradient() and box-shadow are silently ignored — not rendered at all. This is exactly why the design needs to move to a static SVG background rather than relying on PlutoBook to render gradients live.
    • With the SVG-background approach, PlutoPrint needs no CSS feature it doesn't already support: the background is one static image, and the dynamic overlay (name, course title, issue date, certificate number, QR code, organization logo) only needs flexbox/positioning + text + images, all confirmed working.
  • reportlab is a live alternative worth naming explicitly: with the gradient problem removed, its original weakness (no CSS, so gradients would have to be hand-drawn) is moot — the remaining task (draw one background image, overlay a few text fields and a QR code image at fixed positions) is squarely reportlab's use case, and it needs zero new dependencies beyond Pillow (already a dependency of this project), smaller footprint than PlutoPrint's ~19MB wheel. Current lean is still PlutoPrint, because HTML/CSS templates are more maintainable than hand-coded x/y draw calls and match how every other template in this codebase (emails, forms, etc.) is already written — but this is a real tradeoff (footprint vs. authoring consistency), not a settled one.

Open question worth resolving before implementation: does Certificate.jsx need to exist at all once a PDF is generated?

Instead of maintaining two rendering paths (the React/JSX in-browser view and the PDF template) kept in sync only because they happen to reference the same SVG background, the certificate page could simply display the generated PDF directly (e.g. embed it, or redirect to the download/view endpoint) instead of rendering a parallel JSX UI. That would leave exactly one definition of what a certificate looks like — the PDF template — eliminating any possibility of the two drifting apart in the future, and simplifying the frontend (removing or drastically shrinking Certificate.jsx). Trade-offs to weigh:

  • A PDF embedded in-browser (<iframe>/<embed>, or direct navigation to the PDF) is a different UX than a responsive HTML page — worth checking how well that behaves on mobile browsers, and whether an interactive "your certificate" page has value beyond just the printable artifact.
  • Still needs the PDF to be generated on-demand quickly enough for a page load (not just for background-job/download purposes) — confirm PlutoPrint's generation speed is acceptable for this synchronous path.

Still open regardless of the above:

  • Theme/brand-color awareness: the current gradient is computed live from theme.palette.primary.main/secondary.main, adapting to light/dark mode and (per this being a customizable library) presumably to a consuming project's brand colors. A single static SVG bakes in one fixed color scheme. Decide: acceptable fixed look regardless of installation theme, or generate a small set of pre-baked variants (e.g. light/dark)?
  • Delivery architecture (background job + swappable TaskQueueProtocol[Certificate] queue, CERTIFICATE_PDF_QUEUE setting, retry/status tracking on Certificate) designed in the closed WeasyPrint PR is still architecturally sound and not dependent on which PDF engine is used — likely reusable as-is once the rendering approach is finalized, unless the "just show the PDF, no separate JSX" direction above changes when/why generation happens.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions