- Recipe model with ingredients (amount/range, unit, group, note), steps, lowercase tags, author notes, source URL and private/public visibility - Central RecipeQuerySet.visible_to() used by every read path; edit only by owner, delete by owner or admin (visible recipes only) - Text-based editing: ingredient/step parser with roundtrip and scaling - Photo upload (JPEG/PNG/WebP/HEIC), re-encoded to WebP without EXIF, thumbnails, cover image, served only through permission-checked view - Search across title, description, ingredients, tags; ingredient and tag filters; "mine" and favourites views - Admin can delete all recipes of a deactivated user - Acceptance tests: private recipes unreachable for others and admins in list, search, tags, favourites, detail, edit, delete and image URLs Co-Authored-By: Claude Opus 5.5 <[email protected]>
157 lines
6.3 KiB
Python
157 lines
6.3 KiB
Python
"""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 user_delete_recipes(request, pk):
|
|
"""Delete all recipes of a deactivated user, including private ones (content is never shown)."""
|
|
from recipes.models import Recipe
|
|
from recipes.views import delete_recipe
|
|
|
|
target = get_object_or_404(User, pk=pk, is_active=False)
|
|
recipes = Recipe.objects.filter(owner=target)
|
|
if request.method == "POST":
|
|
count = 0
|
|
for recipe in recipes:
|
|
delete_recipe(recipe)
|
|
count += 1
|
|
messages.success(request, f"{count} Rezepte von {target} gelöscht.")
|
|
return redirect("accounts:admin_users")
|
|
return render(request, "accounts/admin/delete_recipes.html", {"target": target, "count": recipes.count()})
|
|
|
|
|
|
@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")
|