Skip to content

Commit eb88620

Browse files
committed
feat(discussion): Implement discussion moderation features including user bans
1 parent b1f01ed commit eb88620

27 files changed

+3794
-35
lines changed

lms/djangoapps/discussion/admin.py

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
"""
2+
Django Admin configuration for discussion moderation models.
3+
4+
Following edX best practices:
5+
- Read-only for most users (view-only audit logs)
6+
- Write access restricted to superusers
7+
- Staff can view but not modify
8+
"""
9+
10+
from django.contrib import admin
11+
from django.utils.html import format_html
12+
from django.utils.translation import gettext_lazy as _
13+
14+
from lms.djangoapps.discussion.models import (
15+
DiscussionBan,
16+
DiscussionBanException,
17+
DiscussionModerationLog,
18+
)
19+
20+
21+
class ReadOnlyForNonSuperuserMixin:
22+
"""
23+
Mixin to make admin read-only for non-superusers.
24+
25+
Superusers can add/change/delete, but regular staff can only view.
26+
This is useful for audit/compliance access while preventing accidental changes.
27+
"""
28+
29+
def has_add_permission(self, request):
30+
"""Only superusers can add new records."""
31+
if request.user.is_superuser:
32+
return super().has_add_permission(request)
33+
return False
34+
35+
def has_change_permission(self, request, obj=None):
36+
"""Only superusers can modify records. Staff can view."""
37+
if request.user.is_superuser:
38+
return super().has_change_permission(request, obj)
39+
# Staff users can view (needed for list view) but fields will be readonly
40+
return request.user.is_staff
41+
42+
def has_delete_permission(self, request, obj=None):
43+
"""Only superusers can delete records."""
44+
if request.user.is_superuser:
45+
return super().has_delete_permission(request, obj)
46+
return False
47+
48+
def get_readonly_fields(self, request, obj=None):
49+
"""Make all fields readonly for non-superusers."""
50+
if not request.user.is_superuser:
51+
# Return all fields as readonly for staff (non-superuser)
52+
return [field.name for field in self.model._meta.fields]
53+
return super().get_readonly_fields(request, obj)
54+
55+
56+
@admin.register(DiscussionBan)
57+
class DiscussionBanAdmin(ReadOnlyForNonSuperuserMixin, admin.ModelAdmin):
58+
"""
59+
Admin interface for Discussion Bans.
60+
61+
Permissions:
62+
- Superusers: Full access (view, add, change, delete)
63+
- Staff: View-only (for audit/support purposes)
64+
- Others: No access
65+
"""
66+
67+
list_display = [
68+
'id',
69+
'user_link',
70+
'scope',
71+
'course_or_org',
72+
'is_active',
73+
'banned_at',
74+
'banned_by_link',
75+
'reason_preview',
76+
]
77+
78+
list_filter = [
79+
'scope',
80+
'is_active',
81+
'banned_at',
82+
]
83+
84+
search_fields = [
85+
'user__username',
86+
'user__email',
87+
'course_id',
88+
'org_key',
89+
'reason',
90+
'banned_by__username',
91+
]
92+
93+
readonly_fields = [
94+
'banned_at',
95+
'unbanned_at',
96+
'created',
97+
'modified',
98+
]
99+
100+
fieldsets = (
101+
(_('Ban Information'), {
102+
'fields': (
103+
'user',
104+
'scope',
105+
'course_id',
106+
'org_key',
107+
'is_active',
108+
)
109+
}),
110+
(_('Moderation Details'), {
111+
'fields': (
112+
'banned_by',
113+
'reason',
114+
'banned_at',
115+
'unbanned_by',
116+
'unbanned_at',
117+
)
118+
}),
119+
(_('Timestamps'), {
120+
'fields': (
121+
'created',
122+
'modified',
123+
),
124+
'classes': ('collapse',),
125+
}),
126+
)
127+
128+
date_hierarchy = 'banned_at'
129+
130+
def user_link(self, obj):
131+
"""Display user with link to user admin."""
132+
if obj.user:
133+
from django.urls import reverse
134+
url = reverse('admin:auth_user_change', args=[obj.user.id])
135+
return format_html('<a href="{}">{}</a>', url, obj.user.username)
136+
return '-'
137+
user_link.short_description = _('User')
138+
139+
def banned_by_link(self, obj):
140+
"""Display moderator with link to user admin."""
141+
if obj.banned_by:
142+
from django.urls import reverse
143+
url = reverse('admin:auth_user_change', args=[obj.banned_by.id])
144+
return format_html('<a href="{}">{}</a>', url, obj.banned_by.username)
145+
return '-'
146+
banned_by_link.short_description = _('Banned By')
147+
148+
def course_or_org(self, obj):
149+
"""Display either course_id or organization based on scope."""
150+
if obj.scope == 'course':
151+
return obj.course_id or '-'
152+
else:
153+
return obj.org_key or '-'
154+
course_or_org.short_description = _('Course/Org')
155+
156+
def reason_preview(self, obj):
157+
"""Display truncated reason."""
158+
if obj.reason:
159+
return obj.reason[:100] + '...' if len(obj.reason) > 100 else obj.reason
160+
return '-'
161+
reason_preview.short_description = _('Reason')
162+
163+
164+
@admin.register(DiscussionBanException)
165+
class DiscussionBanExceptionAdmin(ReadOnlyForNonSuperuserMixin, admin.ModelAdmin):
166+
"""
167+
Admin interface for Ban Exceptions.
168+
169+
Allows viewing course-specific exceptions to organization-level bans.
170+
"""
171+
172+
list_display = [
173+
'id',
174+
'ban_link',
175+
'course_id',
176+
'unbanned_by_link',
177+
'created',
178+
]
179+
180+
list_filter = [
181+
'created',
182+
]
183+
184+
search_fields = [
185+
'ban__user__username',
186+
'course_id',
187+
'unbanned_by__username',
188+
'reason',
189+
]
190+
191+
readonly_fields = [
192+
'created',
193+
'modified',
194+
]
195+
196+
fieldsets = (
197+
(_('Exception Information'), {
198+
'fields': (
199+
'ban',
200+
'course_id',
201+
'unbanned_by',
202+
'reason',
203+
)
204+
}),
205+
(_('Timestamps'), {
206+
'fields': (
207+
'created',
208+
'modified',
209+
),
210+
'classes': ('collapse',),
211+
}),
212+
)
213+
214+
date_hierarchy = 'created'
215+
216+
def ban_link(self, obj):
217+
"""Display link to parent ban."""
218+
if obj.ban:
219+
from django.urls import reverse
220+
url = reverse('admin:discussion_discussionban_change', args=[obj.ban.id])
221+
return format_html(
222+
'<a href="{}">Ban #{} - {}</a>', url, obj.ban.id, obj.ban.user.username
223+
)
224+
return '-'
225+
ban_link.short_description = _('Parent Ban')
226+
227+
def unbanned_by_link(self, obj):
228+
"""Display unbanner with link."""
229+
if obj.unbanned_by:
230+
from django.urls import reverse
231+
url = reverse('admin:auth_user_change', args=[obj.unbanned_by.id])
232+
return format_html('<a href="{}">{}</a>', url, obj.unbanned_by.username)
233+
return '-'
234+
unbanned_by_link.short_description = _('Unbanned By')
235+
236+
237+
@admin.register(DiscussionModerationLog)
238+
class DiscussionModerationLogAdmin(ReadOnlyForNonSuperuserMixin, admin.ModelAdmin):
239+
"""
240+
Admin interface for Moderation Audit Logs.
241+
242+
IMPORTANT: This is an audit log and should be READ-ONLY for all users
243+
(even superusers in production). Only use for compliance/investigation.
244+
"""
245+
246+
list_display = [
247+
'id',
248+
'action_type',
249+
'target_user_link',
250+
'moderator_link',
251+
'course_id',
252+
'scope',
253+
'created',
254+
]
255+
256+
list_filter = [
257+
'action_type',
258+
'scope',
259+
'created',
260+
]
261+
262+
search_fields = [
263+
'target_user__username',
264+
'target_user__email',
265+
'moderator__username',
266+
'course_id',
267+
'reason',
268+
]
269+
270+
readonly_fields = [
271+
'action_type',
272+
'target_user',
273+
'moderator',
274+
'course_id',
275+
'scope',
276+
'reason',
277+
'metadata',
278+
'created',
279+
]
280+
281+
fieldsets = (
282+
(_('Action Details'), {
283+
'fields': (
284+
'action_type',
285+
'target_user',
286+
'moderator',
287+
'course_id',
288+
'scope',
289+
)
290+
}),
291+
(_('Context'), {
292+
'fields': (
293+
'reason',
294+
'metadata',
295+
)
296+
}),
297+
(_('Timestamp'), {
298+
'fields': ('created',),
299+
}),
300+
)
301+
302+
date_hierarchy = 'created'
303+
304+
# Disable add/delete for audit logs - even for superusers
305+
def has_add_permission(self, request):
306+
"""Audit logs should never be manually created."""
307+
return False
308+
309+
def has_delete_permission(self, request, obj=None):
310+
"""Audit logs should never be deleted."""
311+
return False
312+
313+
def target_user_link(self, obj):
314+
"""Display target user with link."""
315+
if obj.target_user:
316+
from django.urls import reverse
317+
url = reverse('admin:auth_user_change', args=[obj.target_user.id])
318+
return format_html('<a href="{}">{}</a>', url, obj.target_user.username)
319+
return '-'
320+
target_user_link.short_description = _('Target User')
321+
322+
def moderator_link(self, obj):
323+
"""Display moderator with link."""
324+
if obj.moderator:
325+
from django.urls import reverse
326+
url = reverse('admin:auth_user_change', args=[obj.moderator.id])
327+
return format_html('<a href="{}">{}</a>', url, obj.moderator.username)
328+
return '-'
329+
moderator_link.short_description = _('Moderator')

0 commit comments

Comments
 (0)