PDF Zertifikate

This commit is contained in:
Bkolb 2026-04-11 16:12:38 +02:00
parent 4e278ef7df
commit 1778bd1bae
13 changed files with 1067 additions and 11 deletions

View File

@ -2,6 +2,17 @@ FROM python:3.11-slim
WORKDIR /app
# 👉 SYSTEM LIBRARIES für WeasyPrint
RUN apt-get update && apt-get install -y \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libcairo2 \
libgdk-pixbuf-2.0-0 \
libffi-dev \
shared-mime-info \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

View File

@ -31,6 +31,9 @@ from security import (
user_admin_required,
contentmanager_required
)
from certificates import generate_certificate_pdf_for_user, get_user_module_completion
from logging_config import setup_logging
from datetime import datetime
@ -458,17 +461,13 @@ def profil():
u.email,
u.name,
u.mandant_id,
u.last_login,
u.status,
m.name AS mandant_name,
m.kuerzel AS mandant_kuerzel,
m.kontakt_email AS mandant_email,
m.level AS mandant_level
FROM app_user u
JOIN mandant m ON m.id = u.mandant_id
WHERE u.id = %s
""", (session["user_id"],))
profile = fetchone_dict(cur)
cur.execute("""
@ -479,19 +478,27 @@ def profil():
AND ug.mandant_id = %s
ORDER BY g.group_name
""", (session["user_id"], session["mandant_id"]))
gruppen_rows = fetchall_dict(cur)
gruppen = [row["group_name"] for row in gruppen_rows]
groups = [row["group_name"] for row in gruppen_rows]
cur.execute("""
SELECT guid, module_code, valid_from, valid_until, created_at
FROM certificate
WHERE user_id = %s
ORDER BY module_code
""", (session["user_id"],))
certificates = fetchall_dict(cur)
cur.close()
conn.close()
profile["mandant_level_label"] = format_level(profile["mandant_level"])
return render_template(
"profil.html",
page_title="Profil",
active_page="profil",
profile=profile,
groups=gruppen,
groups=groups,
certificates=certificates,
mandant_level_label=format_level(profile["mandant_level"]),
**get_current_user()
)
@ -2225,4 +2232,174 @@ def reporting_assessments():
dashboard_total_expected=total_expected,
dashboard_progress_percent=progress_percent,
**get_current_user()
)
)
@app.route("/verification/<guid>")
def certificate_verification(guid):
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT guid, user_name, mandant_name, module_code, valid_from, valid_until
FROM certificate
WHERE guid = %s
""", (guid,))
cert = fetchone_dict(cur)
cur.close()
conn.close()
# 👉 WICHTIG: kein abort mehr
if not cert:
return render_template(
"certificate_verification.html",
page_title="Zertifikat verifizieren",
certificate=None,
invalid_guid=True
)
return render_template(
"certificate_verification.html",
page_title="Zertifikat verifizieren",
certificate=cert,
invalid_guid=False
)
@app.route("/certificates")
@user_admin_required
def certificates_overview():
current_mandant_id = session.get("mandant_id")
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT name
FROM mandant
WHERE id = %s
""", (current_mandant_id,))
mandant_row = cur.fetchone()
mandant_name = mandant_row[0] if mandant_row else f"Mandant {current_mandant_id}"
cur.execute("""
SELECT id, name, email
FROM app_user
WHERE mandant_id = %s
ORDER BY name, email
""", (current_mandant_id,))
users = fetchall_dict(cur)
cur.execute("""
SELECT guid, user_id, module_code
FROM certificate
WHERE mandant_id = %s
""", (current_mandant_id,))
cert_rows = fetchall_dict(cur)
cur.close()
conn.close()
certificate_map = {}
for cert in cert_rows:
certificate_map[(cert["user_id"], cert["module_code"])] = cert["guid"]
result_users = []
for user in users:
completed_modules = []
for module_code in ("A", "B", "C"):
completion = get_user_module_completion(user["id"], module_code)
if completion:
existing_guid = certificate_map.get((user["id"], module_code))
completed_modules.append({
"code": module_code,
"guid": existing_guid,
"has_pdf": existing_guid is not None,
})
if completed_modules:
result_users.append({
"id": user["id"],
"name": user["name"],
"email": user["email"],
"modules": completed_modules,
})
return render_template(
"certificates_overview.html",
page_title="Zertifikate",
active_page="certificates",
mandant_name=mandant_name,
users=result_users,
**get_current_user()
)
@app.route("/certificates/generate/<int:user_id>/<module_code>", methods=["POST"])
@user_admin_required
def generate_certificate_for_user(user_id, module_code):
module_code = module_code.upper()
if module_code not in ("A", "B", "C"):
abort(404)
result = generate_certificate_pdf_for_user(user_id, module_code)
if not result:
abort(400)
return redirect(url_for("certificates_overview"))
@app.route("/certificates/download/<guid>")
@login_required
def download_certificate(guid):
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT guid, user_id
FROM certificate
WHERE guid = %s
""", (guid,))
cert = fetchone_dict(cur)
cur.close()
conn.close()
if not cert:
abort(404)
# User darf nur eigene Zertifikate laden
if cert["user_id"] != session.get("user_id"):
abort(403)
certificate_dir = "/app/files/certificates"
filename = f"{guid}.pdf"
return send_from_directory(certificate_dir, filename, as_attachment=True)
@app.route("/certificates/download/admin/<guid>")
@user_admin_required
def download_certificate_admin(guid):
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT guid, mandant_id
FROM certificate
WHERE guid = %s
""", (guid,))
cert = fetchone_dict(cur)
cur.close()
conn.close()
if not cert:
abort(404)
if cert["mandant_id"] != session.get("mandant_id"):
abort(403)
certificate_dir = "/app/files/certificates"
filename = f"{guid}.pdf"
return send_from_directory(certificate_dir, filename, as_attachment=True)

View File

@ -0,0 +1,281 @@
import os
import uuid
from datetime import timedelta
from flask import render_template
from weasyprint import HTML
import db
CERT_OUTPUT_DIR = "/app/files/certificates"
def get_module_courses(course_rows, module_code):
return [
c for c in course_rows
if (c["code"] or "").upper().startswith(module_code)
]
def get_user_module_completion(user_id, module_code):
conn = db.get_connection()
cur = conn.cursor()
cur.execute("""
SELECT u.id, u.name, u.mandant_id, m.name
FROM app_user u
JOIN mandant m ON m.id = u.mandant_id
WHERE u.id = %s
""", (user_id,))
user_row = cur.fetchone()
if not user_row:
cur.close()
conn.close()
return None
_, user_name, mandant_id, mandant_name = user_row
cur.execute("""
SELECT id, code, title
FROM course
WHERE is_active = TRUE
ORDER BY code
""")
all_courses = db.fetchall_dict(cur)
module_courses = get_module_courses(all_courses, module_code)
if not module_courses:
cur.close()
conn.close()
return None
course_ids = [c["id"] for c in module_courses]
cur.execute("""
SELECT course_id, MIN(created_at) AS first_passed_at, MAX(created_at) AS last_passed_at
FROM user_assessment
WHERE user_id = %s
AND passed = TRUE
AND course_id = ANY(%s)
GROUP BY course_id
""", (user_id, course_ids))
passed_rows = db.fetchall_dict(cur)
cur.close()
conn.close()
if len(passed_rows) != len(module_courses):
return None
first_pass_dates = [r["first_passed_at"] for r in passed_rows]
last_pass_dates = [r["last_passed_at"] for r in passed_rows]
valid_from = max(last_pass_dates)
valid_until = min(first_pass_dates) + timedelta(days=365)
return {
"user_id": user_id,
"user_name": user_name,
"mandant_id": mandant_id,
"mandant_name": mandant_name,
"module_code": module_code,
"valid_from": valid_from,
"valid_until": valid_until,
}
def generate_certificate_pdf_for_user(user_id, module_code):
data = get_user_module_completion(user_id, module_code)
if not data:
return None
conn = db.get_connection()
cur = conn.cursor()
cur.execute("""
SELECT guid
FROM certificate
WHERE user_id = %s
AND module_code = %s
""", (user_id, module_code))
existing = cur.fetchone()
if existing:
guid_value = str(existing[0])
cur.execute("""
UPDATE certificate
SET user_name = %s,
mandant_id = %s,
mandant_name = %s,
valid_from = %s,
valid_until = %s
WHERE guid = %s
""", (
data["user_name"],
data["mandant_id"],
data["mandant_name"],
data["valid_from"],
data["valid_until"],
guid_value
))
else:
guid_value = str(uuid.uuid4())
cur.execute("""
INSERT INTO certificate (
guid, user_id, mandant_id, user_name, mandant_name,
module_code, valid_from, valid_until
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
guid_value,
data["user_id"],
data["mandant_id"],
data["user_name"],
data["mandant_name"],
data["module_code"],
data["valid_from"],
data["valid_until"],
))
conn.commit()
cur.close()
conn.close()
os.makedirs(CERT_OUTPUT_DIR, exist_ok=True)
reference_url = f"https://cert.compliance-verification.info/verification/{guid_value}"
pdf_path = os.path.join(CERT_OUTPUT_DIR, f"{guid_value}.pdf")
html = render_template(
"certificate_template.html",
user_name=data["user_name"],
mandant_name=data["mandant_name"],
module_code=data["module_code"],
valid_from=data["valid_from"].strftime("%d.%m.%Y"),
valid_until=data["valid_until"].strftime("%d.%m.%Y"),
reference_url=reference_url,
reference_id=guid_value,
logo_path="file:///app/certificates/assets/logo.png",
signature_path="file:///app/certificates/assets/signature.png",
)
HTML(string=html, base_url="/").write_pdf(pdf_path)
return {
"guid": guid_value,
"pdf_path": pdf_path,
"reference_url": reference_url,
}
#########
def ensure_certificate_for_user_module(user_id, module_code):
conn = get_connection()
cur = conn.cursor()
# User + Mandant laden
cur.execute("""
SELECT u.id, u.name, u.mandant_id, m.name
FROM app_user u
JOIN mandant m ON m.id = u.mandant_id
WHERE u.id = %s
""", (user_id,))
row = cur.fetchone()
if not row:
cur.close()
conn.close()
return None
_, user_name, mandant_id, mandant_name = row
# Alle Kurse dieses Moduls
cur.execute("""
SELECT id, code, title
FROM course
WHERE is_active = TRUE
ORDER BY code
""")
all_courses = fetchall_dict(cur)
module_courses = get_module_courses(all_courses, module_code)
if not module_courses:
cur.close()
conn.close()
return None
course_ids = [c["id"] for c in module_courses]
# Bestandene Assessments des Users für diese Kurse
cur.execute("""
SELECT course_id, MIN(created_at) AS first_passed_at, MAX(created_at) AS last_passed_at
FROM user_assessment
WHERE user_id = %s
AND passed = TRUE
AND course_id = ANY(%s)
GROUP BY course_id
""", (user_id, course_ids))
passed_rows = fetchall_dict(cur)
if len(passed_rows) != len(module_courses):
cur.close()
conn.close()
return None
first_pass_dates = [r["first_passed_at"] for r in passed_rows]
last_pass_dates = [r["last_passed_at"] for r in passed_rows]
valid_from = max(last_pass_dates)
valid_until = min(first_pass_dates) + timedelta(days=365)
# Gibt es schon ein Zertifikat?
cur.execute("""
SELECT guid
FROM certificate
WHERE user_id = %s
AND module_code = %s
""", (user_id, module_code))
existing = cur.fetchone()
if existing:
guid_value = existing[0]
cur.execute("""
UPDATE certificate
SET user_name = %s,
mandant_id = %s,
mandant_name = %s,
valid_from = %s,
valid_until = %s
WHERE guid = %s
""", (
user_name,
mandant_id,
mandant_name,
valid_from,
valid_until,
guid_value
))
else:
guid_value = str(uuid.uuid4())
cur.execute("""
INSERT INTO certificate (
guid, user_id, mandant_id, user_name, mandant_name,
module_code, valid_from, valid_until
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
guid_value,
user_id,
mandant_id,
user_name,
mandant_name,
module_code,
valid_from,
valid_until
))
conn.commit()
cur.close()
conn.close()
return guid_value

View File

@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Nachweis über die erfolgreiche Teilnahme</title>
<style>
@page {
size: A4;
margin: 24mm 20mm 24mm 20mm;
}
body {
font-family: Arial, Helvetica, sans-serif;
color: #1f2937;
margin: 0;
padding: 0;
background: #ffffff;
font-size: 13pt;
line-height: 1.5;
}
.certificate {
min-height: 250mm;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.top-area {
text-align: center;
margin-bottom: 18mm;
}
.logo {
margin-bottom: 12mm;
}
.logo img {
max-height: 70px;
max-width: 220px;
}
.title {
font-size: 24pt;
font-weight: 700;
color: #0d2f57;
margin-bottom: 12mm;
}
.intro {
margin-bottom: 10mm;
text-align: center;
}
.user-name {
font-size: 22pt;
font-weight: 700;
color: #0d2f57;
text-align: center;
margin: 8mm 0 4mm 0;
}
.mandant-name {
font-size: 15pt;
text-align: center;
margin-bottom: 10mm;
}
.program-line {
text-align: center;
margin-bottom: 6mm;
}
.program-name {
font-size: 18pt;
font-weight: 700;
color: #0d2f57;
text-align: center;
margin-bottom: 12mm;
}
.content {
text-align: justify;
margin-top: 6mm;
}
.issuer {
margin-top: 14mm;
}
.dates {
display: flex;
justify-content: space-between;
margin-top: 16mm;
gap: 20mm;
}
.date-box {
width: 48%;
font-weight: 600;
}
.reference {
margin-top: 16mm;
text-align: left;
font-size: 11pt;
}
.reference a {
color: #0d2f57;
text-decoration: underline;
word-break: break-all;
}
.footer-note {
margin-top: 10mm;
font-size: 10.5pt;
color: #526172;
}
</style>
</head>
<body>
<div class="certificate">
<div>
<div class="top-area">
{% if logo_path %}
<div class="logo">
<img src="{{ logo_path }}" alt="Logo">
</div>
{% endif %}
<div class="title">Nachweis über die erfolgreiche Teilnahme</div>
<div class="intro">Hiermit wird bestätigt, dass</div>
<div class="user-name">{{ user_name }}</div>
<div class="mandant-name">des Unternehmens {{ mandant_name }}</div>
<div class="program-line">das Schulungsprogramm</div>
<div class="program-name">
„Compliance Verification Level {{ module_code }}“
</div>
</div>
<div class="content">
<p>
erfolgreich absolviert und alle vorgesehenen Prüfungen bestanden hat.
</p>
<p>
Im Rahmen der Schulung wurden strukturierte Kenntnisse im Bereich des Einsatzes
von Künstlicher Intelligenz in Organisationen vermittelt.
</p>
<p>
Dieser Nachweis dokumentiert ausschließlich die erfolgreiche Teilnahme an der
Schulung sowie das Bestehen der innerhalb der Plattform vorgesehenen Prüfungen.
</p>
<p>
Er stellt keine Prüfung, Bewertung oder Bestätigung der tatsächlichen Umsetzung
im Unternehmen dar.
</p>
<p>
Ebenso handelt es sich nicht um eine Bestätigung rechtlicher Konformität,
keine behördliche Anerkennung und keinen Nachweis im Sinne gesetzlicher
oder normativer Anforderungen.
</p>
<div class="issuer">
<strong>Ausgestellt durch:</strong><br>
ABC UG (haftungsbeschränkt)
</div>
<div class="dates">
<div class="date-box">
Datum: {{ valid_from }}
</div>
<div class="date-box" style="text-align:right;">
gültig bis {{ valid_until }}
</div>
</div>
<div class="reference">
<strong>Nachweis-ID:</strong>
<a href="{{ reference_url }}">{{ reference_url }}</a>
</div>
</div>
</div>
<div class="footer-note">
Dieses Zertifikat wurde automatisiert erzeugt und ist über die angegebene Referenz verifizierbar.
</div>
{% if signature_path %}
<div style="margin-top: 18mm;">
<img src="{{ signature_path }}" alt="Unterschrift" style="height: 65px;">
</div>
{% endif %}
</div>
</body>
</html>

View File

@ -2,4 +2,13 @@ Flask==3.0.2
gunicorn==22.0.0
psycopg2-binary==2.9.9
Werkzeug==3.0.1
pytest==8.3.2
weasyprint==60.2
pydyf==0.8.0
cairocffi==1.6.1
Pillow==10.4.0
qrcode==7.4.2
pytest==8.3.2
python-json-logger==2.0.7

View File

@ -43,6 +43,7 @@
{% if is_user_admin %}
<a href="/useradmin/mandant">Useradministration</a>
<a href="/reporting/assessments">Reporting</a>
<a href="/certificates">Zertifikate</a>
{% endif %}
{% if is_contentmanager %}
<a href="/dokumente">Dokumente</a>

View File

@ -0,0 +1,231 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Nachweis über die erfolgreiche Teilnahme</title>
<style>
@page {
size: A4;
margin: 20mm 18mm 18mm 18mm;
@bottom-center {
content: "Seite " counter(page) "/" counter(pages);
font-family: Arial, Helvetica, sans-serif;
font-size: 10pt;
color: #64748b;
}
}
body {
font-family: Arial, Helvetica, sans-serif;
color: #1f2937;
margin: 0;
padding: 0;
background: #ffffff;
font-size: 12.5pt;
line-height: 1.45;
}
.certificate {
min-height: 257mm;
position: relative;
}
.certificate-main {
padding-bottom: 24mm;
}
.top-area {
text-align: center;
margin-bottom: 12mm;
}
.logo {
margin-bottom: 10mm;
}
.logo img {
max-height: 70px;
max-width: 220px;
}
.title {
font-size: 24pt;
font-weight: 700;
color: #0d2f57;
margin-bottom: 10mm;
}
.intro {
margin-bottom: 8mm;
text-align: center;
}
.user-name {
font-size: 22pt;
font-weight: 700;
color: #0d2f57;
text-align: center;
margin: 8mm 0 4mm 0;
}
.mandant-name {
font-size: 15pt;
text-align: center;
margin-bottom: 8mm;
}
.program-line {
text-align: center;
margin-bottom: 5mm;
}
.program-name {
font-size: 18pt;
font-weight: 700;
color: #0d2f57;
text-align: center;
margin-bottom: 10mm;
}
.content {
text-align: justify;
margin-top: 4mm;
}
.content p {
margin: 0 0 5mm 0;
}
.issuer {
margin-top: 10mm;
}
.dates {
display: flex;
justify-content: space-between;
margin-top: 12mm;
gap: 16mm;
}
.date-box {
width: 48%;
font-weight: 600;
}
.signature-block {
margin-top: 10mm;
}
.signature-block img {
height: 55px;
max-width: 220px;
}
.reference {
margin-top: 10mm;
margin-bottom: 10mm;
text-align: left;
font-size: 11pt;
}
.reference a {
color: #0d2f57;
text-decoration: underline;
word-break: break-all;
}
.footer-note {
margin-top: 6mm;
font-size: 10.5pt;
color: #526172;
}
</style>
</head>
<body>
<div class="certificate">
<div class="certificate-main">
<div class="top-area">
{% if logo_path %}
<div class="logo">
<img src="{{ logo_path }}" alt="Logo">
</div>
{% endif %}
<div class="title">Nachweis über die erfolgreiche Teilnahme</div>
<div class="intro">Hiermit wird bestätigt, dass</div>
<div class="user-name">{{ user_name }}</div>
<div class="mandant-name">des Unternehmens {{ mandant_name }}</div>
<div class="program-line">das Schulungsprogramm</div>
<div class="program-name">
„Compliance Verification Level {{ module_code }}“
</div>
</div>
<div class="content">
<p>
erfolgreich absolviert und alle vorgesehenen Prüfungen bestanden hat.
</p>
<p>
Im Rahmen der Schulung wurden strukturierte Kenntnisse im Bereich des Einsatzes
von Künstlicher Intelligenz in Organisationen vermittelt.
</p>
<p>
Dieser Nachweis dokumentiert ausschließlich die erfolgreiche Teilnahme an der
Schulung sowie das Bestehen der innerhalb der Plattform vorgesehenen Prüfungen.
</p>
<p>
Er stellt keine Prüfung, Bewertung oder Bestätigung der tatsächlichen Umsetzung
im Unternehmen dar.
</p>
<p>
Ebenso handelt es sich nicht um eine Bestätigung rechtlicher Konformität,
keine behördliche Anerkennung und keinen Nachweis im Sinne gesetzlicher
oder normativer Anforderungen.
</p>
<div class="issuer">
<strong>Ausgestellt durch:</strong><br>
ABC UG (haftungsbeschränkt)
</div>
<div class="dates">
<div class="date-box">
Datum: {{ valid_from }}
</div>
<div class="date-box" style="text-align:right;">
gültig bis {{ valid_until }}
</div>
</div>
{% if signature_path %}
<div class="signature-block">
<img src="{{ signature_path }}" alt="Unterschrift">
</div>
{% endif %}
<div class="reference">
<strong>Nachweis-ID:</strong><br>
<a href="{{ reference_url }}">{{ reference_id }}</a>
</div>
<div class="footer-note">
Dieses Zertifikat wurde automatisiert erzeugt und ist über die angegebene Referenz verifizierbar.
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block content %}
{% if invalid_guid %}
<div class="error-box">
❌ Die angegebene ID kann nicht verifiziert werden.
</div>
{% else %}
<div class="page-header">
<h1>Zertifikat verifiziert</h1>
<p class="intro-text">Die folgenden Zertifikatsdaten wurden erfolgreich bestätigt.</p>
</div>
<section class="admin-section">
<div class="admin-panel profile-panel">
<div class="profile-grid">
<div class="profile-label">Referenz</div>
<div class="profile-value">{{ certificate.guid }}</div>
<div class="profile-label">User</div>
<div class="profile-value">{{ certificate.user_name }}</div>
<div class="profile-label">Mandant</div>
<div class="profile-value">{{ certificate.mandant_name }}</div>
<div class="profile-label">Modul</div>
<div class="profile-value">{{ certificate.module_code }}</div>
<div class="profile-label">Gültig ab</div>
<div class="profile-value">{{ certificate.valid_from.strftime("%d.%m.%Y") }}</div>
<div class="profile-label">Gültig bis</div>
<div class="profile-value">{{ certificate.valid_until.strftime("%d.%m.%Y") }}</div>
</div>
</div>
</section>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Zertifikate</h1>
<p class="intro-text">
Mandant <strong>({{ mandant_name }})</strong> Benutzer mit abgeschlossenem Modul.
</p>
</div>
<section class="admin-section">
<div class="admin-panel">
<div class="table-wrap">
<table class="mandanten-table">
<thead>
<tr>
<th>User</th>
<th>E-Mail</th>
<th>Abgeschlossene Module</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>
<div class="table-actions">
{% for module in user.modules %}
{% if module.has_pdf %}
<a href="/certificates/download/admin/{{ module.guid }}" class="btn-danger btn-small">
PDF - {{ module.code }}
</a>
{% else %}
<form method="post" action="/certificates/generate/{{ user.id }}/{{ module.code }}">
<button type="submit" class="btn-primary btn-small">{{ module.code }}</button>
</form>
{% endif %}
{% endfor %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}

View File

@ -42,6 +42,43 @@
<a href="/pwdchange" class="btn-primary">Passwort ändern</a>
</div>
<section class="admin-section">
<div class="admin-panel profile-panel">
<h2>Zertifikate</h2>
{% if certificates %}
<div class="table-wrap">
<table class="mandanten-table">
<thead>
<tr>
<th>Modul</th>
<th>Gültig ab</th>
<th>Gültig bis</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for cert in certificates %}
<tr>
<td>{{ cert.module_code }}</td>
<td>{{ cert.valid_from.strftime("%d.%m.%Y") }}</td>
<td>{{ cert.valid_until.strftime("%d.%m.%Y") }}</td>
<td>
<a href="/certificates/download/{{ cert.guid }}" class="btn-danger btn-small">
PDF - {{ cert.module_code }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>Noch keine Zertifikate vorhanden.</p>
{% endif %}
</div>
</section>
</div>
</section>

Binary file not shown.

Binary file not shown.

View File

@ -1295,4 +1295,13 @@ button {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.error-box {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fecaca;
padding: 16px;
border-radius: 10px;
font-weight: 600;
}