Skip to content

Commit 92504d2

Browse files
committed
feat(discussion): Implement discussion moderation features including user bans
1 parent ae2ee01 commit 92504d2

File tree

21 files changed

+2251
-22
lines changed

21 files changed

+2251
-22
lines changed

lms/djangoapps/discussion/migrations/__init__.py

Whitespace-only changes.

lms/djangoapps/discussion/rest_api/api.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,6 +1703,11 @@ def create_thread(request, thread_data):
17031703
if not discussion_open_for_user(course, user):
17041704
raise DiscussionBlackOutException
17051705

1706+
# Check if user is banned from discussions
1707+
is_user_banned = getattr(forum_api, 'is_user_banned', None)
1708+
if is_user_banned and is_user_banned(user, course_key):
1709+
raise PermissionDenied("You are banned from posting in this course's discussions.")
1710+
17061711
notify_all_learners = thread_data.pop("notify_all_learners", False)
17071712

17081713
context = get_context(course, request)
@@ -1767,6 +1772,11 @@ def create_comment(request, comment_data):
17671772
if not discussion_open_for_user(course, request.user):
17681773
raise DiscussionBlackOutException
17691774

1775+
# Check if user is banned from discussions
1776+
is_user_banned = getattr(forum_api, 'is_user_banned', None)
1777+
if is_user_banned and is_user_banned(request.user, course.id):
1778+
raise PermissionDenied("You are banned from posting in this course's discussions.")
1779+
17701780
# if a thread is closed; no new comments could be made to it
17711781
if cc_thread["closed"]:
17721782
raise PermissionDenied
@@ -2217,6 +2227,25 @@ def get_course_discussion_user_stats(
22172227

22182228
course_stats_response = get_course_user_stats(course_key, params)
22192229

2230+
# Exclude banned users from the learners list
2231+
# Get all active bans for this course using forum API
2232+
get_banned_usernames = getattr(forum_api, 'get_banned_usernames', None)
2233+
banned_usernames = []
2234+
if get_banned_usernames is not None:
2235+
banned_usernames = get_banned_usernames(
2236+
course_id=course_key,
2237+
org_key=course_key.org
2238+
)
2239+
2240+
# Filter out banned users from the stats
2241+
if banned_usernames:
2242+
course_stats_response["user_stats"] = [
2243+
stats for stats in course_stats_response["user_stats"]
2244+
if stats.get('username') not in banned_usernames
2245+
]
2246+
# Update count to reflect filtered results
2247+
course_stats_response["count"] = len(course_stats_response["user_stats"])
2248+
22202249
if comma_separated_usernames:
22212250
updated_course_stats = add_stats_for_users_with_no_discussion_content(
22222251
course_stats_response["user_stats"],
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""
2+
Email notifications for discussion moderation actions.
3+
"""
4+
import logging
5+
6+
from django.conf import settings
7+
from django.contrib.auth import get_user_model
8+
9+
log = logging.getLogger(__name__)
10+
User = get_user_model()
11+
12+
# Try to import ACE at module level for easier testing
13+
try:
14+
from edx_ace import ace
15+
from edx_ace.recipient import Recipient
16+
from edx_ace.message import Message
17+
ACE_AVAILABLE = True
18+
except ImportError:
19+
ace = None
20+
Recipient = None
21+
Message = None
22+
ACE_AVAILABLE = False
23+
24+
25+
def send_ban_escalation_email(
26+
banned_user_id,
27+
moderator_id,
28+
course_id,
29+
scope,
30+
reason,
31+
threads_deleted,
32+
comments_deleted
33+
):
34+
"""
35+
Send email to partner-support when user is banned.
36+
37+
Uses ACE (Automated Communications Engine) for templated emails if available,
38+
otherwise falls back to Django's email system.
39+
40+
Args:
41+
banned_user_id: ID of the banned user
42+
moderator_id: ID of the moderator who applied the ban
43+
course_id: Course ID where ban was applied
44+
scope: 'course' or 'organization'
45+
reason: Reason for the ban
46+
threads_deleted: Number of threads deleted
47+
comments_deleted: Number of comments deleted
48+
"""
49+
# Check if email notifications are enabled
50+
if not getattr(settings, 'DISCUSSION_MODERATION_BAN_EMAIL_ENABLED', True):
51+
log.info(
52+
"Ban email notifications disabled by settings. "
53+
"User %s banned in course %s (scope: %s)",
54+
banned_user_id, course_id, scope
55+
)
56+
return
57+
58+
try:
59+
banned_user = User.objects.get(id=banned_user_id)
60+
moderator = User.objects.get(id=moderator_id)
61+
62+
# Get escalation email from settings
63+
escalation_email = getattr(
64+
settings,
65+
'DISCUSSION_MODERATION_ESCALATION_EMAIL',
66+
'partner-support@edx.org'
67+
)
68+
69+
# Try using ACE first (preferred method for edX)
70+
if ACE_AVAILABLE and ace is not None:
71+
message = Message(
72+
app_label='discussion',
73+
name='ban_escalation',
74+
recipient=Recipient(lms_user_id=None, email_address=escalation_email),
75+
context={
76+
'banned_username': banned_user.username,
77+
'banned_email': banned_user.email,
78+
'banned_user_id': banned_user_id,
79+
'moderator_username': moderator.username,
80+
'moderator_email': moderator.email,
81+
'moderator_id': moderator_id,
82+
'course_id': str(course_id),
83+
'scope': scope,
84+
'reason': reason or 'No reason provided',
85+
'threads_deleted': threads_deleted,
86+
'comments_deleted': comments_deleted,
87+
'total_deleted': threads_deleted + comments_deleted,
88+
}
89+
)
90+
91+
ace.send(message)
92+
log.info(
93+
"Ban escalation email sent via ACE to %s for user %s in course %s",
94+
escalation_email, banned_user.username, course_id
95+
)
96+
97+
else:
98+
# Fallback to Django's email system if ACE is not available
99+
from django.core.mail import send_mail
100+
from django.template.loader import render_to_string
101+
from django.template import TemplateDoesNotExist
102+
103+
context = {
104+
'banned_username': banned_user.username,
105+
'banned_email': banned_user.email,
106+
'banned_user_id': banned_user_id,
107+
'moderator_username': moderator.username,
108+
'moderator_email': moderator.email,
109+
'moderator_id': moderator_id,
110+
'course_id': str(course_id),
111+
'scope': scope,
112+
'reason': reason or 'No reason provided',
113+
'threads_deleted': threads_deleted,
114+
'comments_deleted': comments_deleted,
115+
'total_deleted': threads_deleted + comments_deleted,
116+
}
117+
118+
# Try to render template, fall back to plain text if template doesn't exist
119+
try:
120+
email_body = render_to_string(
121+
'discussion/ban_escalation_email.txt',
122+
context
123+
)
124+
except TemplateDoesNotExist:
125+
# Plain text fallback
126+
banned_user_info = "{} ({})".format(banned_user.username, banned_user.email)
127+
moderator_info = "{} ({})".format(moderator.username, moderator.email)
128+
email_body = """
129+
A user has been banned from discussions:
130+
131+
Banned User: {}
132+
Moderator: {}
133+
Course: {}
134+
Scope: {}
135+
Reason: {}
136+
Content Deleted: {} threads, {} comments
137+
138+
Please review this moderation action and follow up as needed.
139+
""".format(
140+
banned_user_info,
141+
moderator_info,
142+
course_id,
143+
scope,
144+
reason or 'No reason provided',
145+
threads_deleted,
146+
comments_deleted
147+
)
148+
149+
subject = f'Discussion Ban Alert: {banned_user.username} in {course_id}'
150+
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'no-reply@example.com')
151+
152+
send_mail(
153+
subject=subject,
154+
message=email_body,
155+
from_email=from_email,
156+
recipient_list=[escalation_email],
157+
fail_silently=False,
158+
)
159+
160+
log.info(
161+
"Ban escalation email sent via Django mail to %s for user %s in course %s",
162+
escalation_email, banned_user.username, course_id
163+
)
164+
165+
except User.DoesNotExist as e:
166+
log.error("Failed to send ban escalation email: User not found - %s", str(e))
167+
raise
168+
except Exception as exc:
169+
log.error("Failed to send ban escalation email: %s", str(exc), exc_info=True)
170+
raise

lms/djangoapps/discussion/rest_api/permissions.py

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -190,41 +190,92 @@ def has_permission(self, request, view):
190190

191191
def can_take_action_on_spam(user, course_id):
192192
"""
193-
Returns if the user has access to take action against forum spam posts
193+
Returns if the user has access to take action against forum spam posts.
194+
195+
Grants access to:
196+
- Global Staff (user.is_staff or GlobalStaff role)
197+
- Course Staff for the specific course
198+
- Course Instructors for the specific course
199+
- Forum Moderators for the specific course
200+
- Forum Administrators for the specific course
201+
194202
Parameters:
195203
user: User object
196204
course_id: CourseKey or string of course_id
205+
206+
Returns:
207+
bool: True if user can take action on spam, False otherwise
197208
"""
198-
if GlobalStaff().has_user(user):
209+
# Global staff have universal access
210+
if GlobalStaff().has_user(user) or user.is_staff:
199211
return True
200212

201213
if isinstance(course_id, str):
202214
course_id = CourseKey.from_string(course_id)
203-
org_id = course_id.org
204-
course_ids = CourseEnrollment.objects.filter(user=user).values_list('course_id', flat=True)
205-
course_ids = [c_id for c_id in course_ids if c_id.org == org_id]
215+
216+
# Check if user is Course Staff or Instructor for this specific course
217+
if CourseStaffRole(course_id).has_user(user):
218+
return True
219+
220+
if CourseInstructorRole(course_id).has_user(user):
221+
return True
222+
223+
# Check forum moderator/administrator roles for this specific course
206224
user_roles = set(
207225
Role.objects.filter(
208226
users=user,
209-
course_id__in=course_ids,
210-
).values_list('name', flat=True).distinct()
227+
course_id=course_id,
228+
).values_list('name', flat=True)
211229
)
212-
if bool(user_roles & {FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}):
230+
231+
if user_roles & {FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}:
213232
return True
233+
214234
return False
215235

216236

217237
class IsAllowedToBulkDelete(permissions.BasePermission):
218238
"""
219-
Permission that checks if the user is staff or an admin.
239+
Permission that checks if the user is allowed to perform bulk delete and ban operations.
240+
241+
Grants access to:
242+
- Global Staff (superusers)
243+
- Course Staff
244+
- Course Instructors
245+
- Forum Moderators
246+
- Forum Administrators
247+
248+
Denies access to:
249+
- Unauthenticated users
250+
- Regular students
251+
- Community TAs (they can moderate individual posts but not bulk delete)
220252
"""
221253

222254
def has_permission(self, request, view):
223-
"""Returns true if the user can bulk delete posts"""
255+
"""
256+
Returns True if the user can bulk delete posts and ban users.
257+
258+
For ViewSet actions, course_id may come from:
259+
1. URL kwargs (view.kwargs.get('course_id'))
260+
2. Query parameters (request.query_params.get('course_id'))
261+
3. Request body (request.data.get('course_id'))
262+
"""
224263
if not request.user.is_authenticated:
225264
return False
226265

227-
course_id = view.kwargs.get("course_id")
266+
# Try to get course_id from different sources
267+
course_id = (
268+
view.kwargs.get("course_id") or
269+
request.query_params.get("course_id") or
270+
(request.data.get("course_id") if hasattr(request, 'data') else None)
271+
)
272+
273+
# If no course_id provided, we can't check permissions yet
274+
# Let the view handle validation of required course_id
275+
if not course_id:
276+
# For safety, only allow global staff to proceed without course_id
277+
return GlobalStaff().has_user(request.user) or request.user.is_staff
278+
228279
return can_take_action_on_spam(request.user, course_id)
229280

230281

0 commit comments

Comments
 (0)