PDF Zertifikate
This commit is contained in:
parent
4e278ef7df
commit
1778bd1bae
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
)
|
||||
@ -2226,3 +2233,173 @@ def reporting_assessments():
|
||||
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)
|
||||
281
app/flask-postgres/app/certificates.py
Normal file
281
app/flask-postgres/app/certificates.py
Normal 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
|
||||
@ -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>
|
||||
@ -2,4 +2,13 @@ Flask==3.0.2
|
||||
gunicorn==22.0.0
|
||||
psycopg2-binary==2.9.9
|
||||
Werkzeug==3.0.1
|
||||
|
||||
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
|
||||
@ -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>
|
||||
|
||||
231
app/flask-postgres/app/templates/certificate_template.html
Normal file
231
app/flask-postgres/app/templates/certificate_template.html
Normal 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>
|
||||
@ -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 %}
|
||||
51
app/flask-postgres/app/templates/certificates_overview.html
Normal file
51
app/flask-postgres/app/templates/certificates_overview.html
Normal 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 %}
|
||||
@ -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>
|
||||
|
||||
|
||||
BIN
app/flask-postgres/files/SchulungsTeilnahme.docx
Normal file
BIN
app/flask-postgres/files/SchulungsTeilnahme.docx
Normal file
Binary file not shown.
BIN
app/flask-postgres/files/SchulungsTeilnahme.pdf
Normal file
BIN
app/flask-postgres/files/SchulungsTeilnahme.pdf
Normal file
Binary file not shown.
@ -1296,3 +1296,12 @@ button {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user