M-Bus Gateway
← Tilbage til blog
· Jinja2· templates· HTML· email· PDF· Python· FastAPI· WeasyPrint· DRY

Jinja2 HTML-skabeloner til email og PDF — genbrug og DRY i Python

Jinja2 template-arkitektur til email og PDF-generering: template-arv, makroer, DRY-princippet, kontekst-variabler, custom filters og integration med FastAPI/WeasyPrint/Brevo.

Af M-Bus Gateway

M-Bus Gateway platformen bruger samme Jinja2-skabeloner til both email-preview og PDF-generering. Her er arkitekturen der undgår duplikation og sikrer konsistens.


Template-mappe-struktur

server/
  templates/
    base/
      email_base.html        ← Email layout (inline CSS, mobile-first)
      pdf_base.html          ← PDF layout (A4, @page CSS)
    
    settlement/
      settlement_email.html  ← extends email_base.html
      settlement_pdf.html    ← extends pdf_base.html
      _summary_block.html    ← Fælles blok (brugt i begge)
      _allocation_table.html ← Fælles blok
      _balance_section.html  ← Fælles blok
    
    email/
      welcome_tenant.html
      payment_failed.html
      onboarding_day1.html
      onboarding_day3.html
      onboarding_day7.html
      monthly_digest.html
      mid_expiry_warning.html
    
    css/
      email.css              ← Inline-compat CSS
      print.css              ← @page + A4 layout

Template-arv: email_base.html

