From 3c99c3683eda7598010e4faec6955f9277b3caf8 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Mon, 13 Apr 2026 10:10:07 +0200 Subject: [PATCH] feat: add admin user management --- app/__init__.py | 2 + app/forms.py | 13 ++++ app/models.py | 2 +- app/routes/settings.py | 93 ++++++++++++++++++++++- app/services/bootstrap.py | 33 ++++++++ app/static/css/style.css | 27 +++++++ app/templates/settings/index.html | 122 ++++++++++++++++++++++++------ 7 files changed, 265 insertions(+), 27 deletions(-) create mode 100644 app/services/bootstrap.py diff --git a/app/__init__.py b/app/__init__.py index cca59f1..d4772f4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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) diff --git a/app/forms.py b/app/forms.py index 5ee8cfc..cf927c9 100644 --- a/app/forms.py +++ b/app/forms.py @@ -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.") diff --git a/app/models.py b/app/models.py index 53bbd97..9f55f00 100644 --- a/app/models.py +++ b/app/models.py @@ -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) - diff --git a/app/routes/settings.py b/app/routes/settings.py index 1228059..13b1787 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -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/", 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//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//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 diff --git a/app/services/bootstrap.py b/app/services/bootstrap.py new file mode 100644 index 0000000..6506467 --- /dev/null +++ b/app/services/bootstrap.py @@ -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() diff --git a/app/static/css/style.css b/app/static/css/style.css index 6af86af..eb0a51d 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html index eee5611..1f3c946 100644 --- a/app/templates/settings/index.html +++ b/app/templates/settings/index.html @@ -72,29 +72,103 @@

Gamification

Badge-Regeln pflegen

-
- {% for badge in badges %} -
- -
- {{ badge.name }} -

{{ badge.description }}

-
-
- - -
-
- - -
- - -
- {% endfor %} -
+ {% if current_user.is_admin %} +
+ {% for badge in badges %} +
+ +
+ {{ badge.name }} +

{{ badge.description }}

+
+
+ + +
+
+ + +
+ + +
+ {% endfor %} +
+ {% else %} +

Badge-Regeln können nur von einem Admin geändert werden.

+ {% endif %}
+ + {% if current_user.is_admin %} +
+

Admin

+

Nutzerverwaltung

+
+
+ {{ admin_form.hidden_tag() }} +
+ Neuen Nutzer anlegen +

Hier legst du weitere Personen kontrolliert an, ohne freie Registrierung.

+
+
+ {{ admin_form.name.label }} + {{ admin_form.name() }} +
+
+ {{ admin_form.email.label }} + {{ admin_form.email() }} +
+
+ {{ admin_form.password.label }} + {{ admin_form.password() }} +
+ + {{ admin_form.submit(class_='button button--secondary') }} +
+
+ +
+ {% for user in users %} +
+
+ {{ avatar(user) }} +
+ {{ user.name }} +

{{ user.email }}

+
+
+
+ {% if user.is_admin %} + Admin + {% endif %} + {% if user.id == current_user.id %} + Du + {% endif %} +
+
+
+ + + +
+
+ + +
+
+
+ {% endfor %} +
+
+ {% endif %} {% endblock %}