diff --git a/app/flask-postgres/app/Dockerfile b/app/flask-postgres/app/Dockerfile index 97a0d9b..e7c33e0 100644 --- a/app/flask-postgres/app/Dockerfile +++ b/app/flask-postgres/app/Dockerfile @@ -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 diff --git a/app/flask-postgres/app/app.py b/app/flask-postgres/app/app.py index a34057e..52e86e5 100644 --- a/app/flask-postgres/app/app.py +++ b/app/flask-postgres/app/app.py @@ -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() - ) \ No newline at end of file + ) + +@app.route("/verification/") +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//", 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/") +@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/") +@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) \ No newline at end of file diff --git a/app/flask-postgres/app/certificates.py b/app/flask-postgres/app/certificates.py new file mode 100644 index 0000000..9e0608c --- /dev/null +++ b/app/flask-postgres/app/certificates.py @@ -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 \ No newline at end of file diff --git a/app/flask-postgres/app/certificates/templates/certificate_template.html b/app/flask-postgres/app/certificates/templates/certificate_template.html new file mode 100644 index 0000000..d1b3a2b --- /dev/null +++ b/app/flask-postgres/app/certificates/templates/certificate_template.html @@ -0,0 +1,205 @@ + + + + + Nachweis über die erfolgreiche Teilnahme + + + +
+ +
+
+ {% if logo_path %} + + {% endif %} + +
Nachweis über die erfolgreiche Teilnahme
+ +
Hiermit wird bestätigt, dass
+ +
{{ user_name }}
+ +
des Unternehmens {{ mandant_name }}
+ +
das Schulungsprogramm
+ +
+ „Compliance Verification – Level {{ module_code }}“ +
+
+ +
+

+ erfolgreich absolviert und alle vorgesehenen Prüfungen bestanden hat. +

+ +

+ Im Rahmen der Schulung wurden strukturierte Kenntnisse im Bereich des Einsatzes + von Künstlicher Intelligenz in Organisationen vermittelt. +

+ +

+ Dieser Nachweis dokumentiert ausschließlich die erfolgreiche Teilnahme an der + Schulung sowie das Bestehen der innerhalb der Plattform vorgesehenen Prüfungen. +

+ +

+ Er stellt keine Prüfung, Bewertung oder Bestätigung der tatsächlichen Umsetzung + im Unternehmen dar. +

+ +

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

+ +
+ Ausgestellt durch:
+ ABC UG (haftungsbeschränkt) +
+ +
+
+ Datum: {{ valid_from }} +
+
+ gültig bis {{ valid_until }} +
+
+ +
+ Nachweis-ID: + {{ reference_url }} +
+
+
+ + +{% if signature_path %} +
+ Unterschrift +
+{% endif %} +
+ + \ No newline at end of file diff --git a/app/flask-postgres/app/requirements.txt b/app/flask-postgres/app/requirements.txt index 1374b7d..4864e2d 100644 --- a/app/flask-postgres/app/requirements.txt +++ b/app/flask-postgres/app/requirements.txt @@ -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 \ No newline at end of file + +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 \ No newline at end of file diff --git a/app/flask-postgres/app/templates/base.html b/app/flask-postgres/app/templates/base.html index e6201e5..8fde935 100644 --- a/app/flask-postgres/app/templates/base.html +++ b/app/flask-postgres/app/templates/base.html @@ -43,6 +43,7 @@ {% if is_user_admin %} Useradministration Reporting + Zertifikate {% endif %} {% if is_contentmanager %} Dokumente diff --git a/app/flask-postgres/app/templates/certificate_template.html b/app/flask-postgres/app/templates/certificate_template.html new file mode 100644 index 0000000..768529e --- /dev/null +++ b/app/flask-postgres/app/templates/certificate_template.html @@ -0,0 +1,231 @@ + + + + + Nachweis über die erfolgreiche Teilnahme + + + + +
+
+ +
+ {% if logo_path %} + + {% endif %} + +
Nachweis über die erfolgreiche Teilnahme
+ +
Hiermit wird bestätigt, dass
+ +
{{ user_name }}
+ +
des Unternehmens {{ mandant_name }}
+ +
das Schulungsprogramm
+ +
+ „Compliance Verification – Level {{ module_code }}“ +
+
+ +
+

