"""User and invitation management for admins (/verwaltung/).""" import secrets from functools import wraps from django.contrib import messages from django.core.exceptions import PermissionDenied from django.db import transaction from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.views.decorators.cache import never_cache from django.views.decorators.http import require_POST from .forms import InvitationForm from .models import Invitation, PasswordResetToken, User def admin_required(view): @wraps(view) def wrapper(request, *args, **kwargs): if not request.user.is_admin: raise PermissionDenied return view(request, *args, **kwargs) return wrapper def _show_once(request, title, value, hint): """Render a secret (link or temporary password) that is not stored in plain text.""" return render(request, "accounts/admin/show_once.html", {"title": title, "value": value, "hint": hint}) @admin_required @never_cache def user_list(request): users = User.objects.order_by("-is_active", "display_name") return render(request, "accounts/admin/users.html", {"users": users}) USER_ACTIONS = {"make_admin", "make_user", "deactivate", "activate", "reset_link", "temp_password"} @admin_required @require_POST @never_cache def user_action(request, pk, action): if action not in USER_ACTIONS: raise PermissionDenied with transaction.atomic(): target = get_object_or_404(User.objects.select_for_update(), pk=pk) if action in {"make_user", "deactivate"}: if target == request.user: messages.error(request, "Du kannst dir selbst die Admin-Rechte nicht entziehen oder dich deaktivieren.") return redirect("accounts:admin_users") other_admins = User.objects.filter(role=User.Role.ADMIN, is_active=True).exclude(pk=target.pk) if target.is_admin and not other_admins.exists(): messages.error(request, "Es muss mindestens einen aktiven Admin geben.") return redirect("accounts:admin_users") if action == "make_admin": target.role = User.Role.ADMIN target.save(update_fields=["role"]) messages.success(request, f"{target} ist jetzt Admin.") elif action == "make_user": target.role = User.Role.USER target.save(update_fields=["role"]) messages.success(request, f"{target} ist jetzt Nutzer.") elif action == "deactivate": target.is_active = False target.save(update_fields=["is_active"]) PasswordResetToken.objects.usable().filter(user=target).update(revoked_at=timezone.now()) messages.success(request, f"{target} wurde deaktiviert. Die Rezepte bleiben erhalten.") elif action == "activate": target.is_active = True target.save(update_fields=["is_active"]) messages.success(request, f"{target} wurde wieder aktiviert.") elif action == "reset_link": if not target.is_active: messages.error(request, "Deaktivierte Nutzer können ihr Passwort nicht zurücksetzen.") return redirect("accounts:admin_users") _, link = PasswordResetToken.issue(target, created_by=request.user) return _show_once( request, f"Link zum Zurücksetzen für {target}", link, "Der Link ist 24 Stunden gültig und nur einmal verwendbar. Ältere Links für diesen Nutzer sind ungültig.", ) elif action == "temp_password": if not target.is_active: messages.error(request, "Deaktivierte Nutzer können sich nicht anmelden.") return redirect("accounts:admin_users") password = secrets.token_urlsafe(12) target.set_password(password) target.must_change_password = True target.save(update_fields=["password", "must_change_password"]) return _show_once( request, f"Temporäres Passwort für {target}", password, "Nach der Anmeldung muss sofort ein eigenes Passwort gesetzt werden. Bestehende Sitzungen wurden beendet.", ) return redirect("accounts:admin_users") @admin_required @never_cache def invitation_list(request): form = InvitationForm(request.POST or None) if request.method == "POST" and form.is_valid(): _, link = Invitation.issue( created_by=request.user, email=form.cleaned_data["email"], role=form.cleaned_data["role"], note=form.cleaned_data["note"], valid_days=form.cleaned_data["valid_days"], ) return _show_once( request, "Neue Einladung", link, f"Schicke diesen Link an die eingeladene Person. Er ist {form.cleaned_data['valid_days']} Tage gültig " "und nur einmal verwendbar. Aus Sicherheitsgründen wird er nicht gespeichert und später nicht mehr angezeigt.", ) invitations = Invitation.objects.select_related("created_by", "used_by")[:100] return render(request, "accounts/admin/invitations.html", {"form": form, "invitations": invitations}) @admin_required @require_POST def invitation_revoke(request, pk): invitation = get_object_or_404(Invitation, pk=pk) if invitation.status == "open": invitation.revoked_at = timezone.now() invitation.save(update_fields=["revoked_at"]) messages.success(request, "Einladung widerrufen.") return redirect("accounts:admin_invitations")