feat: add admin user management

This commit is contained in:
2026-04-13 10:10:07 +02:00
parent 9a87ef9562
commit 3c99c3683e
7 changed files with 265 additions and 27 deletions

View File

@@ -8,6 +8,7 @@ from .cli import register_cli, seed_badges
from .extensions import csrf, db, login_manager
from .routes import auth, main, scoreboard, settings, tasks
from .routes.main import load_icon_svg
from .services.bootstrap import ensure_schema_and_admins
from .services.dates import MONTH_NAMES, local_now
from .services.monthly import archive_months_missing_up_to_previous
@@ -25,6 +26,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
with app.app_context():
db.create_all()
ensure_schema_and_admins()
seed_badges()
register_cli(app)

View File

@@ -98,3 +98,16 @@ class SettingsProfileForm(FlaskForm):
return
if User.query.filter_by(email=value).first():
raise ValidationError("Diese E-Mail-Adresse wird bereits verwendet.")
class AdminUserForm(FlaskForm):
name = StringField("Name", validators=[DataRequired(), Length(min=2, max=120)])
email = StringField("E-Mail", validators=[DataRequired(), EMAIL_LIKE, Length(max=255)])
password = PasswordField("Passwort", validators=[DataRequired(), Length(min=6, max=128)])
is_admin = BooleanField("Admin-Rechte")
submit = SubmitField("Nutzer anlegen")
def validate_email(self, field) -> None:
value = field.data.lower().strip()
if User.query.filter_by(email=value).first():
raise ValidationError("Diese E-Mail-Adresse wird bereits verwendet.")

View File

@@ -22,6 +22,7 @@ class User(UserMixin, TimestampMixin, db.Model):
name = db.Column(db.String(120), nullable=False)
email = db.Column(db.String(255), nullable=False, unique=True, index=True)
password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, nullable=False, default=False)
avatar_path = db.Column(db.String(255), nullable=True)
notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True)
notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True)
@@ -164,4 +165,3 @@ class BadgeDefinition(TimestampMixin, db.Model):
threshold = db.Column(db.Integer, nullable=False, default=1)
bonus_points = db.Column(db.Integer, nullable=False, default=0)
active = db.Column(db.Boolean, nullable=False, default=True)

View File

