Let staff delete accounts from the Users page

DELETE /api/users/{id}/ with guards: nobody deletes the account they are
signed in as (400); staff can delete regular/uploader accounts only,
while admins can also delete staff and admins (403 for staff targets
otherwise, and the last admin can never be deleted). Deleting a user
removes their follows, tokens and staged uploads — including the staged
files on disk — while library items survive and simply lose their owner
(uploaded_by is SET_NULL), as does download/match/similarity history.

The Users page gets a per-row delete button behind the shared confirm
dialog, hidden wherever the backend would refuse (own row, or a
staff/admin target when the actor is not an admin).

Verified against the dev server: staff 204 for a regular account, 400
for self, 403 for an admin; admin 204; a plain account gets 403. After
deleting a user that owned J-81 and had a staged file, the file was gone
and J-81 survived with a null owner.
This commit is contained in:
2026-09-17 23:35:56 -05:00
parent 99f617d296
commit 2df001b477
2 changed files with 82 additions and 2 deletions
+38 -2
View File
@@ -1,3 +1,5 @@
import logging
from rest_framework import mixins, status, viewsets
from rest_framework.authtoken.models import Token
from rest_framework.permissions import AllowAny, IsAuthenticated
@@ -18,6 +20,8 @@ from .serializers import (
UserUpdateSerializer,
)
logger = logging.getLogger(__name__)
class RegisterView(APIView):
permission_classes = [AllowAny]
@@ -144,12 +148,13 @@ class PreferencesView(APIView):
class UserViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""Staff user directory: roles and J-ID avatars."""
"""Staff user directory: roles, J-ID avatars, and account deletion."""
permission_classes = [IsAppStaff]
http_method_names = ["get", "patch", "head", "options"]
http_method_names = ["get", "patch", "delete", "head", "options"]
def get_queryset(self):
queryset = User.objects.annotate(uploads_count=Count("uploads")).order_by("id")
@@ -192,3 +197,34 @@ class UserViewSet(
)
partial_update = update
def destroy(self, request, *args, **kwargs):
target = self.get_object()
actor = request.user
if target.pk == actor.pk:
return Response(
{"detail": "You cannot delete the account you are signed in as."},
status=status.HTTP_400_BAD_REQUEST,
)
if not actor.is_superuser and target.is_app_staff:
return Response(
{"detail": "Only an admin can delete staff or admin accounts."},
status=status.HTTP_403_FORBIDDEN,
)
if (
target.is_superuser
and User.objects.filter(is_superuser=True).count() <= 1
):
return Response(
{"detail": "This is the last admin account and cannot be deleted."},
status=status.HTTP_400_BAD_REQUEST,
)
username = target.username
# Staged upload files live on disk; the row cascade would orphan them.
for temp in target.temp_uploads.all():
if temp.file:
temp.file.delete(save=False)
target.delete()
logger.info("Account %s deleted by %s", username, actor.username)
return Response(status=status.HTTP_204_NO_CONTENT)