Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
35 changes: 34 additions & 1 deletion lms/djangoapps/discussion/rest_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@
from lms.djangoapps.courseware.courses import get_course_with_access
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
from lms.djangoapps.discussion.rate_limit import is_content_creation_rate_limited
from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE, ONLY_VERIFIED_USERS_CAN_POST
from lms.djangoapps.discussion.toggles import (
ENABLE_DISCUSSIONS_MFE,
ONLY_VERIFIED_USERS_CAN_POST,
)
from lms.djangoapps.discussion.views import is_privileged_user
from openedx.core.djangoapps.discussions.models import (
DiscussionsConfiguration,
Expand Down Expand Up @@ -102,6 +105,7 @@
has_discussion_privileges,
is_commentable_divided
)
from forum import api as forum_api
from .exceptions import CommentNotFoundError, DiscussionBlackOutException, DiscussionDisabledError, ThreadNotFoundError
from .forms import CommentActionsForm, ThreadActionsForm, UserOrdering
from .pagination import DiscussionAPIPagination
Expand Down Expand Up @@ -1495,6 +1499,11 @@ def create_thread(request, thread_data):
if not discussion_open_for_user(course, user):
raise DiscussionBlackOutException

# Check if user is banned from discussions
is_user_banned = getattr(forum_api, 'is_user_banned', None)
if is_user_banned and is_user_banned(user, course_key):
raise PermissionDenied("You are banned from posting in this course's discussions.")

notify_all_learners = thread_data.pop("notify_all_learners", False)

context = get_context(course, request)
Expand Down Expand Up @@ -1551,6 +1560,11 @@ def create_comment(request, comment_data):
if not discussion_open_for_user(course, request.user):
raise DiscussionBlackOutException

# Check if user is banned from discussions
is_user_banned = getattr(forum_api, 'is_user_banned', None)
if is_user_banned and is_user_banned(request.user, course.id):
raise PermissionDenied("You are banned from posting in this course's discussions.")