@@ -8,14 +8,21 @@ from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from ..extensions import csrf, db
from ..forms import SettingsProfileForm
from ..models import BadgeDefinition, PushSubscription
from ..forms import AdminUserForm, SettingsProfileForm
from ..models import BadgeDefinition, MonthlyScoreSnapshot, NotificationLog, PushSubscription, TaskInstance, TaskTemplate, User
from ..services.notifications import push_enabled
bp = Blueprint("settings", __name__, url_prefix="/settings")
def _require_admin():
if not current_user.is_admin:
flash("Dieser Bereich ist nur für Admins verfügbar.", "error")
return False
return True
def _save_avatar(file_storage) -> str:
filename = secure_filename(file_storage.filename or "")
ext = Path(filename).suffix.lower() or ".png"
@@ -30,6 +37,7 @@ def _save_avatar(file_storage) -> str:
@login_required
def index():
form = SettingsProfileForm(original_email=current_user.email, obj=current_user)
admin_form = AdminUserForm(prefix="admin")
if form.validate_on_submit():
current_user.name = form.name.data.strip()
current_user.email = form.email.data.lower().strip()
@@ -48,7 +56,9 @@ def index():
return render_template(
"settings/index.html",
form=form,
admin_form=admin_form,
badges=badges,
users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(),
push_ready=push_enabled(),
vapid_public_key=current_app.config["VAPID_PUBLIC_KEY"],
has_subscription=bool(subscriptions),
@@ -58,6 +68,8 @@ def index():
@bp.route("/badges/<int:badge_id>", methods=["POST"])
@login_required
def update_badge(badge_id: int):
if not _require_admin():
return redirect(url_for("settings.index"))
badge = BadgeDefinition.query.get_or_404(badge_id)
badge.threshold = max(1, request.form.get("threshold", type=int, default=badge.threshold))
badge.bonus_points = max(0, request.form.get("bonus_points", type=int, default=badge.bonus_points))
@@ -67,6 +79,83 @@ def update_badge(badge_id: int):
return redirect(url_for("settings.index"))
@bp.route("/users", methods=["POST"])
@login_required
def create_user():
if not _require_admin():
return redirect(url_for("settings.index"))
form = AdminUserForm(prefix="admin")
if not form.validate_on_submit():
for field_errors in form.errors.values():
for error in field_errors:
flash(error, "error")
return redirect(url_for("settings.index"))
user = User(
name=form.name.data.strip(),
email=form.email.data.lower().strip(),
is_admin=form.is_admin.data,
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash(f"Nutzer „{user.name}“ wurde angelegt.", "success")
return redirect(url_for("settings.index"))
@bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"])
@login_required
def toggle_admin(user_id: int):
if not _require_admin():
return redirect(url_for("settings.index"))
user = User.query.get_or_404(user_id)
make_admin = request.form.get("make_admin") == "1"
if user.id == current_user.id and not make_admin:
flash("Du kannst dir die Admin-Rechte nicht selbst entziehen.", "error")
return redirect(url_for("settings.index"))
if not make_admin and User.query.filter_by(is_admin=True).count() <= 1:
flash("Mindestens ein Admin muss erhalten bleiben.", "error")
return redirect(url_for("settings.index"))
user.is_admin = make_admin
db.session.commit()
flash(f"Admin-Status für „{user.name}“ wurde aktualisiert.", "success")
return redirect(url_for("settings.index"))
@bp.route("/users/<int:user_id>/delete", methods=["POST"])
@login_required
def delete_user(user_id: int):
if not _require_admin():
return redirect(url_for("settings.index"))
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash("Du kannst deinen aktuell eingeloggten Account nicht löschen.", "error")
return redirect(url_for("settings.index"))
if user.is_admin and User.query.filter_by(is_admin=True).count() <= 1:
flash("Der letzte Admin kann nicht gelöscht werden.", "error")
return redirect(url_for("settings.index"))
TaskTemplate.query.filter_by(default_assigned_user_id=user.id).update({"default_assigned_user_id": None})
TaskInstance.query.filter_by(assigned_user_id=user.id).update({"assigned_user_id": None})
TaskInstance.query.filter_by(completed_by_user_id=user.id).update({"completed_by_user_id": None})
MonthlyScoreSnapshot.query.filter_by(user_id=user.id).delete()
NotificationLog.query.filter_by(user_id=user.id).delete()
PushSubscription.query.filter_by(user_id=user.id).delete()
db.session.delete(user)
db.session.commit()
flash("Nutzer wurde entfernt.", "success")
return redirect(url_for("settings.index"))
@bp.route("/push/subscribe", methods=["POST"])
@login_required
@csrf.exempt

33
app/services/bootstrap.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import os
from sqlalchemy import inspect, text
from ..extensions import db
from ..models import User
def ensure_schema_and_admins() -> None:
inspector = inspect(db.engine)
column_names = {column["name"] for column in inspector.get_columns("user")}
if "is_admin" not in column_names:
db.session.execute(text("ALTER TABLE user ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit()
admin_exists = User.query.filter_by(is_admin=True).first()
if admin_exists:
return
preferred_admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "mail@hnz.io").lower().strip()
preferred_user = User.query.filter(User.email.ilike(preferred_admin_email)).first()
if preferred_user:
preferred_user.is_admin = True
db.session.commit()
return
first_user = User.query.order_by(User.id.asc()).first()
if first_user:
first_user.is_admin = True
db.session.commit()

View File

@@ -710,6 +710,33 @@ p {
gap: 18px;
}
.admin-user-list {
display: grid;
gap: 14px;
margin-top: 22px;
}
.admin-user-card {
display: grid;
gap: 14px;
padding: 18px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(132, 152, 190, 0.22);
}
.admin-user-card__identity,
.admin-user-card__actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.admin-user-card__actions form {
margin: 0;
}
.push-box__state {
align-items: flex-start;
padding: 16px;

View File

@@ -72,29 +72,103 @@
<section class="panel">
<p class="eyebrow">Gamification</p>
<h2>Badge-Regeln pflegen</h2>
<div class="badge-settings">
{% for badge in badges %}
<form method="post" action="{{ url_for('settings.update_badge', badge_id=badge.id) }}" class="badge-setting-card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<strong>{{ badge.name }}</strong>
<p class="muted">{{ badge.description }}</p>
</div>
<div class="field field--compact">
<label>Schwelle</label>
<input type="number" name="threshold" min="1" value="{{ badge.threshold }}">
</div>
<div class="field field--compact">
<label>Bonus</label>
<input type="number" name="bonus_points" min="0" value="{{ badge.bonus_points }}">
</div>
<label class="checkbox checkbox--compact">
<input type="checkbox" name="active" {% if badge.active %}checked{% endif %}>
<span>Aktiv</span>
</label>
<button type="submit" class="button button--secondary">Badge speichern</button>
</form>
{% endfor %}
</div>
{% if current_user.is_admin %}
<div class="badge-settings">
{% for badge in badges %}
<form method="post" action="{{ url_for('settings.update_badge', badge_id=badge.id) }}" class="badge-setting-card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<strong>{{ badge.name }}</strong>
<p class="muted">{{ badge.description }}</p>
</div>
<div class="field field--compact">
<label>Schwelle</label>
<input type="number" name="threshold" min="1" value="{{ badge.threshold }}">
</div>
<div class="field field--compact">
<label>Bonus</label>
<input type="number" name="bonus_points" min="0" value="{{ badge.bonus_points }}">
</div>
<label class="checkbox checkbox--compact">
<input type="checkbox" name="active" {% if badge.active %}checked{% endif %}>
<span>Aktiv</span>
</label>
<button type="submit" class="button button--secondary">Badge speichern</button>
</form>
{% endfor %}
</div>
{% else %}
<p class="muted">Badge-Regeln können nur von einem Admin geändert werden.</p>
{% endif %}
</section>
{% if current_user.is_admin %}
<section class="panel">
<p class="eyebrow">Admin</p>
<h2>Nutzerverwaltung</h2>
<div class="badge-settings">
<form method="post" action="{{ url_for('settings.create_user') }}" class="badge-setting-card">
{{ admin_form.hidden_tag() }}
<div>
<strong>Neuen Nutzer anlegen</strong>
<p class="muted">Hier legst du weitere Personen kontrolliert an, ohne freie Registrierung.</p>
</div>
<div class="field">
{{ admin_form.name.label }}
{{ admin_form.name() }}
</div>
<div class="field">
{{ admin_form.email.label }}
{{ admin_form.email() }}
</div>
<div class="field">
{{ admin_form.password.label }}
{{ admin_form.password() }}
</div>
<label class="checkbox">
{{ admin_form.is_admin() }}
<span>Als Admin anlegen</span>
</label>
{{ admin_form.submit(class_='button button--secondary') }}
</form>
</div>
<div class="admin-user-list">
{% for user in users %}
<article class="admin-user-card">
<div class="admin-user-card__identity">
{{ avatar(user) }}
<div>
<strong>{{ user.name }}</strong>
<p class="muted">{{ user.email }}</p>
</div>
</div>
<div class="chip-row">
{% if user.is_admin %}
<span class="reward-chip">Admin</span>
{% endif %}
{% if user.id == current_user.id %}
<span class="point-pill">Du</span>
{% endif %}
</div>
<div class="admin-user-card__actions">
<form method="post" action="{{ url_for('settings.toggle_admin', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="make_admin" value="{{ 0 if user.is_admin else 1 }}">
<button type="submit" class="button button--ghost">
{% if user.is_admin %}Admin entziehen{% else %}Zum Admin machen{% endif %}
</button>
</form>
<form method="post" action="{{ url_for('settings.delete_user', user_id=user.id) }}" onsubmit="return confirm('Diesen Nutzer wirklich entfernen? Zugewiesene Aufgaben bleiben erhalten, aber ohne Person.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="button button--secondary" {% if user.id == current_user.id %}disabled{% endif %}>
Nutzer löschen
</button>
</form>
</div>
</article>
{% endfor %}
</div>
</section>
{% endif %}
{% endblock %}