Skip to content

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

Closed
payamnj wants to merge 2 commits into
masterfrom
feature/certificate-pdf-email
Closed

Generate a PDF certificate and email it to the learner on issuance#706
payamnj wants to merge 2 commits into
masterfrom
feature/certificate-pdf-email

Conversation

@payamnj

@payamnj payamnj commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Certificates were previously only viewable as an in-browser page after a learner submitted the "enter your name" form — no PDF existed, nothing was emailed. This adds a shared PDF-generation path used by both an automated email delivery job and an on-demand download endpoint.

  • PDF generationservices/certificate_pdf_service.py: generate_certificate_pdf(certificate) -> bytes, rendering a new certificates/certificate_pdf.html template via WeasyPrint (A4 landscape), with the organization logo and a verification QR code embedded as base64 data URIs (no network/storage dependency at render time). WeasyPrint is an optional dependency (pip install django-email-learning[certificates]), since it pulls in system libraries (Pango/Cairo/HarfBuzz) that not every installation needs.
  • Delivery — a background job (send_certificate_pdfs), deferred via a swappable TaskQueueProtocol[Certificate] queue, mirroring the existing newsletter sendout architecture exactly:
    • DatabaseCertificatePdfQueue (default) claims certificates with pdf_email_status=PENDING via select_for_update(skip_locked=True).
    • Resolved through the existing resolve_queue() helper, keyed by a new CERTIFICATE_PDF_QUEUE setting — swap in your own queue (Pub/Sub, SQS, etc.) by implementing the protocol and pointing the setting at it, exactly like SENDOUT_QUEUE.
    • Retries up to CERTIFICATES.MAX_RETRIES (default 3) on failure, then marks the certificate FAILED.
    • Management command (send_certificate_pdfs) and HTTP job-trigger endpoint (/api/jobs/send_certificate_pdfs/), matching every other job in this codebase.
  • On-demand downloadGET .../personalised/certificate/<certificate_number>/download/, reusing the same generate_certificate_pdf() function (regenerated fresh each request, not cached) so there's one source of truth for what a certificate PDF looks like, not two divergent rendering paths.
  • Certificate gains pdf_email_status / pdf_email_retry_count / pdf_email_sent_at to track delivery state, mirroring SendoutDelivery.

Upgrade safety

A data migration (0003_certificate_pdf_email_status) marks all pre-existing certificates as already-sent at migration time, so upgrading does not retroactively email a PDF for every certificate ever issued — only certificates issued after the upgrade get queued for a PDF email. New certificates default to PENDING via the model field default, so the trigger is implicit: Certificate.objects.get_or_create(...) in SubmitCertificateFormView.post already sets it correctly, no extra code needed there.

Design notes

  • WeasyPrint can't actually render without system libraries (Pango etc.) installed, which this sandbox doesn't have — confirmed the package installs and imports correctly, but generate_certificate_pdf's actual HTML(...).write_pdf() call is isolated into a small _render_html_to_pdf() function so it can be mocked in tests without requiring those system libraries in CI. Real end-to-end PDF rendering should be verified in an environment with WeasyPrint's system dependencies installed.
  • Refactored the certificate-number parsing/lookup out of CertificateView.get() into a shared _lookup_certificate() helper, reused by the new download view.

Closes #705

Test plan

  • pytest -q — 796 passed, 81.45% coverage
  • mypy — no issues across 168 source files
  • ruff check / ruff format — clean
  • python manage.py makemigrations --check — no drift
  • bandit — no new findings (2 pre-existing low-severity findings, unrelated to this change)
  • New/updated tests cover: Certificate PDF-status model defaults, generate_certificate_pdf(), DatabaseCertificatePdfQueue claiming/batching, the background job's success/retry/permanent-failure paths, the job-trigger HTTP endpoint and management command, and the download view (success, not-found, invalid format)

🤖 Generated with Claude Code

payamnj and others added 2 commits July 9, 2026 16:00
Certificates were previously only viewable as an in-browser page after a
learner submitted the "enter your name" form. Add a PDF generation path
(WeasyPrint, optional "certificates" extra) that's shared between:

- A background job (send_certificate_pdfs) that emails the PDF, deferred
  via a swappable TaskQueueProtocol[Certificate] queue (CERTIFICATE_PDF_QUEUE
  setting), mirroring the existing newsletter sendout queue architecture.
- An on-demand download endpoint at
  personalised/certificate/<certificate_number>/download/.

Certificate gains pdf_email_status/pdf_email_retry_count/pdf_email_sent_at
to track delivery, with retries up to CERTIFICATES.MAX_RETRIES (default 3).
A data migration marks all pre-existing certificates as already-sent so
upgrading doesn't retroactively email a PDF for every certificate ever
issued — only certificates issued after the upgrade are queued.

Closes #705

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@payamnj

payamnj commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Closing for now — pausing this ticket to reconsider the PDF-generation approach.

Why: WeasyPrint requires native system libraries (Pango/Cairo/GObject/HarfBuzz) that pip does not install. Running the background job locally surfaced this directly:

```
OSError: cannot load library 'libgobject-2.0-0': ...
```

For a `pip install`-able library, requiring Homebrew/apt system packages just to render a certificate is too much friction, and we also want the PDF to look exactly like the certificate the learner actually sees (`Certificate.jsx`, rendered client-side with CSS gradients/blend-modes/flexbox) — which no server-side redraw (WeasyPrint's simplified HTML/CSS template, or a from-scratch reportlab/xhtml2pdf recreation) can achieve without duplicating that design and accepting a visual gap.

Full decision history and the direction being considered next are written up on #705. The branch (`feature/certificate-pdf-email`) is left in place, unmerged, for reference if we return to the WeasyPrint approach later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant