PDF Zertifikate
This commit is contained in:
parent
4e278ef7df
commit
1778bd1bae
@ -2,6 +2,17 @@ FROM python:3.11-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,9 @@ from security import (
|
|||||||
user_admin_required,
|
user_admin_required,
|
||||||
contentmanager_required
|
contentmanager_required
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from certificates import generate_certificate_pdf_for_user, get_user_module_completion
|
||||||
|
|
||||||
from logging_config import setup_logging
|
from logging_config import setup_logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@ -458,17 +461,13 @@ def profil():
|
|||||||
u.email,
|
u.email,
|
||||||
u.name,
|
u.name,
|
||||||
u.mandant_id,
|
u.mandant_id,
|
||||||
u.last_login,
|
|
||||||
u.status,
|
|
||||||
m.name AS mandant_name,
|
m.name AS mandant_name,
|
||||||
m.kuerzel AS mandant_kuerzel,
|
|
||||||
m.kontakt_email AS mandant_email,
|
m.kontakt_email AS mandant_email,
|
||||||
m.level AS mandant_level
|
m.level AS mandant_level
|
||||||
FROM app_user u
|
FROM app_user u
|
||||||
JOIN mandant m ON m.id = u.mandant_id
|
JOIN mandant m ON m.id = u.mandant_id
|
||||||
WHERE u.id = %s
|
WHERE u.id = %s
|
||||||
""", (session["user_id"],))
|
""", (session["user_id"],))
|
||||||
|
|
||||||
profile = fetchone_dict(cur)
|
profile = fetchone_dict(cur)
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@ -479,19 +478,27 @@ def profil():
|
|||||||
AND ug.mandant_id = %s
|
AND ug.mandant_id = %s
|
||||||
ORDER BY g.group_name
|
ORDER BY g.group_name
|
||||||
""", (session["user_id"], session["mandant_id"]))
|
""", (session["user_id"], session["mandant_id"]))
|
||||||
|
|
||||||
gruppen_rows = fetchall_dict(cur)
|
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()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
profile["mandant_level_label"] = format_level(profile["mandant_level"])
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"profil.html",
|
"profil.html",
|
||||||
|
page_title="Profil",
|
||||||
|
active_page="profil",
|
||||||
profile=profile,
|
profile=profile,
|
||||||
groups=gruppen,
|
groups=groups,
|
||||||
|
certificates=certificates,
|
||||||
mandant_level_label=format_level(profile["mandant_level"]),
|
mandant_level_label=format_level(profile["mandant_level"]),
|
||||||
**get_current_user()
|
**get_current_user()
|
||||||
)
|
)
|
||||||
@ -2226,3 +2233,173 @@ def reporting_assessments():
|
|||||||
dashboard_progress_percent=progress_percent,
|
dashboard_progress_percent=progress_percent,
|
||||||
**get_current_user()
|
**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
|
gunicorn==22.0.0
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
Werkzeug==3.0.1
|
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
|
pytest==8.3.2
|
||||||
|
python-json-logger==2.0.7
|
||||||
@ -43,6 +43,7 @@
|
|||||||
{% if is_user_admin %}
|
{% if is_user_admin %}
|
||||||
<a href="/useradmin/mandant">Useradministration</a>
|
<a href="/useradmin/mandant">Useradministration</a>
|
||||||
<a href="/reporting/assessments">Reporting</a>
|
<a href="/reporting/assessments">Reporting</a>
|
||||||
|
<a href="/certificates">Zertifikate</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if is_contentmanager %}
|
{% if is_contentmanager %}
|
||||||
<a href="/dokumente">Dokumente</a>
|
<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>
|
<a href="/pwdchange" class="btn-primary">Passwort ändern</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</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;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
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