# if a thread is closed; no new comments could be made to it
if cc_thread["closed"]:
raise PermissionDenied
Expand Down Expand Up @@ -1938,6 +1952,25 @@ def get_course_discussion_user_stats(

course_stats_response = get_course_user_stats(course_key, params)

# Exclude banned users from the learners list
# Get all active bans for this course using forum API
get_banned_usernames = getattr(forum_api, 'get_banned_usernames', None)
banned_usernames = []
if get_banned_usernames is not None:
banned_usernames = get_banned_usernames(
course_id=course_key,
org_key=course_key.org
)

# Filter out banned users from the stats
if banned_usernames:
course_stats_response["user_stats"] = [
stats for stats in course_stats_response["user_stats"]
if stats.get('username') not in banned_usernames
]
# Update count to reflect filtered results
course_stats_response["count"] = len(course_stats_response["user_stats"])

if comma_separated_usernames:
updated_course_stats = add_stats_for_users_with_no_discussion_content(
course_stats_response["user_stats"],
Expand Down
170 changes: 170 additions & 0 deletions lms/djangoapps/discussion/rest_api/emails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""
Email notifications for discussion moderation actions.
"""
import logging

from django.conf import settings
from django.contrib.auth import get_user_model

log = logging.getLogger(__name__)
User = get_user_model()

# Try to import ACE at module level for easier testing
try:
from edx_ace import ace
from edx_ace.recipient import Recipient
from edx_ace.message import Message
ACE_AVAILABLE = True
except ImportError:
ace = None
Recipient = None
Message = None
ACE_AVAILABLE = False


def send_ban_escalation_email(
banned_user_id,
moderator_id,
course_id,
scope,
reason,
threads_deleted,
comments_deleted
):
"""
Send email to partner-support when user is banned.

Uses ACE (Automated Communications Engine) for templated emails if available,
otherwise falls back to Django's email system.

Args:
banned_user_id: ID of the banned user
moderator_id: ID of the moderator who applied the ban
course_id: Course ID where ban was applied
scope: 'course' or 'organization'
reason: Reason for the ban
threads_deleted: Number of threads deleted
comments_deleted: Number of comments deleted
"""
# Check if email notifications are enabled
if not getattr(settings, 'DISCUSSION_MODERATION_BAN_EMAIL_ENABLED', True):
log.info(
"Ban email notifications disabled by settings. "
"User %s banned in course %s (scope: %s)",
banned_user_id, course_id, scope
)
return

try:
banned_user = User.objects.get(id=banned_user_id)
moderator = User.objects.get(id=moderator_id)

# Get escalation email from settings
escalation_email = getattr(
settings,
'DISCUSSION_MODERATION_ESCALATION_EMAIL',
'[email protected]'
)

# Try using ACE first (preferred method for edX)
if ACE_AVAILABLE and ace is not None:
message = Message(
app_label='discussion',
name='ban_escalation',
recipient=Recipient(lms_user_id=None, email_address=escalation_email),
context={
'banned_username': banned_user.username,
'banned_email': banned_user.email,
'banned_user_id': banned_user_id,
'moderator_username': moderator.username,
'moderator_email': moderator.email,
'moderator_id': moderator_id,
'course_id': str(course_id),
'scope': scope,
'reason': reason or 'No reason provided',
'threads_deleted': threads_deleted,
'comments_deleted': comments_deleted,
'total_deleted': threads_deleted + comments_deleted,
}
)

ace.send(message)
log.info(
"Ban escalation email sent via ACE to %s for user %s in course %s",
escalation_email, banned_user.username, course_id
)

else:
# Fallback to Django's email system if ACE is not available
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.template import TemplateDoesNotExist

context = {
'banned_username': banned_user.username,
'banned_email': banned_user.email,
'banned_user_id': banned_user_id,
'moderator_username': moderator.username,
'moderator_email': moderator.email,
'moderator_id': moderator_id,
'course_id': str(course_id),
'scope': scope,
'reason': reason or 'No reason provided',
'threads_deleted': threads_deleted,
'comments_deleted': comments_deleted,
'total_deleted': threads_deleted + comments_deleted,
}

# Try to render template, fall back to plain text if template doesn't exist
try:
email_body = render_to_string(
'discussion/ban_escalation_email.txt',
context
)
except TemplateDoesNotExist:
# Plain text fallback
banned_user_info = "{} ({})".format(banned_user.username, banned_user.email)
moderator_info = "{} ({})".format(moderator.username, moderator.email)
email_body = """
A user has been banned from discussions:

Banned User: {}
Moderator: {}
Course: {}
Scope: {}
Reason: {}
Content Deleted: {} threads, {} comments

Please review this moderation action and follow up as needed.
""".format(
banned_user_info,
moderator_info,
course_id,
scope,
reason or 'No reason provided',
threads_deleted,
comments_deleted
)

subject = f'Discussion Ban Alert: {banned_user.username} in {course_id}'
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', '[email protected]')

send_mail(
subject=subject,
message=email_body,
from_email=from_email,
recipient_list=[escalation_email],
fail_silently=False,
)

log.info(
"Ban escalation email sent via Django mail to %s for user %s in course %s",
escalation_email, banned_user.username, course_id
)

except User.DoesNotExist as e:
log.error("Failed to send ban escalation email: User not found - %s", str(e))
raise
except Exception as exc:
log.error("Failed to send ban escalation email: %s", str(exc), exc_info=True)
raise
76 changes: 62 additions & 14 deletions lms/djangoapps/discussion/rest_api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from opaque_keys.edx.keys import CourseKey
from rest_framework import permissions

from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.roles import (
CourseInstructorRole,
CourseStaffRole,
Expand Down Expand Up @@ -189,42 +189,90 @@ def has_permission(self, request, view):

def can_take_action_on_spam(user, course_id):
"""
Returns if the user has access to take action against forum spam posts
Returns if the user has access to take action against forum spam posts.

Grants access to:
- Global Staff (user.is_staff or GlobalStaff role)
- Course Staff for the specific course
- Course Instructors for the specific course
- Forum Moderators for the specific course
- Forum Administrators for the specific course

Parameters:
user: User object
course_id: CourseKey or string of course_id

Returns:
bool: True if user can take action on spam, False otherwise
"""
if GlobalStaff().has_user(user):
# Global staff have universal access
if GlobalStaff().has_user(user) or user.is_staff:
return True

if isinstance(course_id, str):
course_id = CourseKey.from_string(course_id)
org_id = course_id.org
course_ids = CourseEnrollment.objects.filter(user=user).values_list('course_id', flat=True)
course_ids = [c_id for c_id in course_ids if c_id.org == org_id]

# Check if user is Course Staff or Instructor for this specific course
if CourseStaffRole(course_id).has_user(user):
return True

if CourseInstructorRole(course_id).has_user(user):
return True

# Check forum moderator/administrator roles for this specific course
user_roles = set(
Role.objects.filter(
users=user,
course_id__in=course_ids,
).values_list('name', flat=True).distinct()
course_id=course_id,
).values_list('name', flat=True)
)
if bool(user_roles & {FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}):
return True

if CourseAccessRole.objects.filter(user=user, course_id__in=course_ids, role__in=["instructor", "staff"]).exists():
if user_roles & {FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}:
return True

return False


class IsAllowedToBulkDelete(permissions.BasePermission):
"""
Permission that checks if the user is staff or an admin.
Permission that checks if the user is allowed to perform bulk delete and ban operations.

Grants access to:
- Global Staff (superusers)
- Course Staff
- Course Instructors
- Forum Moderators
- Forum Administrators

Denies access to:
- Unauthenticated users
- Regular students
- Community TAs (they can moderate individual posts but not bulk delete)
"""

def has_permission(self, request, view):
"""Returns true if the user can bulk delete posts"""
"""
Returns True if the user can bulk delete posts and ban users.

For ViewSet actions, course_id may come from:
1. URL kwargs (view.kwargs.get('course_id'))
2. Query parameters (request.query_params.get('course_id'))
3. Request body (request.data.get('course_id'))
"""
if not request.user.is_authenticated:
return False

course_id = view.kwargs.get("course_id")
# Try to get course_id from different sources
course_id = (
view.kwargs.get("course_id") or
request.query_params.get("course_id") or
(request.data.get("course_id") if hasattr(request, 'data') else None)
)

# If no course_id provided, we can't check permissions yet
# Let the view handle validation of required course_id
if not course_id:
# For safety, only allow global staff to proceed without course_id
return GlobalStaff().has_user(request.user) or request.user.is_staff

return can_take_action_on_spam(request.user, course_id)
Loading
Loading