User Level

This commit is contained in:
Bkolb 2026-04-17 19:39:21 +02:00
parent a971043478
commit b69992f6da
15 changed files with 1522 additions and 332 deletions

View File

@ -346,7 +346,7 @@ def login():
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
SELECT id, email, name, mandant_id, password_hash, status SELECT id, email, name, mandant_id, password_hash, status, level
FROM app_user FROM app_user
WHERE lower(email) = %s WHERE lower(email) = %s
""", (email,)) """, (email,))
@ -355,7 +355,7 @@ def login():
if not row: if not row:
error_message = "Benutzer nicht gefunden." error_message = "Benutzer nicht gefunden."
else: else:
user_id, user_email, user_name, mandant_id, password_hash, status = row user_id, user_email, user_name, mandant_id, password_hash, status, user_level = row
if status == 0: if status == 0:
error_message = "Benutzer ist noch nicht aktiviert." error_message = "Benutzer ist noch nicht aktiviert."
@ -370,6 +370,7 @@ def login():
session["user_email"] = user_email session["user_email"] = user_email
session["user_name"] = user_name session["user_name"] = user_name
session["mandant_id"] = mandant_id session["mandant_id"] = mandant_id
session["user_level"] = user_level
cur.execute(""" cur.execute("""
UPDATE app_user UPDATE app_user
@ -461,6 +462,7 @@ def profil():
u.email, u.email,
u.name, u.name,
u.mandant_id, u.mandant_id,
u.level AS user_level,
m.name AS mandant_name, m.name AS mandant_name,
m.kontakt_email AS mandant_email, m.kontakt_email AS mandant_email,
m.level AS mandant_level m.level AS mandant_level
@ -500,6 +502,7 @@ def profil():
groups=groups, groups=groups,
certificates=certificates, certificates=certificates,
mandant_level_label=format_level(profile["mandant_level"]), mandant_level_label=format_level(profile["mandant_level"]),
user_level_label=format_level(profile["user_level"]),
**get_current_user() **get_current_user()
) )
@ -715,7 +718,8 @@ def useradmin_mandant():
u.id, u.id,
u.email, u.email,
u.name, u.name,
u.status u.status,
u.level
FROM app_user u FROM app_user u
WHERE u.mandant_id = %s WHERE u.mandant_id = %s
ORDER BY u.name, u.email ORDER BY u.name, u.email
@ -725,6 +729,8 @@ def useradmin_mandant():
for user in users: for user in users:
user["status_label"] = format_user_status(user["status"]) user["status_label"] = format_user_status(user["status"])
user["can_delete"] = user["id"] != current_user_id user["can_delete"] = user["id"] != current_user_id
user["level_label"] = format_level(user["level"])
cur.close() cur.close()
conn.close() conn.close()
@ -743,11 +749,23 @@ def useradmin_mandant():
@app.route("/useradmin/mandant/new", methods=["GET", "POST"]) @app.route("/useradmin/mandant/new", methods=["GET", "POST"])
@user_admin_required @user_admin_required
def useradmin_user_new(): def useradmin_user_new():
import re
current_mandant_id = session.get("mandant_id") current_mandant_id = session.get("mandant_id")
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
# Mandanten-Level laden
cur.execute("""
SELECT level
FROM mandant
WHERE id = %s
""", (current_mandant_id,))
mandant_row = cur.fetchone()
mandant_level = mandant_row[0] if mandant_row else 3
# Gruppen des Mandanten laden
cur.execute(""" cur.execute("""
SELECT id, group_name SELECT id, group_name
FROM app_group FROM app_group
@ -757,26 +775,31 @@ def useradmin_user_new():
gruppen = fetchall_dict(cur) gruppen = fetchall_dict(cur)
form_error = None form_error = None
success_message = None
form_values = { form_values = {
"email": "", "email": "",
"name": "", "name": "",
"status": "1", "status": "1",
"selected_groups": [] "level": str(mandant_level),
"selected_groups": [],
} }
if request.method == "POST": if request.method == "POST":
email = request.form.get("email", "").strip().lower() email = request.form.get("email", "").strip().lower()
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
status = request.form.get("status", "1").strip()
level = request.form.get("level", str(mandant_level)).strip()
password = request.form.get("password", "") password = request.form.get("password", "")
password2 = request.form.get("password2", "") password2 = request.form.get("password2", "")
status = request.form.get("status", "1").strip()
selected_groups = request.form.getlist("group_ids") selected_groups = request.form.getlist("group_ids")
form_values = { form_values = {
"email": email, "email": email,
"name": name, "name": name,
"status": status, "status": status,
"selected_groups": selected_groups "level": level,
"selected_groups": selected_groups,
} }
email_pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$" email_pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
@ -787,6 +810,12 @@ def useradmin_user_new():
form_error = "Bitte eine gültige E-Mail-Adresse eingeben." form_error = "Bitte eine gültige E-Mail-Adresse eingeben."
elif not name: elif not name:
form_error = "Name ist ein Pflichtfeld." form_error = "Name ist ein Pflichtfeld."
elif status not in ("0", "1"):
form_error = "Status muss 0 oder 1 sein."
elif level not in ("1", "2", "3"):
form_error = "Bitte ein gültiges User-Level wählen."
elif int(level) < int(mandant_level):
form_error = "Der User-Level darf nicht höher als der Mandanten-Level sein."
elif not password: elif not password:
form_error = "Passwort ist ein Pflichtfeld." form_error = "Passwort ist ein Pflichtfeld."
elif not password2: elif not password2:
@ -805,51 +834,58 @@ def useradmin_user_new():
if existing_user: if existing_user:
form_error = "Ein Benutzer mit dieser E-Mail existiert bereits." form_error = "Ein Benutzer mit dieser E-Mail existiert bereits."
else:
password_hash = generate_password_hash(password)
if not form_error:
password_hash = generate_password_hash(password)
cur.execute("""
INSERT INTO app_user (email, name, mandant_id, password_hash, status, level)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (
email,
name,
current_mandant_id,
password_hash,
int(status),
int(level)
))
new_user_id = cur.fetchone()[0]
selected_group_ids = []
for gid in selected_groups:
try:
selected_group_ids.append(int(gid))
except ValueError:
pass
if selected_group_ids:
cur.execute(""" cur.execute("""
INSERT INTO app_user ( SELECT id
email, FROM app_group
name, WHERE mandant_id = %s
mandant_id, AND id = ANY(%s)
password_hash, """, (current_mandant_id, selected_group_ids))
status valid_groups = cur.fetchall()
)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""", (
email,
name,
current_mandant_id,
password_hash,
int(status or 1)
))
new_user_id = cur.fetchone()[0]
if selected_groups:
selected_group_ids = [int(gid) for gid in selected_groups]
for row in valid_groups:
group_id = row[0]
cur.execute(""" cur.execute("""
SELECT id INSERT INTO user_group (user_id, group_id, mandant_id)
FROM app_group VALUES (%s, %s, %s)
WHERE mandant_id = %s """, (new_user_id, group_id, current_mandant_id))
AND id = ANY(%s)
""", (current_mandant_id, selected_group_ids))
valid_groups = cur.fetchall()
for row in valid_groups: conn.commit()
group_id = row[0] success_message = "Benutzer wurde erfolgreich angelegt."
cur.execute("""
INSERT INTO user_group (user_id, group_id, mandant_id)
VALUES (%s, %s, %s)
""", (new_user_id, group_id, current_mandant_id))
conn.commit() # Formular leeren / auf Defaults zurücksetzen
cur.close() form_values = {
conn.close() "email": "",
"name": "",
return redirect(url_for("useradmin_mandant")) "status": "1",
"level": str(mandant_level),
"selected_groups": [],
}
cur.close() cur.close()
conn.close() conn.close()
@ -859,8 +895,11 @@ def useradmin_user_new():
page_title="Neuer User", page_title="Neuer User",
active_page="useradmin", active_page="useradmin",
gruppen=gruppen, gruppen=gruppen,
form_error=form_error,
form_values=form_values, form_values=form_values,
form_error=form_error,
success_message=success_message,
mandant_level=mandant_level,
mandant_level_label=format_level(mandant_level),
**get_current_user() **get_current_user()
) )
@ -885,8 +924,18 @@ def useradmin_user_edit(user_id):
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
# Mandanten-Level laden
cur.execute(""" cur.execute("""
SELECT id, email, name, mandant_id, status SELECT level
FROM mandant
WHERE id = %s
""", (current_mandant_id,))
mandant_row = cur.fetchone()
mandant_level = mandant_row[0] if mandant_row else None
# User laden
cur.execute("""
SELECT id, email, name, mandant_id, status, level
FROM app_user FROM app_user
WHERE id = %s WHERE id = %s
AND mandant_id = %s AND mandant_id = %s
@ -898,6 +947,7 @@ def useradmin_user_edit(user_id):
conn.close() conn.close()
abort(404) abort(404)
# Gruppen des Mandanten
cur.execute(""" cur.execute("""
SELECT id, group_name SELECT id, group_name
FROM app_group FROM app_group
@ -906,6 +956,7 @@ def useradmin_user_edit(user_id):
""", (current_mandant_id,)) """, (current_mandant_id,))
gruppen = fetchall_dict(cur) gruppen = fetchall_dict(cur)
# Zugeordnete Gruppen des Users
cur.execute(""" cur.execute("""
SELECT group_id SELECT group_id
FROM user_group FROM user_group
@ -922,6 +973,7 @@ def useradmin_user_edit(user_id):
"email": user["email"], "email": user["email"],
"name": user["name"], "name": user["name"],
"status": str(user["status"]), "status": str(user["status"]),
"level": str(user["level"] if user.get("level") is not None else 1),
"selected_groups": assigned_group_ids, "selected_groups": assigned_group_ids,
} }
@ -929,6 +981,7 @@ def useradmin_user_edit(user_id):
email = request.form.get("email", "").strip().lower() email = request.form.get("email", "").strip().lower()
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
status = request.form.get("status", "1").strip() status = request.form.get("status", "1").strip()
level = request.form.get("level", "1").strip()
password = request.form.get("password", "") password = request.form.get("password", "")
password2 = request.form.get("password2", "") password2 = request.form.get("password2", "")
selected_groups = request.form.getlist("group_ids") selected_groups = request.form.getlist("group_ids")
@ -937,6 +990,7 @@ def useradmin_user_edit(user_id):
"email": email, "email": email,
"name": name, "name": name,
"status": status, "status": status,
"level": level,
"selected_groups": selected_groups, "selected_groups": selected_groups,
} }
@ -948,6 +1002,12 @@ def useradmin_user_edit(user_id):
form_error = "Bitte eine gültige E-Mail-Adresse eingeben." form_error = "Bitte eine gültige E-Mail-Adresse eingeben."
elif not name: elif not name:
form_error = "Name ist ein Pflichtfeld." form_error = "Name ist ein Pflichtfeld."
elif status not in ("0", "1"):
form_error = "Status muss 0 oder 1 sein."
elif level not in ("1", "2", "3"):
form_error = "Bitte ein gültiges User-Level wählen."
elif mandant_level is not None and int(level) < int(mandant_level):
form_error = "Der User-Level darf nicht höher als der Mandanten-Level sein."
else: else:
cur.execute(""" cur.execute("""
SELECT id SELECT id
@ -975,10 +1035,18 @@ def useradmin_user_edit(user_id):
UPDATE app_user UPDATE app_user
SET email = %s, SET email = %s,
name = %s, name = %s,
status = %s status = %s,
level = %s
WHERE id = %s WHERE id = %s
AND mandant_id = %s AND mandant_id = %s
""", (email, name, int(status or 1), user_id, current_mandant_id)) """, (
email,
name,
int(status),
int(level),
user_id,
current_mandant_id
))
if password: if password:
password_hash = generate_password_hash(password) password_hash = generate_password_hash(password)
@ -1021,6 +1089,7 @@ def useradmin_user_edit(user_id):
conn.commit() conn.commit()
success_message = "Benutzer wurde erfolgreich aktualisiert." success_message = "Benutzer wurde erfolgreich aktualisiert."
# Formularwerte nach dem Speichern neu laden
cur.execute(""" cur.execute("""
SELECT group_id SELECT group_id
FROM user_group FROM user_group
@ -1030,6 +1099,16 @@ def useradmin_user_edit(user_id):
assigned_rows = cur.fetchall() assigned_rows = cur.fetchall()
form_values["selected_groups"] = [str(row[0]) for row in assigned_rows] form_values["selected_groups"] = [str(row[0]) for row in assigned_rows]
# edit_user für Anzeige aktualisieren
user["email"] = email
user["name"] = name
user["status"] = int(status)
user["level"] = int(level)
# Falls der aktuell eingeloggte User sich selbst geändert hat: Session-Level aktualisieren
if session.get("user_id") == user_id:
session["user_level"] = int(level)
cur.close() cur.close()
conn.close() conn.close()
@ -1042,6 +1121,8 @@ def useradmin_user_edit(user_id):
form_values=form_values, form_values=form_values,
form_error=form_error, form_error=form_error,
success_message=success_message, success_message=success_message,
mandant_level=mandant_level,
mandant_level_label=format_level(mandant_level) if mandant_level is not None else "-",
**get_current_user() **get_current_user()
) )
@ -1050,6 +1131,7 @@ def useradmin_user_edit(user_id):
def course_list(): def course_list():
user_id = session.get("user_id") user_id = session.get("user_id")
mandant_level = session.get("mandant_level", 0) mandant_level = session.get("mandant_level", 0)
user_level = session.get("user_level", 3)
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
@ -1066,7 +1148,7 @@ def course_list():
# Filter nach Level # Filter nach Level
available_courses = [ available_courses = [
c for c in all_courses c for c in all_courses
if is_course_allowed_for_level(c["code"], mandant_level) if is_course_allowed_for_level(c["code"], user_level)
] ]
# Bestandene Assessments laden # Bestandene Assessments laden
@ -1870,6 +1952,9 @@ def admin_questions_course(course_id):
@login_required @login_required
def course_assessment(course_id): def course_assessment(course_id):
mandant_level = session.get("mandant_level", 0) mandant_level = session.get("mandant_level", 0)
user_level = session.get("user_level", 3)
if not is_course_allowed_for_level(course["code"], user_level):
abort(403)
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
@ -1996,6 +2081,10 @@ def course_assessment(course_id):
@app.route("/useradmin/mandant/upload", methods=["GET", "POST"]) @app.route("/useradmin/mandant/upload", methods=["GET", "POST"])
@user_admin_required @user_admin_required
def useradmin_user_upload(): def useradmin_user_upload():
import csv
import io
import re
current_mandant_id = session.get("mandant_id") current_mandant_id = session.get("mandant_id")
form_error = None form_error = None
success_message = None success_message = None
@ -2004,13 +2093,15 @@ def useradmin_user_upload():
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
# Mandant + Mandanten-Level laden
cur.execute(""" cur.execute("""
SELECT name SELECT name, level
FROM mandant FROM mandant
WHERE id = %s WHERE id = %s
""", (current_mandant_id,)) """, (current_mandant_id,))
mandant_row = cur.fetchone() mandant_row = cur.fetchone()
mandant_name = mandant_row[0] if mandant_row else f"Mandant {current_mandant_id}" mandant_name = mandant_row[0] if mandant_row else f"Mandant {current_mandant_id}"
mandant_level = mandant_row[1] if mandant_row else 3
if request.method == "POST": if request.method == "POST":
uploaded_file = request.files.get("csv_file") uploaded_file = request.files.get("csv_file")
@ -2020,10 +2111,14 @@ def useradmin_user_upload():
else: else:
file_bytes = uploaded_file.read() file_bytes = uploaded_file.read()
try: if not file_bytes:
content = file_bytes.decode("utf-8-sig") form_error = "Die Datei ist leer."
except UnicodeDecodeError:
form_error = "Die Datei ist nicht im UTF-8 Format. Bitte als UTF-8 CSV speichern." if not form_error:
try:
content = file_bytes.decode("utf-8-sig")
except UnicodeDecodeError:
form_error = "Die Datei ist nicht im UTF-8 Format. Bitte als UTF-8 CSV speichern."
if not form_error: if not form_error:
reader = csv.reader(io.StringIO(content), delimiter=";") reader = csv.reader(io.StringIO(content), delimiter=";")
@ -2032,7 +2127,8 @@ def useradmin_user_upload():
if not rows: if not rows:
form_error = "Die Datei ist leer." form_error = "Die Datei ist leer."
# Nur echte Datenzeilen zählen (keine leeren) if not form_error:
# Nur echte Datenzeilen zählen
data_rows = [ data_rows = [
row for row in rows row for row in rows
if row and any(str(col).strip() for col in row) if row and any(str(col).strip() for col in row)
@ -2048,9 +2144,6 @@ def useradmin_user_upload():
seen_emails_in_file = set() seen_emails_in_file = set()
for line_no, row in enumerate(data_rows, start=1): for line_no, row in enumerate(data_rows, start=1):
if not row or all(not str(col).strip() for col in row):
continue
if len(row) != 4: if len(row) != 4:
row_errors.append(f"Zeile {line_no}: Es müssen genau 4 Felder vorhanden sein.") row_errors.append(f"Zeile {line_no}: Es müssen genau 4 Felder vorhanden sein.")
continue continue
@ -2064,14 +2157,17 @@ def useradmin_user_upload():
if not name: if not name:
line_problems.append("Name fehlt") line_problems.append("Name fehlt")
if not email: if not email:
line_problems.append("E-Mail fehlt") line_problems.append("E-Mail fehlt")
elif not re.match(email_pattern, email): elif not re.match(email_pattern, email):
line_problems.append("E-Mail ungültig") line_problems.append("E-Mail ungültig")
if not status: if not status:
line_problems.append("Status fehlt") line_problems.append("Status fehlt")
elif status not in ("0", "1"): elif status not in ("0", "1"):
line_problems.append("Status muss 0 oder 1 sein") line_problems.append("Status muss 0 oder 1 sein")
if not password: if not password:
line_problems.append("Passwort fehlt") line_problems.append("Passwort fehlt")
elif ";" in password: elif ";" in password:
@ -2102,6 +2198,7 @@ def useradmin_user_upload():
"email": email, "email": email,
"status": int(status), "status": int(status),
"password_hash": generate_password_hash(password), "password_hash": generate_password_hash(password),
"level": mandant_level, # 👈 neu
}) })
if row_errors: if row_errors:
@ -2109,14 +2206,15 @@ def useradmin_user_upload():
else: else:
for u in parsed_users: for u in parsed_users:
cur.execute(""" cur.execute("""
INSERT INTO app_user (email, name, mandant_id, password_hash, status) INSERT INTO app_user (email, name, mandant_id, password_hash, status, level)
VALUES (%s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
""", ( """, (
u["email"], u["email"],
u["name"], u["name"],
current_mandant_id, current_mandant_id,
u["password_hash"], u["password_hash"],
u["status"] u["status"],
u["level"]
)) ))
conn.commit() conn.commit()
@ -2133,6 +2231,7 @@ def useradmin_user_upload():
success_message=success_message, success_message=success_message,
row_errors=row_errors, row_errors=row_errors,
mandant_name=mandant_name, mandant_name=mandant_name,
mandant_level_label=format_level(mandant_level),
**get_current_user() **get_current_user()
) )
@ -2221,59 +2320,93 @@ def pwdchange():
@user_admin_required @user_admin_required
def reporting_assessments(): def reporting_assessments():
current_mandant_id = session.get("mandant_id") current_mandant_id = session.get("mandant_id")
mandant_level = session.get("mandant_level")
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
# Mandant # Mandant laden
cur.execute("SELECT name, level FROM mandant WHERE id = %s", (current_mandant_id,))
mandant_row = cur.fetchone()
mandant_name = mandant_row[0]
mandant_level_value = mandant_row[1]
# User
cur.execute(""" cur.execute("""
SELECT id, name, email, status SELECT name, level
FROM mandant
WHERE id = %s
""", (current_mandant_id,))
mandant_row = cur.fetchone()
if not mandant_row:
cur.close()
conn.close()
abort(404)
mandant_name = mandant_row[0]
mandant_level = mandant_row[1]
# User des Mandanten
cur.execute("""
SELECT id, name, email, status, level
FROM app_user FROM app_user
WHERE mandant_id = %s WHERE mandant_id = %s
ORDER BY name, email
""", (current_mandant_id,)) """, (current_mandant_id,))
users = fetchall_dict(cur) users = fetchall_dict(cur)
# Kurse # Alle aktiven Kurse
cur.execute(""" cur.execute("""
SELECT id, code, title, min_level SELECT id, code, title, min_level, sort_order
FROM course FROM course
WHERE is_active = TRUE WHERE is_active = TRUE
ORDER BY code, sort_order, id
""") """)
all_courses = fetchall_dict(cur) all_courses = fetchall_dict(cur)
# Reporting basiert auf Mandanten-Level, NICHT auf User-Level
available_courses = [ available_courses = [
c for c in all_courses c for c in all_courses
if is_course_allowed_for_level(c["code"], mandant_level) if is_course_allowed_for_level(c["code"], mandant_level)
] ]
# Assessments (nur bestanden) # Erfolgreiche Assessments
cur.execute(""" cur.execute("""
SELECT user_id, course_id, score, created_at SELECT user_id, course_id, score, passed, created_at
FROM user_assessment FROM user_assessment
WHERE passed = TRUE WHERE passed = TRUE
""") """)
assessments = fetchall_dict(cur) assessment_rows = fetchall_dict(cur)
cur.close() cur.close()
conn.close() conn.close()
passed_map = {(a["user_id"], a["course_id"]): a for a in assessments} # Letzten erfolgreichen Abschluss je User/Kurs merken
passed_map = {}
for row in assessment_rows:
key = (row["user_id"], row["course_id"])
if key not in passed_map or row["created_at"] > passed_map[key]["created_at"]:
passed_map[key] = row
# Filterlisten
available_courses = sorted(available_courses, key=lambda c: c["code"] or "")
user_list = sorted(
[{"id": u["id"], "name": u["name"]} for u in users],
key=lambda x: (x["name"] or "").lower()
)
# Detaildaten
detail_rows = []
completed_count = 0
open_count = 0
detail_rows = [] detail_rows = []
completed_count = 0 completed_count = 0
open_count = 0 open_count = 0
for u in users: for user in users:
for c in available_courses: user_allowed_courses = [
a = passed_map.get((u["id"], c["id"])) c for c in all_courses
done = a is not None if is_course_allowed_for_level(c["code"], user["level"])
]
for course in user_allowed_courses:
passed_row = passed_map.get((user["id"], course["id"]))
done = passed_row is not None
if done: if done:
completed_count += 1 completed_count += 1
@ -2281,37 +2414,39 @@ def reporting_assessments():
open_count += 1 open_count += 1
detail_rows.append({ detail_rows.append({
"user_name": u["name"], "user_id": user["id"],
"user_email": u["email"], "user_name": user["name"],
"course_code": c["code"], "user_email": user["email"],
"course_title": c["title"], "user_status": user["status"],
"user_level": user["level"],
"user_level_label": format_level(user["level"]),
"course_id": course["id"],
"course_code": course["code"],
"course_title": course["title"],
"done": done, "done": done,
"done_label": "erledigt" if done else "offen", "done_label": "erledigt" if done else "offen",
"completed_at": a["created_at"] if done else None, "completed_at": passed_row["created_at"] if done else None,
"score": a["score"] if done else None, "score": passed_row["score"] if done else None,
}) })
total_expected = len(users) * len(available_courses) total_expected = len(users) * len(available_courses)
progress_percent = int((completed_count / total_expected) * 100) if total_expected else 0 progress_percent = int((completed_count / total_expected) * 100) if total_expected > 0 else 0
users = sorted(users, key=lambda u: (u["name"] or "", u["email"] or ""))
available_courses = sorted(available_courses, key=lambda c: c["code"])
user_list = sorted(
[{"id": u["id"], "name": u["name"]} for u in users],
key=lambda x: x["name"] or ""
)
return render_template( return render_template(
"reporting_assessments.html", "reporting_assessments.html",
page_title="Assessment Reporting",
active_page="reporting_assessments",
mandant_name=mandant_name, mandant_name=mandant_name,
mandant_level_label=format_level(mandant_level_value), mandant_level=mandant_level,
mandant_level_label=format_level(mandant_level),
available_courses=available_courses, available_courses=available_courses,
user_list=user_list, user_list=user_list,
detail_rows=detail_rows, detail_rows=detail_rows,
dashboard_total_users=len(users),
dashboard_total_courses=len(available_courses),
dashboard_total_expected=total_expected,
dashboard_completed=completed_count, dashboard_completed=completed_count,
dashboard_open=open_count, dashboard_open=open_count,
dashboard_total_expected=total_expected,
dashboard_progress_percent=progress_percent, dashboard_progress_percent=progress_percent,
**get_current_user() **get_current_user()
) )

View File

@ -146,8 +146,10 @@ def generate_certificate_pdf_for_user(user_id, module_code):
reference_url = f"https://cert.compliance-verification.info/verification/{guid_value}" reference_url = f"https://cert.compliance-verification.info/verification/{guid_value}"
pdf_path = os.path.join(CERT_OUTPUT_DIR, f"{guid_value}.pdf") pdf_path = os.path.join(CERT_OUTPUT_DIR, f"{guid_value}.pdf")
template_name = get_certificate_template_for_module(data["module_code"])
html = render_template( html = render_template(
"certificate_template.html", template_name,
user_name=data["user_name"], user_name=data["user_name"],
mandant_name=data["mandant_name"], mandant_name=data["mandant_name"],
module_code=data["module_code"], module_code=data["module_code"],
@ -281,4 +283,15 @@ def ensure_certificate_for_user_module(user_id, module_code):
return guid_value return guid_value
def is_module_completed_for_user(user_id, module_code): def is_module_completed_for_user(user_id, module_code):
return get_user_module_completion(user_id, module_code) is not None return get_user_module_completion(user_id, module_code) is not None
def get_certificate_template_for_module(module_code):
module_code = (module_code or "").upper()
mapping = {
"A": "certificates/certificate_A.html",
"B": "certificates/certificate_B.html",
"C": "certificates/certificate_C.html",
}
return mapping.get(module_code, "certificates/certificate_A.html")

View File

@ -28,27 +28,20 @@ def is_video_allowed_for_level(filename: str, mandant_level: int | None) -> bool
def is_course_allowed_for_level(code: str, mandant_level: int | None) -> bool: def is_course_allowed_for_level(course_code, level):
if mandant_level is None: if level is None:
return False return False
prefix = (code or "")[:1].upper() first_char = (course_code or "")[:1].upper()
# 0 = Admin = alles if level == 0:
if mandant_level == 0:
return True return True
if level == 1:
# 1 = Gold = A + B + C return first_char in ("A", "B", "C")
if mandant_level == 1: if level == 2:
return prefix in ("A", "B", "C") return first_char in ("A", "B")
if level == 3:
# 2 = Silber = A + B return first_char == "A"
if mandant_level == 2:
return prefix in ("A", "B")
# 3 = Bronze = A
if mandant_level == 3:
return prefix == "A"
return False return False

View File

@ -67,6 +67,7 @@ def get_current_user():
"user_name": session.get("user_name"), "user_name": session.get("user_name"),
"user_email": session.get("user_email"), "user_email": session.get("user_email"),
"mandant_id": session.get("mandant_id"), "mandant_id": session.get("mandant_id"),
"user_level": session.get("user_level"),
"is_logged_in": bool(session.get("user_id")), "is_logged_in": bool(session.get("user_id")),
"is_admin": user_is_admin() if session.get("user_id") else False, "is_admin": user_is_admin() if session.get("user_id") else False,
"is_user_admin": user_is_user_admin() if session.get("user_id") else False, "is_user_admin": user_is_user_admin() if session.get("user_id") else False,

View File

@ -66,7 +66,7 @@
<a href="/useradmin/mandant">Userverwaltung</a> <a href="/useradmin/mandant">Userverwaltung</a>
<a href="/certificates">Zertifikate</a> <a href="/certificates">Zertifikate</a>
<a href="/reporting">Reporting</a> <a href="/reporting/assessments">Reporting</a>
</div> </div>
{% endif %} {% endif %}

View File

@ -0,0 +1,299 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Zertifikat Level A</title>
<style>
@page {
size: A4;
margin: 6mm 8mm 10mm 8mm;
@bottom-center {
content: "Seite " counter(page) "/" counter(pages);
font-family: Arial, Helvetica, sans-serif;
font-size: 9pt;
color: #64748b;
}
}
html, body {
margin: 0;
padding: 0;
background: #fff;
font-family: Arial, Helvetica, sans-serif;
color: #1f2937;
font-size: 10pt;
line-height: 1.26;
}
body {
height: 100%;
}
.certificate {
border: 1px solid #d9e2ec;
border-radius: 10px;
box-sizing: border-box;
/* A4-Höhe minus page margins */
min-height: 281mm;
padding: 7mm 9mm 5mm 9mm;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.content-area {
flex: 1 1 auto;
}
.logo {
text-align: center;
margin-bottom: 4mm;
}
.logo img {
max-height: 46px;
max-width: 180px;
}
.title {
text-align: center;
font-size: 19pt;
font-weight: 700;
color: #0d2f57;
margin: 0 0 4mm 0;
}
.intro,
.program-line,
.program-name,
.user-name,
.mandant-name {
text-align: center;
}
.intro {
margin-bottom: 2mm;
}
.user-name {
font-size: 17pt;
font-weight: 700;
color: #0d2f57;
margin: 2mm 0;
}
.mandant-name {
font-size: 12pt;
margin-bottom: 4mm;
}
.program-line {
margin-bottom: 1.5mm;
}
.program-name {
font-size: 14pt;
font-weight: 700;
color: #0d2f57;
margin-bottom: 5mm;
}
p {
margin: 0 0 3mm 0;
text-align: justify;
}
.focus-box {
background: #f8fbff;
border: 1px solid #dce3ea;
border-radius: 8px;
padding: 4mm 5mm;
margin: 4mm 0;
}
.focus-title {
font-weight: 700;
color: #0d2f57;
margin-bottom: 2mm;
}
ul {
margin: 0;
padding-left: 5mm;
}
li {
margin-bottom: 1.3mm;
}
.dates-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12mm;
margin-top: 5mm;
margin-bottom: 5mm;
font-weight: 700;
font-size: 10.5pt;
}
.dates-right {
text-align: right;
margin-left: auto;
}
.bottom-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16mm;
margin-top: 4mm;
}
.left-col {
width: 54%;
}
.right-col {
width: 46%;
text-align: right;
}
.reference-id {
margin-top: 1mm;
font-size: 8.5pt; /* kleiner */
line-height: 1.15;
white-space: nowrap; /* GUID nie umbrechen */
}
.reference-id a {
color: #0d2f57;
text-decoration: underline;
white-space: nowrap;
}
.issuer-title {
font-weight: 700;
margin-bottom: 2mm;
}
.issuer-name {
margin-bottom: 2mm;
display: inline-block;
text-align: right;
}
.signature {
margin-top: 1mm;
text-align: right;
}
.signature img {
height: 48px; /* höher */
max-width: 200px;
}
.footer-note {
flex: 0 0 auto;
margin-top: 5mm;
padding-top: 3mm;
text-align: center;
font-size: 8.8pt;
color: #526172;
white-space: nowrap;
}
</style>
</head>
<body>
<div class="certificate">
<div class="content-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 A“</div>
<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>
<div class="focus-box">
<div class="focus-title">Level A Vermittelt wurden grundlegende Kenntnisse zu:</div>
<ul>
<li>Funktionsweise und Einsatz von KI-Systemen</li>
<li>Risiken, Verantwortung und menschlicher Kontrolle</li>
<li>Transparenz- und Kennzeichnungspflichten</li>
<li>grundlegender Dokumentation und internen Prozessen</li>
</ul>
</div>
<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="dates-row">
<div>Datum: {{ valid_from }}</div>
<div class="dates-right">Gültig bis: {{ valid_until }}</div>
</div>
<div class="bottom-row">
<div class="left-col">
<div class="reference-id">
<strong>Nachweis-ID:</strong>
<a href="{{ reference_url }}">{{ reference_id }}</a>
</div>
</div>
<div class="right-col">
<div class="issuer-title">Ausgestellt durch:</div>
<div class="issuer-name">ABC UG (haftungsbeschränkt)</div>
{% if signature_path %}
<div class="signature">
<img src="{{ signature_path }}" alt="Unterschrift">
</div>
{% endif %}
</div>
</div>
</div>
<div class="footer-note">
Dieses Zertifikat wurde automatisiert erzeugt und ist über die angegebene Referenz verifizierbar.
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,301 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Zertifikat Level A</title>
<style>
@page {
size: A4;
margin: 6mm 8mm 10mm 8mm;
@bottom-center {
content: "Seite " counter(page) "/" counter(pages);
font-family: Arial, Helvetica, sans-serif;
font-size: 9pt;
color: #64748b;
}
}
html, body {
margin: 0;
padding: 0;
background: #fff;
font-family: Arial, Helvetica, sans-serif;
color: #1f2937;
font-size: 10pt;
line-height: 1.26;
}
body {
height: 100%;
}
.certificate {
border: 1px solid #d9e2ec;
border-radius: 10px;
box-sizing: border-box;
/* A4-Höhe minus page margins */
min-height: 281mm;
padding: 7mm 9mm 5mm 9mm;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.content-area {
flex: 1 1 auto;
}
.logo {
text-align: center;
margin-bottom: 4mm;
}
.logo img {
max-height: 46px;
max-width: 180px;
}
.title {
text-align: center;
font-size: 19pt;
font-weight: 700;
color: #0d2f57;
margin: 0 0 4mm 0;
}
.intro,
.program-line,
.program-name,
.user-name,
.mandant-name {
text-align: center;
}
.intro {
margin-bottom: 2mm;
}
.user-name {
font-size: 17pt;
font-weight: 700;
color: #0d2f57;
margin: 2mm 0;
}
.mandant-name {
font-size: 12pt;
margin-bottom: 4mm;
}
.program-line {
margin-bottom: 1.5mm;
}
.program-name {
font-size: 14pt;
font-weight: 700;
color: #0d2f57;
margin-bottom: 5mm;
}
p {
margin: 0 0 3mm 0;
text-align: justify;
}
.focus-box {
background: #f8fbff;
border: 1px solid #dce3ea;
border-radius: 8px;
padding: 4mm 5mm;
margin: 4mm 0;
}
.focus-title {
font-weight: 700;
color: #0d2f57;
margin-bottom: 2mm;
}
ul {
margin: 0;
padding-left: 5mm;
}
li {
margin-bottom: 1.3mm;
}
.dates-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12mm;
margin-top: 5mm;
margin-bottom: 5mm;
font-weight: 700;
font-size: 10.5pt;
}
.dates-right {
text-align: right;
margin-left: auto;
}
.bottom-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16mm;
margin-top: 4mm;
}
.left-col {
width: 54%;
}
.right-col {
width: 46%;
text-align: right;
}
.reference-id {
margin-top: 1mm;
font-size: 8.5pt; /* kleiner */
line-height: 1.15;
white-space: nowrap; /* GUID nie umbrechen */
}
.reference-id a {
color: #0d2f57;
text-decoration: underline;
white-space: nowrap;
}
.issuer-title {
font-weight: 700;
margin-bottom: 2mm;
}
.issuer-name {
margin-bottom: 2mm;
display: inline-block;
text-align: right;
}
.signature {
margin-top: 1mm;
text-align: right;
}
.signature img {
height: 48px; /* höher */
max-width: 200px;
}
.footer-note {
flex: 0 0 auto;
margin-top: 5mm;
padding-top: 3mm;
text-align: center;
font-size: 8.8pt;
color: #526172;
white-space: nowrap;
}
</style>
</head>
<body>
<div class="certificate">
<div class="content-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 B“</div>
<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>
<div class="focus-box">
<div class="focus-title">Level B Vermittelt wurden vertiefte Kenntnisse zu:</div>
<ul>
<li>datenschutzkonformer Nutzung von KI</li>
<li>unternehmensinternen Richtlinien und Prozessen</li>
<li>urheberrechtlichen Fragestellungen</li>
<li>Dokumentation und Vorbereitung auf Prüfungen</li>
<li>organisatorischer Verantwortung und Umsetzung</li>
</ul>
</div>
<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="dates-row">
<div>Datum: {{ valid_from }}</div>
<div class="dates-right">Gültig bis: {{ valid_until }}</div>
</div>
<div class="bottom-row">
<div class="left-col">
<div class="reference-id">
<strong>Nachweis-ID:</strong>
<a href="{{ reference_url }}">{{ reference_id }}</a>
</div>
</div>
<div class="right-col">
<div class="issuer-title">Ausgestellt durch:</div>
<div class="issuer-name">ABC UG (haftungsbeschränkt)</div>
{% if signature_path %}
<div class="signature">
<img src="{{ signature_path }}" alt="Unterschrift">
</div>
{% endif %}
</div>
</div>
</div>
<div class="footer-note">
Dieses Zertifikat wurde automatisiert erzeugt und ist über die angegebene Referenz verifizierbar.
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,301 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Zertifikat Level A</title>
<style>
@page {
size: A4;
margin: 6mm 8mm 10mm 8mm;
@bottom-center {
content: "Seite " counter(page) "/" counter(pages);
font-family: Arial, Helvetica, sans-serif;
font-size: 9pt;
color: #64748b;
}
}
html, body {
margin: 0;
padding: 0;
background: #fff;
font-family: Arial, Helvetica, sans-serif;
color: #1f2937;
font-size: 10pt;
line-height: 1.26;
}
body {
height: 100%;
}
.certificate {
border: 1px solid #d9e2ec;
border-radius: 10px;
box-sizing: border-box;
/* A4-Höhe minus page margins */
min-height: 281mm;
padding: 7mm 9mm 5mm 9mm;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.content-area {
flex: 1 1 auto;
}
.logo {
text-align: center;
margin-bottom: 4mm;
}
.logo img {
max-height: 46px;
max-width: 180px;
}
.title {
text-align: center;
font-size: 19pt;
font-weight: 700;
color: #0d2f57;
margin: 0 0 4mm 0;
}
.intro,
.program-line,
.program-name,
.user-name,
.mandant-name {
text-align: center;
}
.intro {
margin-bottom: 2mm;
}
.user-name {
font-size: 17pt;
font-weight: 700;
color: #0d2f57;
margin: 2mm 0;
}
.mandant-name {
font-size: 12pt;
margin-bottom: 4mm;
}
.program-line {
margin-bottom: 1.5mm;
}
.program-name {
font-size: 14pt;
font-weight: 700;
color: #0d2f57;
margin-bottom: 5mm;
}
p {
margin: 0 0 3mm 0;
text-align: justify;
}
.focus-box {
background: #f8fbff;
border: 1px solid #dce3ea;
border-radius: 8px;
padding: 4mm 5mm;
margin: 4mm 0;
}
.focus-title {
font-weight: 700;
color: #0d2f57;
margin-bottom: 2mm;
}
ul {
margin: 0;
padding-left: 5mm;
}
li {
margin-bottom: 1.3mm;
}
.dates-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12mm;
margin-top: 5mm;
margin-bottom: 5mm;
font-weight: 700;
font-size: 10.5pt;
}
.dates-right {
text-align: right;
margin-left: auto;
}
.bottom-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16mm;
margin-top: 4mm;
}
.left-col {
width: 54%;
}
.right-col {
width: 46%;
text-align: right;
}
.reference-id {
margin-top: 1mm;
font-size: 8.5pt; /* kleiner */
line-height: 1.15;
white-space: nowrap; /* GUID nie umbrechen */
}
.reference-id a {
color: #0d2f57;
text-decoration: underline;
white-space: nowrap;
}
.issuer-title {
font-weight: 700;
margin-bottom: 2mm;
}
.issuer-name {
margin-bottom: 2mm;
display: inline-block;
text-align: right;
}
.signature {
margin-top: 1mm;
text-align: right;
}
.signature img {
height: 48px; /* höher */
max-width: 200px;
}
.footer-note {
flex: 0 0 auto;
margin-top: 5mm;
padding-top: 3mm;
text-align: center;
font-size: 8.8pt;
color: #526172;
white-space: nowrap;
}
</style>
</head>
<body>
<div class="certificate">
<div class="content-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 C“</div>
<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>
<div class="focus-box">
<div class="focus-title">Level C Vermittelt wurden strategische Kenntnisse zu:</div>
<ul>
<li>Schutz und Nutzung geistigen Eigentums im KI-Kontext</li>
<li>Sicherheits- und Missbrauchsrisiken</li>
<li>Steuerung externer KI-Anbieter</li>
<li>unternehmensweiter Risikoanalyse und Governance</li>
<li>Umsetzung und fortlaufender Begleitung</li>
</ul>
</div>
<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="dates-row">
<div>Datum: {{ valid_from }}</div>
<div class="dates-right">Gültig bis: {{ valid_until }}</div>
</div>
<div class="bottom-row">
<div class="left-col">
<div class="reference-id">
<strong>Nachweis-ID:</strong>
<a href="{{ reference_url }}">{{ reference_id }}</a>
</div>
</div>
<div class="right-col">
<div class="issuer-title">Ausgestellt durch:</div>
<div class="issuer-name">ABC UG (haftungsbeschränkt)</div>
{% if signature_path %}
<div class="signature">
<img src="{{ signature_path }}" alt="Unterschrift">
</div>
{% endif %}
</div>
</div>
</div>
<div class="footer-note">
Dieses Zertifikat wurde automatisiert erzeugt und ist über die angegebene Referenz verifizierbar.
</div>
</div>
</body>
</html>

View File

@ -29,6 +29,9 @@
<div class="profile-label">Mandant Level</div> <div class="profile-label">Mandant Level</div>
<div class="profile-value">{{ mandant_level_label }}</div> <div class="profile-value">{{ mandant_level_label }}</div>
<div class="profile-label">User Level</div>
<div class="profile-value">{{ user_level_label }}</div>
<div class="profile-label">Gruppen</div> <div class="profile-label">Gruppen</div>
<div class="profile-value"> <div class="profile-value">
{% for g in groups %} {% for g in groups %}

View File

@ -68,7 +68,14 @@
data-status="{{ r.done_label }}" data-status="{{ r.done_label }}"
data-user="{{ r.user_name }}" data-user="{{ r.user_name }}"
> >
<td>{{ r.user_name }}</td>
<td class="user-cell">
<span class="user-level-icon level-{{ r.user_level }}"></span>
<span class="user-name">
{{ r.user_name }}
</span>
</td>
<td>{{ r.course_code }} - {{ r.course_title }}</td> <td>{{ r.course_code }} - {{ r.course_title }}</td>
<td>{{ r.done_label }}</td> <td>{{ r.done_label }}</td>
<td> <td>

View File

@ -4,75 +4,79 @@
<div class="page-header"> <div class="page-header">
<h1>Useradministration</h1> <h1>Useradministration</h1>
<p class="intro-text"> <p>Benutzer des aktuellen Mandanten.</p>
Benutzer des aktuellen Mandanten <strong>({{ mandant_name }})</strong>.
</p>
</div> </div>
<section class="admin-section"> <div class="admin-panel">
<div class="admin-panel">
<div class="admin-panel-header">
<h2>Benutzerübersicht</h2>
<p class="admin-subtitle">
Level des Mandanten: <strong>{{ mandant_level_label }}</strong>
</p>
</div>
<div class="admin-actions admin-actions-spaced">
<a href="/useradmin/mandant/new" class="btn-primary">Neuer User</a>
<a href="/useradmin/mandant/upload" class="btn-secondary">CSV-Upload</a>
</div>
<div class="status-legend">
<strong>Status:</strong>
<span>0 - deaktiviert</span>
<span>1 - aktiv</span>
<span>2 - gesperrt</span>
<span>3 - disabled</span>
</div>
<div class="table-wrap">
<table class="mandanten-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>E-Mail</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td class="col-id">{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.status_label }}</td>
<td class="col-actions">
<div class="table-actions">
<a href="/useradmin/mandant/user/{{ user.id }}" class="btn-primary btn-small">Bearbeiten</a>
{% if user.can_delete %}
<form method="post" action="/useradmin/mandant/user/{{ user.id }}/delete">
<button type="submit"
class="btn-danger btn-small"
onclick="return confirm('Benutzer wirklich löschen?')">
Löschen
</button>
</form>
{% else %}
<button type="button" class="btn-secondary btn-small" disabled>Aktueller User</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="admin-actions-top">
<a href="/useradmin/mandant/new" class="btn-primary">Neuer User</a>
<a href="/useradmin/mandant/upload" class="btn-secondary">CSV-Upload</a>
</div> </div>
</section>
<table class="admin-table">
<thead>
<tr>
<th style="width: 60px;">ID</th>
<th>Name</th>
<th>E-Mail</th>
<th style="width: 120px;">Status</th>
<th style="width: 140px;">Level</th>
<th style="width: 140px;"></th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>
{% if user.status == 1 %}
<span class="status-active">Aktiv</span>
{% else %}
<span class="status-inactive">Deaktiviert</span>
{% endif %}
</td>
<td>
<span class="level-badge level-{{ user.level }}">
{{ user.level_label }}
</span>
</td>
<td class="actions">
<a href="/useradmin/mandant/user/{{ user.id }}"
class="btn-icon btn-edit"
title="Bearbeiten">
✏️
</a>
{% if user.id != session.user_id %}
<form method="post"
action="/useradmin/mandant/user/{{ user.id }}/delete"
style="display:inline;">
<button type="submit"
class="btn-icon btn-delete"
title="Löschen"
onclick="return confirm('Benutzer wirklich löschen?')">
🗑️
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %} {% endblock %}

View File

@ -4,7 +4,9 @@
<div class="page-header"> <div class="page-header">
<h1>User bearbeiten</h1> <h1>User bearbeiten</h1>
<p class="intro-text">Benutzer im aktuellen Mandanten ändern, Gruppen zuweisen und Passwort zurücksetzen.</p> <p class="intro-text">
Benutzer im aktuellen Mandanten ändern, Gruppen zuweisen und Passwort zurücksetzen.
</p>
</div> </div>
<section class="admin-section"> <section class="admin-section">
@ -18,7 +20,7 @@
<div class="success-box">{{ success_message }}</div> <div class="success-box">{{ success_message }}</div>
{% endif %} {% endif %}
<form method="post" id="user-edit-form" novalidate class="admin-grid-form"> <form method="post" class="admin-grid-form">
<div class="form-row"> <div class="form-row">
<label>ID</label> <label>ID</label>
@ -32,7 +34,8 @@
id="email" id="email"
name="email" name="email"
value="{{ form_values.email }}" value="{{ form_values.email }}"
required> required
>
</div> </div>
<div class="form-row"> <div class="form-row">
@ -42,26 +45,42 @@
id="name" id="name"
name="name" name="name"
value="{{ form_values.name }}" value="{{ form_values.name }}"
required> required
>
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="status">Status</label> <label for="status">Status</label>
<select id="status" name="status"> <select id="status" name="status" required>
<option value="0" {% if form_values.status == '0' %}selected{% endif %}>0 - nicht aktiviert</option> <option value="1" {% if form_values.status == "1" %}selected{% endif %}>1 - OK</option>
<option value="1" {% if form_values.status == '1' %}selected{% endif %}>1 - OK</option> <option value="0" {% if form_values.status == "0" %}selected{% endif %}>0 - deaktiviert</option>
<option value="2" {% if form_values.status == '2' %}selected{% endif %}>2 - locked</option>
<option value="3" {% if form_values.status == '3' %}selected{% endif %}>3 - disabled</option>
</select> </select>
</div> </div>
<div class="form-row">
<label for="level">User Level</label>
<div>
<select id="level" name="level" required>
<option value="1" {% if form_values.level == "1" %}selected{% endif %}>1 - Gold</option>
<option value="2" {% if form_values.level == "2" %}selected{% endif %}>2 - Silber</option>
<option value="3" {% if form_values.level == "3" %}selected{% endif %}>3 - Bronze</option>
</select>
<p class="form-hint">
Maximales Level dieses Mandanten:
<strong>{{ mandant_level_label }}</strong>
</p>
</div>
</div>
<div class="form-row"> <div class="form-row">
<label for="password">Neues Passwort</label> <label for="password">Neues Passwort</label>
<input <input
type="password" type="password"
id="password" id="password"
name="password" name="password"
placeholder="leer lassen = unverändert"> placeholder="leer lassen = unverändert"
>
</div> </div>
<div class="form-row"> <div class="form-row">
@ -70,19 +89,22 @@
type="password" type="password"
id="password2" id="password2"
name="password2" name="password2"
placeholder="leer lassen = unverändert"> placeholder="leer lassen = unverändert"
>
</div> </div>
<div class="form-row form-row-full"> <div class="form-row form-row-full">
<label>Gruppen des Mandanten</label> <label>Gruppen des Mandanten</label>
<div class="checkbox-group">
<div class="checkbox-grid">
{% for gruppe in gruppen %} {% for gruppe in gruppen %}
<label class="checkbox-item"> <label class="checkbox-card">
<input <input
type="checkbox" type="checkbox"
name="group_ids" name="group_ids"
value="{{ gruppe.id }}" value="{{ gruppe.id }}"
{% if gruppe.id|string in form_values.selected_groups %}checked{% endif %}> {% if gruppe.id|string in form_values.selected_groups %}checked{% endif %}
>
<span>{{ gruppe.group_name }}</span> <span>{{ gruppe.group_name }}</span>
</label> </label>
{% endfor %} {% endfor %}
@ -90,7 +112,6 @@
</div> </div>
<div class="form-row form-row-full"> <div class="form-row form-row-full">
<div id="user-edit-error" class="error-box" style="display:none;"></div>
<div class="admin-actions"> <div class="admin-actions">
<button type="submit" class="btn-primary">Speichern</button> <button type="submit" class="btn-primary">Speichern</button>
<a href="/useradmin/mandant" class="btn-secondary">Zurück</a> <a href="/useradmin/mandant" class="btn-secondary">Zurück</a>
@ -101,57 +122,4 @@
</div> </div>
</section> </section>
<script>
document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("user-edit-form");
const errorBox = document.getElementById("user-edit-error");
if (!form) return;
form.addEventListener("submit", function (event) {
const email = document.getElementById("email").value.trim();
const name = document.getElementById("name").value.trim();
const password = document.getElementById("password").value;
const password2 = document.getElementById("password2").value;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
let errors = [];
if (!email) {
errors.push("E-Mail ist ein Pflichtfeld.");
} else if (!emailRegex.test(email)) {
errors.push("Bitte eine gültige E-Mail-Adresse eingeben.");
}
if (!name) {
errors.push("Name ist ein Pflichtfeld.");
}
if (password || password2) {
if (!password) {
errors.push("Bitte neues Passwort eingeben.");
}
if (!password2) {
errors.push("Bitte neues Passwort bestätigen.");
}
if (password && password2 && password !== password2) {
errors.push("Die beiden Passwörter stimmen nicht überein.");
}
if (password && password.length < 8) {
errors.push("Das Passwort muss mindestens 8 Zeichen lang sein.");
}
}
if (errors.length > 0) {
event.preventDefault();
errorBox.innerHTML = errors.join("<br>");
errorBox.style.display = "block";
} else {
errorBox.innerHTML = "";
errorBox.style.display = "none";
}
});
});
</script>
{% endblock %} {% endblock %}

View File

@ -3,8 +3,10 @@
{% block content %} {% block content %}
<div class="page-header"> <div class="page-header">
<h1>Neuen User anlegen</h1> <h1>Neuer Benutzer</h1>
<p class="intro-text">Benutzer im aktuellen Mandanten anlegen und Gruppen zuweisen.</p> <p class="intro-text">
Neuen Benutzer im aktuellen Mandanten anlegen.
</p>
</div> </div>
<section class="admin-section"> <section class="admin-section">
@ -14,77 +16,118 @@
<div class="error-box">{{ form_error }}</div> <div class="error-box">{{ form_error }}</div>
{% endif %} {% endif %}
<form method="post" id="user-create-form" novalidate class="admin-grid-form"> {% if success_message %}
<div class="success-box">{{ success_message }}</div>
{% endif %}
<form method="post" class="admin-grid-form">
<!-- E-Mail -->
<div class="form-row"> <div class="form-row">
<label for="email">E-Mail</label> <label for="email">E-Mail</label>
<input <input
type="email" type="email"
id="email" id="email"
name="email" name="email"
value="{{ form_values.email if form_values else '' }}" value="{{ form_values.email }}"
required> required
>
</div> </div>
<!-- Name -->
<div class="form-row"> <div class="form-row">
<label for="name">Name</label> <label for="name">Name</label>
<input <input
type="text" type="text"
id="name" id="name"
name="name" name="name"
value="{{ form_values.name if form_values else '' }}" value="{{ form_values.name }}"
required> required
>
</div> </div>
<!-- Status -->
<div class="form-row">
<label for="status">Status</label>
<select id="status" name="status" required>
<option value="1" {% if form_values.status == "1" %}selected{% endif %}>
1 - Aktiv
</option>
<option value="0" {% if form_values.status == "0" %}selected{% endif %}>
0 - Deaktiviert
</option>
</select>
</div>
<!-- Level -->
<div class="form-row">
<label for="level">User Level</label>
<div>
<select id="level" name="level" required>
<option value="1" {% if form_values.level == "1" %}selected{% endif %}>1 - Gold</option>
<option value="2" {% if form_values.level == "2" %}selected{% endif %}>2 - Silber</option>
<option value="3" {% if form_values.level == "3" %}selected{% endif %}>3 - Bronze</option>
</select>
<p class="form-hint">
Maximales Level dieses Mandanten:
<strong>{{ mandant_level_label }}</strong>
</p>
</div>
</div>
<!-- Passwort -->
<div class="form-row"> <div class="form-row">
<label for="password">Passwort</label> <label for="password">Passwort</label>
<input <input
type="password" type="password"
id="password" id="password"
name="password" name="password"
required> required
>
</div> </div>
<!-- Passwort bestätigen -->
<div class="form-row"> <div class="form-row">
<label for="password2">Passwort bestätigen</label> <label for="password2">Passwort bestätigen</label>
<input <input
type="password" type="password"
id="password2" id="password2"
name="password2" name="password2"
required> required
</div> >
<div class="form-row">
<label for="status">Status</label>
<select id="status" name="status">
<option value="0" {% if form_values and form_values.status == '0' %}selected{% endif %}>0 - nicht aktiviert</option>
<option value="1" {% if not form_values or form_values.status == '1' %}selected{% endif %}>1 - OK</option>
<option value="2" {% if form_values and form_values.status == '2' %}selected{% endif %}>2 - locked</option>
<option value="3" {% if form_values and form_values.status == '3' %}selected{% endif %}>3 - disabled</option>
</select>
</div> </div>
<!-- Gruppen -->
<div class="form-row form-row-full"> <div class="form-row form-row-full">
<label>Gruppen des Mandanten</label> <label>Gruppen des Mandanten</label>
<div class="checkbox-group">
<div class="checkbox-grid">
{% for gruppe in gruppen %} {% for gruppe in gruppen %}
<label class="checkbox-item"> <label class="checkbox-card">
<input <input
type="checkbox" type="checkbox"
name="group_ids" name="group_ids"
value="{{ gruppe.id }}" value="{{ gruppe.id }}"
{% if form_values and gruppe.id|string in form_values.selected_groups %}checked{% endif %}> {% if gruppe.id|string in form_values.selected_groups %}checked{% endif %}
>
<span>{{ gruppe.group_name }}</span> <span>{{ gruppe.group_name }}</span>
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<!-- Buttons -->
<div class="form-row form-row-full"> <div class="form-row form-row-full">
<div id="user-create-error" class="error-box" style="display:none;"></div>
<div class="admin-actions"> <div class="admin-actions">
<button type="submit" class="btn-primary">User anlegen</button> <button type="submit" class="btn-primary">
<a href="/useradmin/mandant" class="btn-secondary">Zurück</a> Benutzer anlegen
</button>
<a href="/useradmin/mandant" class="btn-secondary">
Zurück
</a>
</div> </div>
</div> </div>
@ -92,58 +135,4 @@
</div> </div>
</section> </section>
<script>
document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("user-create-form");
const errorBox = document.getElementById("user-create-error");
if (!form) return;
form.addEventListener("submit", function (event) {
const email = document.getElementById("email").value.trim();
const name = document.getElementById("name").value.trim();
const password = document.getElementById("password").value;
const password2 = document.getElementById("password2").value;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
let errors = [];
if (!email) {
errors.push("E-Mail ist ein Pflichtfeld.");
} else if (!emailRegex.test(email)) {
errors.push("Bitte eine gültige E-Mail-Adresse eingeben.");
}
if (!name) {
errors.push("Name ist ein Pflichtfeld.");
}
if (!password) {
errors.push("Passwort ist ein Pflichtfeld.");
}
if (!password2) {
errors.push("Bitte Passwort bestätigen.");
}
if (password && password2 && password !== password2) {
errors.push("Die beiden Passwörter stimmen nicht überein.");
}
if (password && password.length < 8) {
errors.push("Das Passwort muss mindestens 8 Zeichen lang sein.");
}
if (errors.length > 0) {
event.preventDefault();
errorBox.innerHTML = errors.join("<br>");
errorBox.style.display = "block";
} else {
errorBox.innerHTML = "";
errorBox.style.display = "none";
}
});
});
</script>
{% endblock %} {% endblock %}

View File

@ -54,6 +54,7 @@ Erika Musterfrau;erika@example.com;0;Passwort999</pre>
<li>Passwort mindestens <strong>8 Zeichen</strong></li> <li>Passwort mindestens <strong>8 Zeichen</strong></li>
<li>E-Mail muss eindeutig und gültig sein</li> <li>E-Mail muss eindeutig und gültig sein</li>
<li>Trennzeichen ist <strong>Semikolon</strong></li> <li>Trennzeichen ist <strong>Semikolon</strong></li>
<li>Neue Benutzer erhalten automatisch das Level des aktuellen Mandanten: <strong>{{ mandant_level_label }}</strong></li>
</ul> </ul>
</div> </div>

View File

@ -1650,4 +1650,179 @@ button {
border-radius: 10px; border-radius: 10px;
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
}
.form-hint {
margin-top: 8px;
font-size: 14px;
color: #526172;
}
.checkbox-grid {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 8px;
}
.checkbox-card {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border: 1px solid #dce3ea;
border-radius: 14px;
background: #f8fbff;
cursor: pointer;
font-weight: 600;
color: #1e3a5f;
}
.checkbox-card input[type="checkbox"] {
width: 18px;
height: 18px;
}
.admin-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.admin-table th {
text-align: left;
padding: 10px;
background: #f1f5f9;
}
.admin-table td {
padding: 10px;
border-bottom: 1px solid #e2e8f0;
}
.status-active {
color: #15803d;
font-weight: 600;
}
.status-inactive {
color: #b91c1c;
font-weight: 600;
}
/* Level Badges */
.level-badge {
padding: 6px 10px;
border-radius: 10px;
font-weight: 600;
font-size: 13px;
}
.level-1 {
background: #dcfce7;
color: #166534;
}
.level-2 {
background: #fef9c3;
color: #854d0e;
}
.level-3 {
background: #e0f2fe;
color: #075985;
}
.actions {
display: flex;
gap: 8px;
align-items: center;
}
/* Basis Button */
.btn-icon {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
border: 1px solid transparent;
font-size: 15px;
cursor: pointer;
transition: all 0.2s ease;
}
/* EDIT */
.btn-edit {
background: #e0f2fe;
color: #075985;
}
.btn-edit:hover {
background: #bae6fd;
transform: translateY(-1px);
}
/* DELETE */
.btn-delete {
background: #fee2e2;
color: #991b1b;
border: none;
}
.btn-delete:hover {
background: #fecaca;
transform: translateY(-1px);
}
.user-cell {
display: flex;
align-items: center;
gap: 10px;
}
/* kleines Level-Icon */
.user-level-icon {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 0 2px rgba(255,255,255,0.6);
}
/* Farben passend zu deinen Badges */
/* 🟡 GOLD */
.level-1 {
background: linear-gradient(145deg, #ffd700, #e6b800);
border: 1px solid #c9a200;
}
/* ⚪ SILBER */
.level-2 {
background: linear-gradient(145deg, #d1d5db, #9ca3af);
border: 1px solid #6b7280;
}
/* 🟤 BRONZE */
.level-3 {
background: linear-gradient(145deg, #cd7f32, #a65a1f);
border: 1px solid #7c3e0a;
} }