+ erfolgreich absolviert und alle vorgesehenen Prüfungen bestanden hat. +

+ +

+ Im Rahmen der Schulung wurden strukturierte Kenntnisse im Bereich des Einsatzes + von Künstlicher Intelligenz in Organisationen vermittelt. +

+ +

+ Dieser Nachweis dokumentiert ausschließlich die erfolgreiche Teilnahme an der + Schulung sowie das Bestehen der innerhalb der Plattform vorgesehenen Prüfungen. +

+ +

+ Er stellt keine Prüfung, Bewertung oder Bestätigung der tatsächlichen Umsetzung + im Unternehmen dar. +

+ +

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

+ +
+ Ausgestellt durch:
+ ABC UG (haftungsbeschränkt) +
+ +
+
+ Datum: {{ valid_from }} +
+
+ gültig bis {{ valid_until }} +
+
+ + {% if signature_path %} +
+ Unterschrift +
+ {% endif %} + +
+ Nachweis-ID:
+ {{ reference_id }} +
+ + +
+ +
+
+ + \ No newline at end of file diff --git a/app/flask-postgres/app/templates/certificate_verification.html b/app/flask-postgres/app/templates/certificate_verification.html new file mode 100644 index 0000000..be9e0cb --- /dev/null +++ b/app/flask-postgres/app/templates/certificate_verification.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block content %} + +{% if invalid_guid %} +
+ ❌ Die angegebene ID kann nicht verifiziert werden. +
+{% else %} + + + +
+
+ +
+
Referenz
+
{{ certificate.guid }}
+ +
User
+
{{ certificate.user_name }}
+ +
Mandant
+
{{ certificate.mandant_name }}
+ +
Modul
+
{{ certificate.module_code }}
+ +
Gültig ab
+
{{ certificate.valid_from.strftime("%d.%m.%Y") }}
+ +
Gültig bis
+
{{ certificate.valid_until.strftime("%d.%m.%Y") }}
+
+ +
+
+ +{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/app/flask-postgres/app/templates/certificates_overview.html b/app/flask-postgres/app/templates/certificates_overview.html new file mode 100644 index 0000000..d04bda3 --- /dev/null +++ b/app/flask-postgres/app/templates/certificates_overview.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block content %} + + + +
+
+
+ + + + + + + + + + {% for user in users %} + + + + + + {% endfor %} + +
UserE-MailAbgeschlossene Module
{{ user.name }}{{ user.email }} +
+ {% for module in user.modules %} + {% if module.has_pdf %} + + PDF - {{ module.code }} + + {% else %} +
+ +
+ {% endif %} + {% endfor %} +
+
+
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/app/flask-postgres/app/templates/profil.html b/app/flask-postgres/app/templates/profil.html index 8a5a4a6..d2cfc79 100644 --- a/app/flask-postgres/app/templates/profil.html +++ b/app/flask-postgres/app/templates/profil.html @@ -42,6 +42,43 @@ Passwort ändern +
+
+

Zertifikate

+ + {% if certificates %} +
+ + + + + + + + + + + {% for cert in certificates %} + + + + + + + {% endfor %} + +
ModulGültig abGültig bisAktion
{{ cert.module_code }}{{ cert.valid_from.strftime("%d.%m.%Y") }}{{ cert.valid_until.strftime("%d.%m.%Y") }} + + PDF - {{ cert.module_code }} + +
+
+ {% else %} +

Noch keine Zertifikate vorhanden.

+ {% endif %} +
+
+ diff --git a/app/flask-postgres/files/SchulungsTeilnahme.docx b/app/flask-postgres/files/SchulungsTeilnahme.docx new file mode 100644 index 0000000..d4eac08 Binary files /dev/null and b/app/flask-postgres/files/SchulungsTeilnahme.docx differ diff --git a/app/flask-postgres/files/SchulungsTeilnahme.pdf b/app/flask-postgres/files/SchulungsTeilnahme.pdf new file mode 100644 index 0000000..219deb8 Binary files /dev/null and b/app/flask-postgres/files/SchulungsTeilnahme.pdf differ diff --git a/app/flask-postgres/styles/site.css b/app/flask-postgres/styles/site.css index 8bac8db..35c0793 100644 --- a/app/flask-postgres/styles/site.css +++ b/app/flask-postgres/styles/site.css @@ -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; } \ No newline at end of file