<!-- templates/base/email_base.html -->
<!DOCTYPE html>
<html lang="da">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <style>
    /* Inline CSS for email-kompatibilitet */
    body { font-family: Arial, sans-serif; margin: 0; background: #f4f4f4; }
    .container { max-width: 600px; margin: 0 auto; background: #fff; }
    .header { background: #0f172a; color: #fff; padding: 20px; }
    .content { padding: 30px; }
    .footer { background: #f8fafc; padding: 20px; font-size: 12px; color: #666; }
    .btn { display: inline-block; padding: 12px 24px; background: #3b82f6;
           color: #fff; text-decoration: none; border-radius: 6px; }
  </style>
</head>
<body>
<div class="container">
  <div class="header">
    <img src="{{ logo_url }}" alt="M-Bus Gateway" height="40">
    <h1>{% block title %}{% endblock %}</h1>
  </div>
  <div class="content">
    {% block content %}{% endblock %}
  </div>
  <div class="footer">
    {% block footer %}
    <p>M-Bus Gateway ApS · Kontakt: <a href="mailto:support@mbus-gateway.dk">support@mbus-gateway.dk</a></p>
    <p><a href="{{ unsubscribe_url }}">Afmeld notifikationer</a> · 
       <a href="{{ privacy_url }}">Privatlivspolitik</a></p>
    {% endblock %}
  </div>
</div>
</body>
</html>

Delt blok: _balance_section.html

<!-- templates/settlement/_balance_section.html -->
{# Bruges identisk i email og PDF #}
<section class="balance-section">
  <table class="balance-table" style="width:100%; border-collapse:collapse;">
    <tr>
      <td>Din samlede andel:</td>
      <td style="text-align:right; font-family:monospace;">
        {{ total_share_kr | krformat }} kr.
      </td>
    </tr>
    <tr>
      <td>Indbetalt á conto ({{ a_conto_months }} mdr. × {{ a_conto_kr | krformat }} kr.):</td>
      <td style="text-align:right; font-family:monospace;">
        - {{ total_a_conto_kr | krformat }} kr.
      </td>
    </tr>
    <tr style="{% if balance_kr > 0 %}background:#fff3cd{% else %}background:#d4edda{% endif %};
               font-weight:bold;">
      <td>{{ "EFTERBETALING" if balance_kr > 0 else "TILBAGEBETALING" }}:</td>
      <td style="text-align:right; font-family:monospace;">
        {{ balance_kr | abs | krformat }} kr.
      </td>
    </tr>
  </table>
  {% if balance_kr > 0 %}
  <p>Betalingsfrist: <strong>{{ due_date | datoformat }}</strong></p>
  {% else %}
  <p>Tilbagebetaling sker inden: <strong>{{ refund_date | datoformat }}</strong></p>
  {% endif %}
</section>

Email-template der bruger delt blok

<!-- templates/settlement/settlement_email.html -->
{% extends "base/email_base.html" %}

{% block title %}Din varmeafregning {{ period_start.year }}/{{ period_end.year }}{% endblock %}

{% block content %}
<p>Kære {{ tenant_name }},</p>
<p>Her er din varmeafregning for perioden
   {{ period_start | datoformat }} – {{ period_end | datoformat }}.</p>

{% include "settlement/_balance_section.html" %}

<p style="margin-top:30px;">
  <a href="{{ view_url }}" class="btn">Se fuld afregning online</a>
</p>

<p style="font-size:13px; color:#666;">
  Du har ret til at gøre indsigelse mod denne afregning senest
  <strong>{{ dispute_deadline | datoformat }}</strong> (6 uger).
</p>
{% endblock %}

PDF-template der bruger samme delt blok

<!-- templates/settlement/settlement_pdf.html -->
{% extends "base/pdf_base.html" %}

{% block content %}
<div class="settlement-header">
  <!-- ... header med adresse etc. ... -->
</div>

{% include "settlement/_allocation_table.html" %}

{# SAMME blok som i email: #}
{% include "settlement/_balance_section.html" %}

<section class="dispute-section">
  <h3>Indsigelsesret</h3>
  <p>Indsigelse kan indgives inden {{ dispute_deadline | datoformat }}
     via: <a href="{{ dispute_url }}">{{ dispute_url }}</a></p>
</section>
{% endblock %}

Custom filters

# server/src/utils/template_filters.py
from datetime import date, datetime

def krformat(value: float) -> str:
    """1234.56 → "1.234,56" (dansk format)."""
    formatted = f"{abs(value):,.2f}"
    # Swap komma og punktum:
    return formatted.replace(",", "X").replace(".", ",").replace("X", ".")

def datoformat(value: date | datetime) -> str:
    """date → "24. maj 2026" (dansk)."""
    if isinstance(value, datetime):
        value = value.date()
    months = ["januar","februar","marts","april","maj","juni",
               "juli","august","september","oktober","november","december"]
    return f"{value.day}. {months[value.month-1]} {value.year}"

def pct(value: float, decimals: int = 1) -> str:
    """0.3 → "30,0%"."""
    return f"{value * 100:.{decimals}f}".replace(".", ",") + "%"

Jinja2 miljø-opsætning i FastAPI

# server/src/utils/templates.py
from jinja2 import Environment, FileSystemLoader, select_autoescape
from pathlib import Path

TEMPLATE_DIR = Path(__file__).parent.parent.parent / "templates"

env = Environment(
    loader=FileSystemLoader(TEMPLATE_DIR),
    autoescape=select_autoescape(["html"]),
    trim_blocks=True,
    lstrip_blocks=True,
)

# Registrér custom filters:
env.filters["krformat"] = krformat
env.filters["datoformat"] = datoformat
env.filters["pct"] = pct

# Global context-variabler (tilgængelige i alle templates):
env.globals["logo_url"] = "https://mbus-gateway.dk/logo.png"
env.globals["privacy_url"] = "https://mbus-gateway.dk/privatliv"
env.globals["support_email"] = "support@mbus-gateway.dk"


def render_template(template_name: str, **context) -> str:
    template = env.get_template(template_name)
    return template.render(**context)

Konklusion

Jinja2 template-arv og {% include %} giver DRY-HTML-skabeloner der bruges identisk til email-preview og WeasyPrint PDF-generering. Custom filters (krformat, datoformat) sikrer konsistent dansk formatering. Global context-variabler undgår at sende logo-URL og support-email i hvert template-kald.

Se WeasyPrint PDF guide eller Brevo email guide.