UserManagement CSV plus Layout

This commit is contained in:
Bkolb 2026-04-10 15:24:42 +02:00
parent 989422a4a7
commit 4956fdf6ef
17 changed files with 421 additions and 94 deletions

View File

@ -1,4 +1,6 @@
import logging
import csv
import io
import os
import re
import shutil
@ -55,6 +57,14 @@ if not app.config.get("TESTING"):
# app.logger.setLevel(logging.INFO)
# app.logger.addHandler(file_handler)
def format_user_status(status):
mapping = {
0: "0 - deaktiviert",
1: "1 - aktiv",
2: "2 - gesperrt",
3: "3 - disabled",
}
return mapping.get(status, f"{status} - unbekannt")
def format_level(level):
mapping = {
@ -680,108 +690,47 @@ def admin_mandanten():
@user_admin_required
def useradmin_mandant():
current_mandant_id = session.get("mandant_id")
current_user_id = session.get("user_id")
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT name, level
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}"
mandant_level = mandant_row[1] if mandant_row else None
cur.execute("""
SELECT
u.id,
u.email,
u.name,
u.mandant_id,
u.last_login,
u.status,
m.name AS mandant_name,
m.kontakt_email AS mandant_email,
m.level AS mandant_level
u.status
FROM app_user u
JOIN mandant m ON m.id = u.mandant_id
WHERE u.mandant_id = %s
ORDER BY u.name, u.email
""", (current_mandant_id,))
users = fetchall_dict(cur)
for user in users:
user["status_label"] = format_user_status(user["status"])
user["can_delete"] = user["id"] != current_user_id
cur.close()
conn.close()
for user in users:
user["mandant_level_label"] = format_level(user["mandant_level"])
return render_template(
"useradmin_mandant.html",
page_title="Useradministration",
active_page="useradmin",
users=users,
**get_current_user()
)
@app.errorhandler(403)
def forbidden(_error):
return render_template(
"403.html",
page_title="Kein Zugriff",
active_page="",
**get_current_user()
), 403
@app.route("/pwdchange", methods=["GET", "POST"])
@login_required
def pwdchange():
error_message = ""
success_message = ""
if request.method == "POST":
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
if not current_password or not new_password or not confirm_password:
error_message = "Bitte alle Felder ausfüllen."
elif new_password != confirm_password:
error_message = "Die neuen Passwörter stimmen nicht überein."
elif len(new_password) < 8:
error_message = "Das neue Passwort muss mindestens 8 Zeichen lang sein."
else:
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT password_hash
FROM app_user
WHERE id = %s
""", (session["user_id"],))
row = cur.fetchone()
if row is None:
error_message = "Benutzer nicht gefunden."
else:
stored_hash = row[0]
if not check_password_hash(stored_hash, current_password):
error_message = "Das aktuelle Passwort ist falsch."
else:
new_hash = generate_password_hash(new_password)
cur.execute("""
UPDATE app_user
SET password_hash = %s
WHERE id = %s
""", (new_hash, session["user_id"]))
conn.commit()
success_message = "Passwort wurde erfolgreich geändert."
cur.close()
conn.close()
return render_template(
"pwdchange.html",
page_title="Passwort ändern",
active_page="profil",
error_message=error_message,
success_message=success_message,
mandant_name=mandant_name,
mandant_level_label=format_level(mandant_level) if mandant_level is not None else "-",
current_user_id=current_user_id,
**get_current_user()
)
@ -1955,3 +1904,159 @@ def course_assessment(course_id):
passed=passed,
**get_current_user()
)
@app.route("/useradmin/mandant/upload", methods=["GET", "POST"])
@user_admin_required
def useradmin_user_upload():
current_mandant_id = session.get("mandant_id")
form_error = None
success_message = None
row_errors = []
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}"
if request.method == "POST":
uploaded_file = request.files.get("csv_file")
if not uploaded_file or uploaded_file.filename == "":
form_error = "Bitte eine CSV-Datei auswählen."
else:
try:
content = uploaded_file.read().decode("utf-8-sig")
except Exception:
form_error = "Die Datei konnte nicht gelesen werden. Bitte UTF-8 CSV verwenden."
if not form_error:
reader = csv.reader(io.StringIO(content), delimiter=";")
rows = list(reader)
if not rows:
form_error = "Die Datei ist leer."
parsed_users = []
if not form_error:
email_pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
seen_emails_in_file = set()
for line_no, row in enumerate(rows, start=1):
if not row or all(not str(col).strip() for col in row):
continue
if len(row) != 4:
row_errors.append(f"Zeile {line_no}: Es müssen genau 4 Felder vorhanden sein.")
continue
name = row[0].strip()
email = row[1].strip().lower()
status = row[2].strip()
password = row[3].strip()
line_problems = []
if not name:
line_problems.append("Name fehlt")
if not email:
line_problems.append("E-Mail fehlt")
elif not re.match(email_pattern, email):
line_problems.append("E-Mail ungültig")
if not status:
line_problems.append("Status fehlt")
elif status not in ("0", "1"):
line_problems.append("Status muss 0 oder 1 sein")
if not password:
line_problems.append("Passwort fehlt")
elif ";" in password:
line_problems.append("Passwort darf kein ';' enthalten")
elif len(password) < 8:
line_problems.append("Passwort muss mindestens 8 Zeichen lang sein")
if email:
if email in seen_emails_in_file:
line_problems.append("E-Mail doppelt in Datei")
else:
seen_emails_in_file.add(email)
cur.execute("""
SELECT id
FROM app_user
WHERE lower(email) = %s
""", (email,))
existing_user = cur.fetchone()
if existing_user:
line_problems.append("E-Mail existiert bereits")
if line_problems:
row_errors.append(f"Zeile {line_no}: " + ", ".join(line_problems))
else:
parsed_users.append({
"name": name,
"email": email,
"status": int(status),
"password_hash": generate_password_hash(password),
})
if row_errors:
form_error = "Fehlerhafte Datei. Bitte prüfen Sie die markierten Zeilen."
else:
for u in parsed_users:
cur.execute("""
INSERT INTO app_user (email, name, mandant_id, password_hash, status)
VALUES (%s, %s, %s, %s, %s)
""", (
u["email"],
u["name"],
current_mandant_id,
u["password_hash"],
u["status"]
))
conn.commit()
success_message = f"{len(parsed_users)} neue Benutzer angelegt."
cur.close()
conn.close()
return render_template(
"useradmin_user_upload.html",
page_title="CSV-Upload Benutzer",
active_page="useradmin",
form_error=form_error,
success_message=success_message,
row_errors=row_errors,
mandant_name=mandant_name,
**get_current_user()
)
@app.route("/useradmin/mandant/user/<int:user_id>/delete", methods=["POST"])
@user_admin_required
def useradmin_user_delete(user_id):
current_mandant_id = session.get("mandant_id")
current_user_id = session.get("user_id")
if user_id == current_user_id:
abort(403)
conn = get_connection()
cur = conn.cursor()
cur.execute("""
DELETE FROM app_user
WHERE id = %s
AND mandant_id = %s
""", (user_id, current_mandant_id))
conn.commit()
cur.close()
conn.close()
return redirect(url_for("useradmin_mandant"))

View File

@ -4,18 +4,32 @@
<div class="page-header">
<h1>Useradministration</h1>
<p class="intro-text">Benutzer des aktuellen Mandanten.</p>
<p class="intro-text">
Benutzer des aktuellen Mandanten <strong>({{ mandant_name }})</strong>.
</p>
</div>
<section class="admin-section">
<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">
<div class="admin-actions admin-actions-spaced">
<a href="/useradmin/mandant/new" class="btn-primary">Neuer User</a>
</div>
<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">
@ -25,9 +39,6 @@
<th>Name</th>
<th>E-Mail</th>
<th>Status</th>
<th>Letzter Login</th>
<th>Mandant</th>
<th>Level</th>
<th>Aktionen</th>
</tr>
</thead>
@ -37,13 +48,22 @@
<td class="col-id">{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.status }}</td>
<td>{{ user.last_login or "-" }}</td>
<td>{{ user.mandant_name }}</td>
<td>{{ user.mandant_level_label }}</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>
@ -51,6 +71,7 @@
</tbody>
</table>
</div>
</div>
</section>

View File

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Benutzer per CSV anlegen</h1>
<p class="intro-text">
Upload für neue Benutzer im aktuellen Mandanten
<strong>({{ mandant_name }})</strong>.
</p>
</div>
<section class="admin-section">
<div class="admin-panel upload-panel">
{% if form_error %}
<div class="error-box">{{ form_error }}</div>
{% endif %}
{% if success_message %}
<div class="success-box">{{ success_message }}</div>
{% endif %}
{% if row_errors %}
<div class="error-box">
<strong>Fehlerdetails:</strong>
<ul class="error-list">
{% for err in row_errors %}
<li>{{ err }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" class="upload-form">
<div class="upload-grid">
<div class="upload-main">
<label for="csv_file" class="upload-label">CSV-Datei</label>
<input type="file" id="csv_file" name="csv_file" accept=".csv,text/csv" required>
</div>
<div class="upload-info-card">
<h2>Format</h2>
<div class="upload-code">name;email;status;passwort</div>
<h2>Beispiel</h2>
<pre class="upload-example">Max Mustermann;max@example.com;1;Geheimes123
Erika Musterfrau;erika@example.com;0;Passwort999</pre>
<h2>Hinweise</h2>
<ul class="upload-hints">
<li>Status nur <strong>0</strong> oder <strong>1</strong></li>
<li>Passwort mindestens <strong>8 Zeichen</strong></li>
<li>E-Mail muss eindeutig und gültig sein</li>
<li>Trennzeichen ist <strong>Semikolon</strong></li>
</ul>
</div>
</div>
<div class="admin-actions upload-actions">
<button type="submit" class="btn-primary">CSV prüfen und importieren</button>
<a href="/useradmin/mandant" class="btn-secondary">Zurück</a>
</div>
</form>
</div>
</section>
{% endblock %}

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

View File

@ -1091,3 +1091,127 @@ button {
border-radius: 8px;
transition: width 0.3s ease;
}
/* ===============================
CSV Upload User
=============================== */
.upload-panel {
max-width: 1100px;
}
.upload-form {
margin-top: 8px;
}
.upload-grid {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 28px;
align-items: start;
}
.upload-main {
background: #f8fbff;
border: 1px solid #dce3ea;
border-radius: 16px;
padding: 24px;
}
.upload-label {
display: block;
margin-bottom: 12px;
font-weight: 700;
color: #0d2f57;
font-size: 20px;
}
.upload-info-card {
background: #f8fbff;
border: 1px solid #dce3ea;
border-radius: 16px;
padding: 24px;
}
.upload-info-card h2 {
margin: 0 0 10px;
font-size: 22px;
color: #0d2f57;
}
.upload-info-card h2:not(:first-child) {
margin-top: 22px;
}
.upload-code {
display: inline-block;
padding: 10px 14px;
border-radius: 10px;
background: #eef4fb;
border: 1px solid #dce3ea;
color: #0d2f57;
font-family: monospace;
font-size: 18px;
}
.upload-example {
margin: 0;
padding: 14px 16px;
border-radius: 12px;
background: #0f172a;
color: #e2e8f0;
font-family: monospace;
font-size: 16px;
line-height: 1.5;
overflow-x: auto;
}
.upload-hints {
margin: 0;
padding-left: 18px;
}
.upload-hints li {
margin-bottom: 8px;
}
.upload-actions {
margin-top: 24px;
}
.error-list {
margin: 10px 0 0;
padding-left: 18px;
}
@media (max-width: 900px) {
.upload-grid {
grid-template-columns: 1fr;
}
}
.admin-actions-spaced {
margin-bottom: 22px;
}
.status-legend {
display: flex;
flex-wrap: wrap;
gap: 12px 18px;
align-items: center;
margin-bottom: 18px;
padding: 12px 14px;
background: #f8fbff;
border: 1px solid #dce3ea;
border-radius: 12px;
color: #334155;
}
.status-legend strong {
color: #0d2f57;
}
.admin-subtitle {
margin: 6px 0 0;
color: #526172;
}

View File

@ -0,0 +1,3 @@
Max Mustermann;mm@kolb.cc;0;geheim123
Susi Sonnenschein;suso@gmail;0;test
Petra Schön;ps@kolb.cc;4;passwort;ooo
1 Max Mustermann;mm kolb.cc;0;geheim123
2 Susi Sonnenschein;suso gmail;0;test
3 Petra Schön;ps kolb.cc;4;passwort;ooo

View File

@ -0,0 +1,3 @@
Max Mustermann;mm@kolb.cc;0;geheim123
Susi Sonnenschein;suso@gmail.com;0;testtest
Petra Schön;ps@kolb.cc;1;passwort
1 Max Mustermann mm@kolb.cc 0 geheim123
2 Susi Sonnenschein suso@gmail.com 0 testtest
3 Petra Schön ps@kolb.cc 1 passwort