feat: add admin user management
This commit is contained in:
@@ -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)
|
||||
|
||||
13
app/forms.py
13
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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
33
app/services/bootstrap.py
Normal 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()
|
||||
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user