Skip to content

Commit ebe6fbf

Browse files
feat: personless User deletion via admin (#9312)
* feat: admin to allow user deletion * fix: permissions + drop dangerous action * chore: minor style lint * fix: avoid limit on a queryset delete * feat: User age filter * feat: show useful fields on User admin * chore: fix lint * fix: reverse direction of age filter
1 parent beb873e commit ebe6fbf

File tree

1 file changed

+136
-0
lines changed

1 file changed

+136
-0
lines changed

ietf/ietfauth/admin.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Copyright The IETF Trust 2025, All Rights Reserved
2+
import datetime
3+
4+
from django.conf import settings
5+
from django.contrib import admin, messages
6+
from django.contrib.admin import action
7+
from django.contrib.admin.actions import delete_selected as default_delete_selected
8+
from django.contrib.auth.admin import UserAdmin
9+
from django.contrib.auth.models import User
10+
from django.utils import timezone
11+
12+
13+
# Replace default UserAdmin with our custom one
14+
admin.site.unregister(User)
15+
16+
17+
class AgeListFilter(admin.SimpleListFilter):
18+
title = "account age"
19+
parameter_name = "age"
20+
21+
def lookups(self, request, model_admin):
22+
return [
23+
("1day", "> 1 day"),
24+
("3days", "> 3 days"),
25+
("1week", "> 1 week"),
26+
("1month", "> 1 month"),
27+
("1year", "> 1 year"),
28+
]
29+
30+
def queryset(self, request, queryset):
31+
deltas = {
32+
"1day": datetime.timedelta(days=1),
33+
"3days": datetime.timedelta(days=3),
34+
"1week": datetime.timedelta(weeks=1),
35+
"1month": datetime.timedelta(days=30),
36+
"1year": datetime.timedelta(days=365),
37+
}
38+
if self.value():
39+
return queryset.filter(date_joined__lt=timezone.now()-deltas[self.value()])
40+
return queryset
41+
42+
43+
@admin.register(User)
44+
class CustomUserAdmin(UserAdmin):
45+
list_display = (
46+
"username",
47+
"person",
48+
"date_joined",
49+
"last_login",
50+
"is_staff",
51+
)
52+
list_filter = list(UserAdmin.list_filter) + [
53+
AgeListFilter,
54+
("person", admin.EmptyFieldListFilter),
55+
]
56+
actions = ["delete_selected"]
57+
58+
@action(
59+
permissions=["delete"], description="Delete personless %(verbose_name_plural)s"
60+
)
61+
def delete_selected(self, request, queryset):
62+
"""Delete selected action restricted to Users with a null Person field
63+
64+
This displaces the default delete_selected action with a safer one that will
65+
only delete personless Users. It is done this way instead of by introducing
66+
a new action so that we can simply hand off to the default action (imported
67+
as default_delete_selected()) without having to adjust its template (and maybe
68+
other things) to make it work with a different action name.
69+
"""
70+
already_confirmed = bool(request.POST.get("post"))
71+
personless_queryset = queryset.filter(person__isnull=True)
72+
original_count = queryset.count()
73+
personless_count = personless_queryset.count()
74+
if personless_count > original_count:
75+
# Refuse to act if the count increased!
76+
self.message_user(
77+
request,
78+
(
79+
"Limiting the selection to Users without a Person INCREASED the "
80+
"count from {} to {}. This should not happen and probably means a "
81+
"concurrent change to the database affected this request. Please "
82+
"try again.".format(original_count, personless_count)
83+
),
84+
level=messages.ERROR,
85+
)
86+
return None # return to changelist
87+
88+
# Display warning/info if this is showing the confirmation page
89+
if not already_confirmed:
90+
if personless_count < original_count:
91+
self.message_user(
92+
request,
93+
(
94+
"Limiting the selection to Users without a Person reduced the "
95+
"count from {} to {}. Only {} will be deleted.".format(
96+
original_count, personless_count, personless_count
97+
)
98+
),
99+
level=messages.WARNING,
100+
)
101+
else:
102+
self.message_user(
103+
request,
104+
"Confirmed that all selected Users had no Persons.",
105+
)
106+
107+
# Django limits the number of fields in a request. The delete form itself
108+
# includes a few metadata fields, so give it a little padding. The default
109+
# limit is 1000 and everything will break if it's a small number, so not
110+
# bothering to check that it's > 10.
111+
max_count = settings.DATA_UPLOAD_MAX_NUMBER_FIELDS - 10
112+
if personless_count > max_count:
113+
self.message_user(
114+
request,
115+
(
116+
f"Only {max_count} Users can be deleted at once. Will only delete "
117+
f"the first {max_count} selected Personless Users."
118+
),
119+
level=messages.WARNING,
120+
)
121+
# delete() doesn't like a queryset limited via [:max_count], so do an
122+
# equivalent filter.
123+
last_to_delete = personless_queryset.order_by("pk")[max_count]
124+
personless_queryset = personless_queryset.filter(pk__lt=last_to_delete.pk)
125+
126+
if already_confirmed and personless_count != original_count:
127+
# After confirmation, none of the above filtering should change anything.
128+
# Refuse to delete if the DB moved underneath us.
129+
self.message_user(
130+
request,
131+
"Queryset count changed, nothing deleted. Please try again.",
132+
level=messages.ERROR,
133+
)
134+
return None
135+
136+
return default_delete_selected(self, request, personless_queryset)

0 commit comments

Comments
 (0)