UserManagement CSV plus Layout
@ -1,4 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@ -55,6 +57,14 @@ if not app.config.get("TESTING"):
|
|||||||
# app.logger.setLevel(logging.INFO)
|
# app.logger.setLevel(logging.INFO)
|
||||||
# app.logger.addHandler(file_handler)
|
# 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):
|
def format_level(level):
|
||||||
mapping = {
|
mapping = {
|
||||||
@ -680,108 +690,47 @@ def admin_mandanten():
|
|||||||
@user_admin_required
|
@user_admin_required
|
||||||
def useradmin_mandant():
|
def useradmin_mandant():
|
||||||
current_mandant_id = session.get("mandant_id")
|
current_mandant_id = session.get("mandant_id")
|
||||||
|
current_user_id = session.get("user_id")
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cur = conn.cursor()
|
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("""
|
cur.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
u.id,
|
u.id,
|
||||||
u.email,
|
u.email,
|
||||||
u.name,
|
u.name,
|
||||||
u.mandant_id,
|
u.status
|
||||||
u.last_login,
|
|
||||||
u.status,
|
|
||||||
m.name AS mandant_name,
|
|
||||||
m.kontakt_email AS mandant_email,
|
|
||||||
m.level AS mandant_level
|
|
||||||
FROM app_user u
|
FROM app_user u
|
||||||
JOIN mandant m ON m.id = u.mandant_id
|
|
||||||
WHERE u.mandant_id = %s
|
WHERE u.mandant_id = %s
|
||||||
ORDER BY u.name, u.email
|
ORDER BY u.name, u.email
|
||||||
""", (current_mandant_id,))
|
""", (current_mandant_id,))
|
||||||
|
|
||||||
users = fetchall_dict(cur)
|
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()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
for user in users:
|
|
||||||
user["mandant_level_label"] = format_level(user["mandant_level"])
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"useradmin_mandant.html",
|
"useradmin_mandant.html",
|
||||||
page_title="Useradministration",
|
page_title="Useradministration",
|
||||||
active_page="useradmin",
|
active_page="useradmin",
|
||||||
users=users,
|
users=users,
|
||||||
**get_current_user()
|
mandant_name=mandant_name,
|
||||||
)
|
mandant_level_label=format_level(mandant_level) if mandant_level is not None else "-",
|
||||||
|
current_user_id=current_user_id,
|
||||||
@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,
|
|
||||||
**get_current_user()
|
**get_current_user()
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1955,3 +1904,159 @@ def course_assessment(course_id):
|
|||||||
passed=passed,
|
passed=passed,
|
||||||
**get_current_user()
|
**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,17 +4,31 @@
|
|||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Useradministration</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<section class="admin-section">
|
<section class="admin-section">
|
||||||
<div class="admin-panel">
|
<div class="admin-panel">
|
||||||
<div class="admin-panel-header">
|
<div class="admin-panel-header">
|
||||||
<h2>Benutzerübersicht</h2>
|
<h2>Benutzerübersicht</h2>
|
||||||
|
<p class="admin-subtitle">
|
||||||
|
Level des Mandanten: <strong>{{ mandant_level_label }}</strong>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-actions">
|
<div class="admin-actions admin-actions-spaced">
|
||||||
<a href="/useradmin/mandant/new" class="btn-primary">Neuer User</a>
|
<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>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@ -25,9 +39,6 @@
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>E-Mail</th>
|
<th>E-Mail</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Letzter Login</th>
|
|
||||||
<th>Mandant</th>
|
|
||||||
<th>Level</th>
|
|
||||||
<th>Aktionen</th>
|
<th>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -37,13 +48,22 @@
|
|||||||
<td class="col-id">{{ user.id }}</td>
|
<td class="col-id">{{ user.id }}</td>
|
||||||
<td>{{ user.name }}</td>
|
<td>{{ user.name }}</td>
|
||||||
<td>{{ user.email }}</td>
|
<td>{{ user.email }}</td>
|
||||||
<td>{{ user.status }}</td>
|
<td>{{ user.status_label }}</td>
|
||||||
<td>{{ user.last_login or "-" }}</td>
|
|
||||||
<td>{{ user.mandant_name }}</td>
|
|
||||||
<td>{{ user.mandant_level_label }}</td>
|
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<a href="/useradmin/mandant/user/{{ user.id }}" class="btn-primary btn-small">Bearbeiten</a>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -51,6 +71,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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;
|
border-radius: 8px;
|
||||||
transition: width 0.3s ease;
|
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
|
||||||
|