UserManagement CSV plus Layout
@ -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"))
|
||||
@ -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>
|
||||
|
||||
|
||||
71
app/flask-postgres/app/templates/useradmin_user_upload.html
Normal 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 %}
|
||||
BIN
app/flask-postgres/images/comic/avatar1.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
app/flask-postgres/images/comic/avatar10.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
app/flask-postgres/images/comic/avatar11.png
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
app/flask-postgres/images/comic/avatar2.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
app/flask-postgres/images/comic/avatar3.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
app/flask-postgres/images/comic/avatar4.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
app/flask-postgres/images/comic/avatar5.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
app/flask-postgres/images/comic/avatar6.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
app/flask-postgres/images/comic/avatar7.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
app/flask-postgres/images/comic/avatar8.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
app/flask-postgres/images/comic/avatar9.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
@ -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;
|
||||
}
|
||||
3
app/flask-postgres/tests/userliste-fail.csv
Normal 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
|
||||
|
3
app/flask-postgres/tests/userliste.csv
Normal 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
|
||||
|