· 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.