From 27f46a0c2e98b66a922c1e3e5439db682a10df87 Mon Sep 17 00:00:00 2001 From: Maniraja Raman Date: Tue, 25 Nov 2025 09:36:52 +0000 Subject: [PATCH] feat(discussion): Implement discussion moderation features including user bans --- .../discussion/migrations/__init__.py | 0 lms/djangoapps/discussion/rest_api/api.py | 35 +- lms/djangoapps/discussion/rest_api/emails.py | 170 ++++ .../discussion/rest_api/permissions.py | 76 +- .../discussion/rest_api/serializers.py | 216 +++- lms/djangoapps/discussion/rest_api/tasks.py | 184 +++- .../rest_api/tests/test_moderation_emails.py | 238 +++++ .../tests/test_moderation_permissions.py | 213 ++++ lms/djangoapps/discussion/rest_api/urls.py | 27 + lms/djangoapps/discussion/rest_api/utils.py | 4 +- lms/djangoapps/discussion/rest_api/views.py | 931 ++++++++++++++++++ .../discussion/ban_escalation_email.txt | 28 + .../edx_ace/ban_escalation/email/body.html | 82 ++ .../edx_ace/ban_escalation/email/body.txt | 28 + .../ban_escalation/email/from_name.txt | 1 + .../edx_ace/ban_escalation/email/head.html | 1 + .../edx_ace/ban_escalation/email/subject.txt | 1 + lms/djangoapps/discussion/toggles.py | 17 + lms/envs/common.py | 34 + lms/envs/devstack.py | 4 + lms/envs/test.py | 12 + 21 files changed, 2267 insertions(+), 35 deletions(-) create mode 100644 lms/djangoapps/discussion/migrations/__init__.py create mode 100644 lms/djangoapps/discussion/rest_api/emails.py create mode 100644 lms/djangoapps/discussion/rest_api/tests/test_moderation_emails.py create mode 100644 lms/djangoapps/discussion/rest_api/tests/test_moderation_permissions.py create mode 100644 lms/djangoapps/discussion/templates/discussion/ban_escalation_email.txt create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/body.html create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/body.txt create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/from_name.txt create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/head.html create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/subject.txt diff --git a/lms/djangoapps/discussion/migrations/__init__.py b/lms/djangoapps/discussion/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 443f9527acda..2a03a42ef2f6 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -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, @@ -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 @@ -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) @@ -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 @@ -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"], diff --git a/lms/djangoapps/discussion/rest_api/emails.py b/lms/djangoapps/discussion/rest_api/emails.py new file mode 100644 index 000000000000..e4ebcc21a567 --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/emails.py @@ -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', + 'partner-support@edx.org' + ) + + # 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', 'no-reply@example.com') + + 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 diff --git a/lms/djangoapps/discussion/rest_api/permissions.py b/lms/djangoapps/discussion/rest_api/permissions.py index cfcea5b32834..31cef934ce75 100644 --- a/lms/djangoapps/discussion/rest_api/permissions.py +++ b/lms/djangoapps/discussion/rest_api/permissions.py @@ -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, @@ -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) diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index 9c2668d0b226..b796697866ca 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -76,9 +76,15 @@ def get_context(course, request, thread=None): cc_requester["course_id"] = course.id course_discussion_settings = CourseDiscussionSettings.get(course.id) is_global_staff = GlobalStaff().has_user(requester) - has_moderation_privilege = requester.id in moderator_user_ids or requester.id in ta_user_ids or is_global_staff + has_moderation_privilege = ( + requester.id in moderator_user_ids or + requester.id in ta_user_ids or + requester.id in course_staff_user_ids or + is_global_staff + ) return { "course": course, + "course_id": course.id, "request": request, "thread": thread, "discussion_division_enabled": course_discussion_division_enabled(course_discussion_settings), @@ -153,8 +159,8 @@ def filter_spam_urls_from_html(html_string): is_spam = False for domain in settings.DISCUSSION_SPAM_URLS: escaped = domain.replace(".", r"\.") - domain_pattern = rf"(\w+\.)*{escaped}(?:/\S*)*" - patterns.append(re.compile(rf"(https?://)?{domain_pattern}", re.IGNORECASE)) + domain_pattern = r"(\w+\.)*{}(?:/\S*)*".format(escaped) + patterns.append(re.compile(r"(https?:)?{}".format(domain_pattern), re.IGNORECASE)) for a_tag in soup.find_all("a", href=True): href = a_tag.get('href') @@ -944,3 +950,207 @@ class CourseMetadataSerailizer(serializers.Serializer): child=ReasonCodeSeralizer(), help_text="A list of reasons that can be specified by moderators for editing a post, response, or comment", ) + + +class BulkDeleteBanRequestSerializer(serializers.Serializer): + """ + Request payload for bulk delete + ban action. + + Accepts either user_id (for programmatic access) or username (for UI/human convenience). + Internally normalizes to user_id before processing. + """ + + user_id = serializers.IntegerField( + required=False, + help_text="User ID to ban. Either user_id or username must be provided." + ) + username = serializers.CharField( + required=False, + max_length=150, + help_text="Username to ban. Converted to user_id internally. Either user_id or username must be provided." + ) + course_id = serializers.CharField(max_length=255, required=True) + ban_user = serializers.BooleanField(default=False) + ban_scope = serializers.ChoiceField( + choices=['course', 'organization'], + default='course', + help_text="Scope of the ban: 'course' for course-level or 'organization' for organization-level" + ) + reason = serializers.CharField( + required=False, + allow_blank=True, + max_length=1000 + ) + + def validate(self, data): + """ + Validate and normalize user identification. + + - Ensures either user_id or username is provided + - Converts username to user_id if needed + - Validates ban requirements (reason, permissions) + """ + # Validate that either user_id or username is provided + if not data.get('user_id') and not data.get('username'): + raise serializers.ValidationError({ + 'user_id': "Either user_id or username must be provided." + }) + + # Normalize username to user_id for internal processing + # This allows the view/task to always work with user_id + if data.get('username') and not data.get('user_id'): + try: + user = User.objects.get(username=data['username']) + data['user_id'] = user.id + # Keep username for logging/audit purposes + data['resolved_username'] = user.username + except User.DoesNotExist as exc: + raise serializers.ValidationError({ + 'username': f"User with username '{data['username']}' does not exist." + }) from exc + elif data.get('user_id'): + # If user_id provided directly, resolve username for consistency + try: + user = User.objects.get(id=data['user_id']) + data['resolved_username'] = user.username + except User.DoesNotExist as exc: + raise serializers.ValidationError({ + 'user_id': f"User with ID {data['user_id']} does not exist." + }) from exc + + if data.get('ban_user'): + reason = data.get('reason', '').strip() + if not reason: + raise serializers.ValidationError({ + 'reason': "Reason is required when banning a user." + }) + + # Validate that organization-level bans require elevated permissions + if data.get('ban_scope') == 'organization': + request = self.context.get('request') + if request and not GlobalStaff().has_user(request.user): + raise serializers.ValidationError({ + 'ban_scope': "Organization-level bans require global staff permissions." + }) + + return data + + +class BanUserRequestSerializer(serializers.Serializer): + """ + Request payload for standalone ban action (without bulk delete). + + For direct ban from UI moderation actions. + """ + + user_id = serializers.IntegerField( + required=False, + help_text="User ID to ban. Either user_id or username must be provided." + ) + username = serializers.CharField( + required=False, + max_length=150, + help_text="Username to ban. Converted to user_id internally. Either user_id or username must be provided." + ) + course_id = serializers.CharField( + max_length=255, + required=True, + help_text="Course ID for course-level bans or org context for organization-level bans" + ) + scope = serializers.ChoiceField( + choices=['course', 'organization'], + default='course', + help_text="Scope of the ban: 'course' for course-level or 'organization' for organization-level" + ) + reason = serializers.CharField( + required=False, + allow_blank=True, + max_length=1000, + help_text="Reason for the ban (optional)" + ) + + def validate(self, data): + """ + Validate and normalize user identification. + """ + # Validate that either user_id or username is provided + if not data.get('user_id') and not data.get('username'): + raise serializers.ValidationError({ + 'user_id': "Either user_id or username must be provided." + }) + + # Normalize username to user_id if provided (view will validate existence) + if data.get('username') and not data.get('user_id'): + # Don't validate user existence here - let the view return 404 + # Just record the username for the view to resolve + data['lookup_username'] = data['username'] + + return data + + +class BannedUserSerializer(serializers.Serializer): + """Banned user information for list view.""" + + id = serializers.IntegerField(read_only=True) + username = serializers.CharField(source='user.username', read_only=True) + email = serializers.EmailField(source='user.email', read_only=True) + user_id = serializers.IntegerField(source='user.id', read_only=True) + course_id = serializers.CharField(read_only=True) + organization = serializers.CharField(source='org_key', read_only=True) + scope = serializers.CharField(read_only=True) + reason = serializers.CharField(read_only=True) + banned_at = serializers.DateTimeField(read_only=True) + banned_by_username = serializers.CharField(source='banned_by.username', read_only=True) + is_active = serializers.BooleanField(read_only=True) + threads = serializers.SerializerMethodField() + responses = serializers.SerializerMethodField() + replies = serializers.SerializerMethodField() + inactive_flags = serializers.SerializerMethodField() + active_flags = serializers.SerializerMethodField() + + def _get_user_stats(self, obj): + """Get user stats for the banned user.""" + try: + from forum import api as forum_api + course_id = str(obj.course_id) if obj.course_id else None + if not course_id: + return {} + + # Get stats for this specific user + stats_response = forum_api.get_user_course_stats( + course_id, + usernames=obj.user.username + ) + + if stats_response and stats_response.get('user_stats'): + user_stats_list = stats_response['user_stats'] + if user_stats_list and len(user_stats_list) > 0: + return user_stats_list[0] + return {} + except Exception: # pylint: disable=broad-exception-caught + return {} + + def get_threads(self, obj): + """Get thread count for the user.""" + stats = self._get_user_stats(obj) + return stats.get('threads', 0) + + def get_responses(self, obj): + """Get response count for the user.""" + stats = self._get_user_stats(obj) + return stats.get('responses', 0) + + def get_replies(self, obj): + """Get reply count for the user.""" + stats = self._get_user_stats(obj) + return stats.get('replies', 0) + + def get_inactive_flags(self, obj): + """Get inactive flag count for the user.""" + stats = self._get_user_stats(obj) + return stats.get('inactive_flags', 0) + + def get_active_flags(self, obj): + """Get active flag count for the user.""" + stats = self._get_user_stats(obj) + return stats.get('active_flags', 0) diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py index c8d983f90c33..6ccb541ebd24 100644 --- a/lms/djangoapps/discussion/rest_api/tasks.py +++ b/lms/djangoapps/discussion/rest_api/tasks.py @@ -5,6 +5,7 @@ from celery import shared_task from django.contrib.auth import get_user_model +from django.db import transaction from edx_django_utils.monitoring import set_code_owner_attribute from opaque_keys.edx.locator import CourseKey from eventtracking import tracker @@ -92,22 +93,175 @@ def send_response_endorsed_notifications(thread_id, response_id, course_key_str, notification_sender.send_response_endorsed_notification() -@shared_task +@shared_task( + bind=True, # Enable retry context and access to task instance + max_retries=3, # Retry up to 3 times on failure + default_retry_delay=60, # Wait 60 seconds between retries + autoretry_for=(OSError, TimeoutError), # Only retry on transient network/IO errors + retry_backoff=True, # Exponential backoff between retries + retry_jitter=True, # Add randomization to retry delays +) @set_code_owner_attribute -def delete_course_post_for_user(user_id, username, course_ids, event_data=None): +def delete_course_post_for_user( # pylint: disable=too-many-statements + self, + user_id, + username=None, + course_ids=None, + event_data=None, + # NEW PARAMETERS (backward compatible - all have defaults): + ban_user=False, + ban_scope='course', + moderator_id=None, + reason=None, +): """ - Deletes all posts for user in a course. + Delete all discussion posts for a user and optionally ban them. + + BACKWARD COMPATIBLE: Existing callers without ban_user parameter + will experience no change in behavior. + + Args: + self: Task instance (when bind=True) + user_id: User whose posts to delete + username: Username of the user (optional, will be fetched if not provided) + course_ids: List of course IDs (API sends single course wrapped in array) + event_data: Event tracking metadata + ban_user: If True, create ban record (NEW) + ban_scope: 'course' or 'organization' (NEW) + moderator_id: Moderator applying ban (NEW) + reason: Ban reason (NEW) """ + from django.db.utils import OperationalError, InterfaceError + event_data = event_data or {} - log.info(f"<> Deleting all posts for {username} in course {course_ids}") - threads_deleted = Thread.delete_user_threads(user_id, course_ids) - comments_deleted = Comment.delete_user_comments(user_id, course_ids) - log.info(f"<> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} " - f"in course {course_ids}") - event_data.update({ - "number_of_posts_deleted": threads_deleted, - "number_of_comments_deleted": comments_deleted, - }) - event_name = 'edx.discussion.bulk_delete_user_posts' - tracker.emit(event_name, event_data) - segment.track('None', event_name, event_data) + + try: + user = User.objects.get(id=user_id) + if username is None: + username = user.username + + log.info( + "Task %s: Deleting posts for user=%s, courses=%s, ban=%s", + self.request.id, username, course_ids, ban_user + ) + + # Phase 1: Delete content (EXISTING - unchanged) + threads_deleted = Thread.delete_user_threads(user_id, course_ids) + comments_deleted = Comment.delete_user_comments(user_id, course_ids) + + log.info( + "Task %s: Deleted %d threads and %d comments for %s in courses %s", + self.request.id, threads_deleted, comments_deleted, username, course_ids + ) + + # Phase 2: Create ban record (NEW - only if ban_user=True) + if ban_user and moderator_id: + from forum import api as forum_api + + with transaction.atomic(): + banned_user = User.objects.get(id=user_id) + moderator = User.objects.get(id=moderator_id) + + # Extract organization from course for consistency + course_key = CourseKey.from_string(course_ids[0]) + + # Use forum API to ban user + ban_result = forum_api.ban_user( + user=banned_user, + banned_by=moderator, + course_id=course_key, + scope=ban_scope, + reason=reason or 'No reason provided' + ) + + log.info( + "Task %s: Created/updated ban (id=%d) for user=%s, scope=%s", + self.request.id, ban_result.get('id', 0), username, ban_scope + ) + + # Phase 3: Audit logging (NEW) + if ban_user and moderator_id: + from forum import api as forum_api + + with transaction.atomic(): + forum_api.create_audit_log( + action_type='ban_user', + target_user=User.objects.get(id=user_id), + moderator=User.objects.get(id=moderator_id), + course_id=course_ids[0], + scope=ban_scope, + reason=reason, + metadata={ + 'threads_deleted': threads_deleted, + 'comments_deleted': comments_deleted, + 'task_id': self.request.id, + } + ) + + # Phase 4: Event tracking (ENHANCED) + event_data.update({ + "number_of_posts_deleted": threads_deleted, + "number_of_comments_deleted": comments_deleted, + 'ban_applied': ban_user, + 'ban_scope': ban_scope if ban_user else None, + }) + event_name = 'edx.discussion.bulk_delete_user_posts' + tracker.emit(event_name, event_data) + segment.track('None', event_name, event_data) + + # Phase 5: Email notification (NEW) + if ban_user and moderator_id: + # Check if email notifications are enabled before attempting to send + from django.conf import settings as django_settings + if getattr(django_settings, 'DISCUSSION_MODERATION_BAN_EMAIL_ENABLED', True): + from lms.djangoapps.discussion.rest_api.emails import send_ban_escalation_email + + try: + send_ban_escalation_email( + banned_user_id=user_id, + moderator_id=moderator_id, + course_id=course_ids[0], + scope=ban_scope, + reason=reason, + threads_deleted=threads_deleted, + comments_deleted=comments_deleted, + ) + except (OSError, ValueError, TypeError) as email_exc: + # Log but don't fail the task if email fails + # Catches: SMTP errors (OSError), template errors (ValueError), data errors (TypeError) + log.error( + "Task %s: Failed to send ban escalation email: %s", + self.request.id, str(email_exc) + ) + else: + log.info( + "Task %s: Email notifications disabled, skipping ban escalation email", + self.request.id + ) + + log.info( + "Task %s completed: user=%s, threads=%d, comments=%d, ban=%s", + self.request.id, username, threads_deleted, comments_deleted, ban_user + ) + + return { + 'threads_deleted': threads_deleted, + 'comments_deleted': comments_deleted, + 'ban_applied': ban_user, + 'task_id': self.request.id, + } + + except (OperationalError, InterfaceError, OSError, TimeoutError) as exc: + # Transient errors - let Celery retry + log.warning( + "Task %s retrying due to transient error: user_id=%s, error=%s", + self.request.id, user_id, str(exc) + ) + raise + except Exception as exc: + # Permanent errors - log and fail immediately + log.error( + "Task %s failed permanently: user_id=%s, error=%s", + self.request.id, user_id, str(exc), exc_info=True + ) + raise diff --git a/lms/djangoapps/discussion/rest_api/tests/test_moderation_emails.py b/lms/djangoapps/discussion/rest_api/tests/test_moderation_emails.py new file mode 100644 index 000000000000..9256596945e2 --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_moderation_emails.py @@ -0,0 +1,238 @@ +""" +Tests for discussion moderation email notifications. +""" +from unittest import mock +from django.test import override_settings +from django.core import mail + +from lms.djangoapps.discussion.rest_api.emails import send_ban_escalation_email +from common.djangoapps.student.tests.factories import UserFactory +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +class BanEscalationEmailTest(ModuleStoreTestCase): + """Tests for send_ban_escalation_email function.""" + + def setUp(self): + super().setUp() + self.course = CourseFactory.create(org='TestX', number='CS101', run='2024') + self.course_key = str(self.course.id) + self.banned_user = UserFactory.create(username='spammer', email='spammer@example.com') + self.moderator = UserFactory.create(username='moderator', email='mod@example.com') + + @override_settings(DISCUSSION_MODERATION_BAN_EMAIL_ENABLED=False) + def test_email_disabled_by_setting(self): + """Test that email is not sent when DISCUSSION_MODERATION_BAN_EMAIL_ENABLED is False.""" + # Clear outbox + mail.outbox = [] + + # Try to send email + send_ban_escalation_email( + banned_user_id=self.banned_user.id, + moderator_id=self.moderator.id, + course_id=self.course_key, + scope='course', + reason='Spam', + threads_deleted=5, + comments_deleted=10 + ) + + # No email should be sent + self.assertEqual(len(mail.outbox), 0) + + @override_settings( + DISCUSSION_MODERATION_BAN_EMAIL_ENABLED=True, + DISCUSSION_MODERATION_ESCALATION_EMAIL='partner-support@edx.org' + ) + @mock.patch('lms.djangoapps.discussion.rest_api.emails.ace') + def test_email_sent_via_ace(self, mock_ace_module): + """Test that email is sent via ACE when available.""" + # Create mock ACE send function + mock_send = mock.MagicMock() + mock_ace_module.send = mock_send + + send_ban_escalation_email( + banned_user_id=self.banned_user.id, + moderator_id=self.moderator.id, + course_id=self.course_key, + scope='course', + reason='Posting scam links', + threads_deleted=3, + comments_deleted=7 + ) + + # ACE send should be called + mock_send.assert_called_once() + + # Get the message argument + call_args = mock_send.call_args + message = call_args[0][0] + + # Verify message properties + self.assertEqual(message.recipient.email_address, 'partner-support@edx.org') + self.assertEqual(message.context['banned_username'], 'spammer') + self.assertEqual(message.context['moderator_username'], 'moderator') + self.assertEqual(message.context['scope'], 'course') + self.assertEqual(message.context['reason'], 'Posting scam links') + self.assertEqual(message.context['threads_deleted'], 3) + self.assertEqual(message.context['comments_deleted'], 7) + self.assertEqual(message.context['total_deleted'], 10) + + @override_settings( + DISCUSSION_MODERATION_BAN_EMAIL_ENABLED=True, + DISCUSSION_MODERATION_ESCALATION_EMAIL='custom-support@example.com', + DEFAULT_FROM_EMAIL='noreply@edx.org' + ) + @mock.patch('lms.djangoapps.discussion.rest_api.emails.ace', None) + def test_email_fallback_to_django_mail(self): + """Test that email falls back to Django mail when ACE is not available.""" + # Clear outbox + mail.outbox = [] + + # Simulate ACE not being importable by making the import fail + import sys + original_modules = sys.modules.copy() + + # Remove ace modules if present + ace_modules = [key for key in sys.modules if key.startswith('edx_ace')] + for mod in ace_modules: + sys.modules.pop(mod, None) + + try: + send_ban_escalation_email( + banned_user_id=self.banned_user.id, + moderator_id=self.moderator.id, + course_id=self.course_key, + scope='organization', + reason='Multiple violations', + threads_deleted=15, + comments_deleted=25 + ) + finally: + # Restore modules + sys.modules.update(original_modules) + + # Email should be sent via Django + self.assertEqual(len(mail.outbox), 1) + + email = mail.outbox[0] + self.assertIn('custom-support@example.com', email.to) + self.assertEqual(email.from_email, 'noreply@edx.org') + self.assertIn('spammer', email.body) + self.assertIn('moderator', email.body) + self.assertIn('Multiple violations', email.body) + self.assertIn('ORGANIZATION', email.body) + self.assertIn('15', email.body) # threads_deleted + self.assertIn('25', email.body) # comments_deleted + + @override_settings( + DISCUSSION_MODERATION_BAN_EMAIL_ENABLED=True, + DISCUSSION_MODERATION_ESCALATION_EMAIL='support@example.com' + ) + @mock.patch('lms.djangoapps.discussion.rest_api.emails.ace', None) + def test_email_handles_missing_reason(self): + """Test that email handles empty/None reason gracefully.""" + mail.outbox = [] + + # Send with empty reason (will use Django mail since ace is None) + send_ban_escalation_email( + banned_user_id=self.banned_user.id, + moderator_id=self.moderator.id, + course_id=self.course_key, + scope='course', + reason='', + threads_deleted=1, + comments_deleted=0 + ) + + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + # Should use default text + self.assertIn('No reason provided', email.body) + + @override_settings( + DISCUSSION_MODERATION_BAN_EMAIL_ENABLED=True, + DISCUSSION_MODERATION_ESCALATION_EMAIL='support@example.com' + ) + @mock.patch('lms.djangoapps.discussion.rest_api.emails.ace', None) + def test_email_with_org_level_ban(self): + """Test email for organization-level ban.""" + mail.outbox = [] + + send_ban_escalation_email( + banned_user_id=self.banned_user.id, + moderator_id=self.moderator.id, + course_id=self.course_key, + scope='organization', + reason='Org-wide spam campaign', + threads_deleted=50, + comments_deleted=100 + ) + + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertIn('ORGANIZATION', email.body) + self.assertIn('Org-wide spam campaign', email.body) + + @override_settings( + DISCUSSION_MODERATION_BAN_EMAIL_ENABLED=True, + DISCUSSION_MODERATION_ESCALATION_EMAIL='support@example.com' + ) + @mock.patch('lms.djangoapps.discussion.rest_api.emails.ace', None) + def test_email_failure_logged(self): + """Test that email failures are properly logged.""" + with mock.patch('django.core.mail.send_mail', side_effect=Exception("SMTP error")): + with self.assertLogs('lms.djangoapps.discussion.rest_api.emails', level='ERROR') as logs: + with self.assertRaises(Exception): + send_ban_escalation_email( + banned_user_id=self.banned_user.id, + moderator_id=self.moderator.id, + course_id=self.course_key, + scope='course', + reason='Test', + threads_deleted=1, + comments_deleted=1 + ) + + # Verify error was logged + self.assertTrue(any('Failed to send ban escalation email' in log for log in logs.output)) + + @override_settings(DISCUSSION_MODERATION_BAN_EMAIL_ENABLED=True) + def test_email_with_invalid_user_id(self): + """Test that email handles invalid user IDs gracefully.""" + with self.assertRaises(Exception): + send_ban_escalation_email( + banned_user_id=99999, # Non-existent user + moderator_id=self.moderator.id, + course_id=self.course_key, + scope='course', + reason='Test', + threads_deleted=0, + comments_deleted=0 + ) + + @override_settings( + DISCUSSION_MODERATION_BAN_EMAIL_ENABLED=True, + DISCUSSION_MODERATION_ESCALATION_EMAIL='test@example.com' + ) + @mock.patch('lms.djangoapps.discussion.rest_api.emails.ace', None) + def test_email_subject_format(self): + """Test that email subject is properly formatted.""" + mail.outbox = [] + + send_ban_escalation_email( + banned_user_id=self.banned_user.id, + moderator_id=self.moderator.id, + course_id=self.course_key, + scope='course', + reason='Test ban', + threads_deleted=1, + comments_deleted=1 + ) + + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + # Subject should contain username and course + self.assertIn('spammer', email.subject) + self.assertIn(self.course_key, email.subject) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_moderation_permissions.py b/lms/djangoapps/discussion/rest_api/tests/test_moderation_permissions.py new file mode 100644 index 000000000000..20a520d2d5bd --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_moderation_permissions.py @@ -0,0 +1,213 @@ +""" +Tests for discussion moderation permissions. +""" +from unittest.mock import Mock + +from rest_framework.test import APIRequestFactory + +from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole, GlobalStaff +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.discussion.rest_api.permissions import ( + IsAllowedToBulkDelete, + can_take_action_on_spam, +) +from openedx.core.djangoapps.django_comment_common.models import Role +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +class CanTakeActionOnSpamTest(ModuleStoreTestCase): + """Tests for can_take_action_on_spam permission helper function.""" + + def setUp(self): + super().setUp() + self.course = CourseFactory.create(org='TestX', number='CS101', run='2024') + self.course_key = self.course.id + + def test_global_staff_has_permission(self): + """Global staff should have permission.""" + user = UserFactory.create(is_staff=True) + self.assertTrue(can_take_action_on_spam(user, self.course_key)) + + def test_global_staff_role_has_permission(self): + """Users with GlobalStaff role should have permission.""" + user = UserFactory.create() + GlobalStaff().add_users(user) + self.assertTrue(can_take_action_on_spam(user, self.course_key)) + + def test_course_staff_has_permission(self): + """Course staff should have permission for their course.""" + user = UserFactory.create() + CourseStaffRole(self.course_key).add_users(user) + self.assertTrue(can_take_action_on_spam(user, self.course_key)) + + def test_course_instructor_has_permission(self): + """Course instructors should have permission for their course.""" + user = UserFactory.create() + CourseInstructorRole(self.course_key).add_users(user) + self.assertTrue(can_take_action_on_spam(user, self.course_key)) + + def test_forum_moderator_has_permission(self): + """Forum moderators should have permission for their course.""" + user = UserFactory.create() + role = Role.objects.create(name='Moderator', course_id=self.course_key) + role.users.add(user) + self.assertTrue(can_take_action_on_spam(user, self.course_key)) + + def test_forum_administrator_has_permission(self): + """Forum administrators should have permission for their course.""" + user = UserFactory.create() + role = Role.objects.create(name='Administrator', course_id=self.course_key) + role.users.add(user) + self.assertTrue(can_take_action_on_spam(user, self.course_key)) + + def test_regular_student_no_permission(self): + """Regular students should not have permission.""" + user = UserFactory.create() + self.assertFalse(can_take_action_on_spam(user, self.course_key)) + + def test_community_ta_no_permission(self): + """Community TAs should not have bulk delete permission.""" + user = UserFactory.create() + role = Role.objects.create(name='Community TA', course_id=self.course_key) + role.users.add(user) + self.assertFalse(can_take_action_on_spam(user, self.course_key)) + + def test_staff_different_course_no_permission(self): + """Staff from a different course should not have permission.""" + other_course = CourseFactory.create(org='OtherX', number='CS201', run='2024') + user = UserFactory.create() + CourseStaffRole(other_course.id).add_users(user) + self.assertFalse(can_take_action_on_spam(user, self.course_key)) + + def test_accepts_string_course_id(self): + """Function should accept string course_id and convert it.""" + user = UserFactory.create() + CourseStaffRole(self.course_key).add_users(user) + self.assertTrue(can_take_action_on_spam(user, str(self.course_key))) + + +class IsAllowedToBulkDeleteTest(ModuleStoreTestCase): + """Tests for IsAllowedToBulkDelete permission class.""" + + def setUp(self): + super().setUp() + self.course = CourseFactory.create(org='TestX', number='CS101', run='2024') + self.course_key = str(self.course.id) + self.factory = APIRequestFactory() + self.permission = IsAllowedToBulkDelete() + + def _create_view_with_kwargs(self, course_id=None): + """Helper to create a mock view with kwargs.""" + view = Mock() + view.kwargs = {'course_id': course_id} if course_id else {} + return view + + def _create_request_with_data(self, user, course_id=None, method='POST'): + """Helper to create a request with data.""" + if method == 'POST': + request = self.factory.post('/api/discussion/v1/moderation/bulk-delete-ban/') + else: + request = self.factory.get('/api/discussion/v1/moderation/banned-users/') + + request.user = user + request.data = {'course_id': course_id} if course_id else {} + request.query_params = {'course_id': course_id} if course_id and method == 'GET' else {} + return request + + def test_unauthenticated_user_denied(self): + """Unauthenticated users should be denied.""" + request = self.factory.post('/api/discussion/v1/moderation/bulk-delete-ban/') + request.user = Mock(is_authenticated=False) + view = self._create_view_with_kwargs() + + self.assertFalse(self.permission.has_permission(request, view)) + + def test_global_staff_with_course_id_in_data(self): + """Global staff should have permission when course_id is in request data.""" + user = UserFactory.create(is_staff=True) + request = self._create_request_with_data(user, self.course_key) + view = self._create_view_with_kwargs() + + self.assertTrue(self.permission.has_permission(request, view)) + + def test_course_staff_with_course_id_in_data(self): + """Course staff should have permission when course_id is in request data.""" + user = UserFactory.create() + CourseStaffRole(self.course.id).add_users(user) + request = self._create_request_with_data(user, self.course_key) + view = self._create_view_with_kwargs() + + self.assertTrue(self.permission.has_permission(request, view)) + + def test_course_instructor_with_course_id_in_data(self): + """Course instructors should have permission when course_id is in request data.""" + user = UserFactory.create() + CourseInstructorRole(self.course.id).add_users(user) + request = self._create_request_with_data(user, self.course_key) + view = self._create_view_with_kwargs() + + self.assertTrue(self.permission.has_permission(request, view)) + + def test_forum_moderator_with_course_id_in_data(self): + """Forum moderators should have permission when course_id is in request data.""" + user = UserFactory.create() + role = Role.objects.create(name='Moderator', course_id=self.course.id) + role.users.add(user) + request = self._create_request_with_data(user, self.course_key) + view = self._create_view_with_kwargs() + + self.assertTrue(self.permission.has_permission(request, view)) + + def test_regular_student_denied(self): + """Regular students should be denied.""" + user = UserFactory.create() + request = self._create_request_with_data(user, self.course_key) + view = self._create_view_with_kwargs() + + self.assertFalse(self.permission.has_permission(request, view)) + + def test_course_id_in_url_kwargs(self): + """Permission should work when course_id is in URL kwargs.""" + user = UserFactory.create() + CourseStaffRole(self.course.id).add_users(user) + request = self.factory.get('/api/discussion/v1/moderation/banned-users/') + request.user = user + request.data = {} + request.query_params = {} + view = self._create_view_with_kwargs(self.course_key) + + self.assertTrue(self.permission.has_permission(request, view)) + + def test_course_id_in_query_params(self): + """Permission should work when course_id is in query parameters.""" + user = UserFactory.create() + CourseStaffRole(self.course.id).add_users(user) + request = self._create_request_with_data(user, self.course_key, method='GET') + view = self._create_view_with_kwargs() + + self.assertTrue(self.permission.has_permission(request, view)) + + def test_no_course_id_only_global_staff_allowed(self): + """When no course_id provided, only global staff should be allowed.""" + # Global staff allowed + global_staff = UserFactory.create(is_staff=True) + request = self._create_request_with_data(global_staff) + view = self._create_view_with_kwargs() + self.assertTrue(self.permission.has_permission(request, view)) + + # Regular user denied + regular_user = UserFactory.create() + request = self._create_request_with_data(regular_user) + view = self._create_view_with_kwargs() + self.assertFalse(self.permission.has_permission(request, view)) + + def test_staff_different_course_denied(self): + """Staff from different course should be denied.""" + other_course = CourseFactory.create(org='OtherX', number='CS201', run='2024') + user = UserFactory.create() + CourseStaffRole(other_course.id).add_users(user) + request = self._create_request_with_data(user, self.course_key) + view = self._create_view_with_kwargs() + + self.assertFalse(self.permission.has_permission(request, view)) diff --git a/lms/djangoapps/discussion/rest_api/urls.py b/lms/djangoapps/discussion/rest_api/urls.py index f102dc41f249..e6a60948ac7f 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -18,6 +18,7 @@ CourseTopicsViewV3, CourseView, CourseViewV2, + DiscussionModerationViewSet, LearnerThreadView, ReplaceUsernamesView, RetireUserView, @@ -30,6 +31,32 @@ ROUTER.register("comments", CommentViewSet, basename="comment") urlpatterns = [ + # Moderation endpoints (defined first to avoid router conflicts) + path( + 'v1/moderation/ban-user/', + DiscussionModerationViewSet.as_view({'post': 'ban_user'}), + name='discussion-moderation-ban-user' + ), + path( + 'v1/moderation/unban-user/', + DiscussionModerationViewSet.as_view({'post': 'unban_user'}), + name='discussion-moderation-unban-user' + ), + path( + 'v1/moderation//unban/', + DiscussionModerationViewSet.as_view({'post': 'unban_user_by_id'}), + name='discussion-moderation-unban-user' + ), + path( + 'v1/moderation/bulk-delete-ban/', + DiscussionModerationViewSet.as_view({'post': 'bulk_delete_ban'}), + name='discussion-moderation-bulk-delete-ban' + ), + re_path( + fr'^v1/moderation/banned-users/{settings.COURSE_ID_PATTERN}$', + DiscussionModerationViewSet.as_view({'get': 'banned_users'}), + name='discussion-moderation-banned-users' + ), re_path( r"^v1/courses/{}/settings$".format( settings.COURSE_ID_PATTERN diff --git a/lms/djangoapps/discussion/rest_api/utils.py b/lms/djangoapps/discussion/rest_api/utils.py index a2591655adc2..7af419d0e8fb 100644 --- a/lms/djangoapps/discussion/rest_api/utils.py +++ b/lms/djangoapps/discussion/rest_api/utils.py @@ -454,8 +454,8 @@ def verify_recaptcha_token(token: str) -> bool: """ try: site_key = get_captcha_site_key_by_platform(get_platform_from_request()) - url = (f"https://recaptchaenterprise.googleapis.com/v1/projects/{settings.RECAPTCHA_PROJECT_ID}/assessments" - f"?key={settings.RECAPTCHA_PRIVATE_KEY}") + url = ("https://recaptchaenterprise.googleapis.com/v1/projects/{}/assessments" + "?key={}").format(settings.RECAPTCHA_PROJECT_ID, settings.RECAPTCHA_PRIVATE_KEY) data = { "event": { "token": token, diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index ba9818124e08..3448cb1807e0 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -31,6 +31,7 @@ from lms.djangoapps.discussion.rest_api.permissions import IsAllowedToBulkDelete from lms.djangoapps.discussion.rest_api.tasks import delete_course_post_for_user from lms.djangoapps.discussion.toggles import ONLY_VERIFIED_USERS_CAN_POST +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSION_BAN from lms.djangoapps.discussion.django_comment_client import settings as cc_settings from lms.djangoapps.discussion.django_comment_client.utils import get_group_id_for_comments_service from lms.djangoapps.instructor.access import update_forum_role @@ -1615,3 +1616,933 @@ def post(self, request, course_id): {"comment_count": comment_count, "thread_count": thread_count}, status=status.HTTP_202_ACCEPTED ) + + +class DiscussionModerationViewSet(DeveloperErrorViewMixin, ViewSet): + """ + **Use Cases** + + Perform bulk moderation actions on discussion posts and manage user bans. + + **Example Requests** + + POST /api/discussion/v1/moderation/bulk-delete-ban/ + GET /api/discussion/v1/moderation/banned-users/?course_id=course-v1:edX+DemoX+Demo + POST /api/discussion/v1/moderation/123/unban/ + """ + + authentication_classes = ( + JwtAuthentication, BearerAuthentication, SessionAuthentication, + ) + permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) + + def get_permissions(self): + """ + Return permission instances for the view. + + For unban_user, unban_user_by_id, and banned_users actions, we only need IsAuthenticated + because we check course-specific permissions inside the action method after retrieving the ban. + For ban_user, we check permissions inside the action based on scope. + """ + if self.action in ['unban_user', 'unban_user_by_id', 'banned_users', 'ban_user']: + return [permissions.IsAuthenticated()] + return super().get_permissions() + + @apidocs.schema( + body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['user_id', 'course_id'], + properties={ + 'user_id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description='ID of the user to ban (either user_id or username required)' + ), + 'username': openapi.Schema( + type=openapi.TYPE_STRING, + description='Username of the user to ban (either user_id or username required)' + ), + 'course_id': openapi.Schema( + type=openapi.TYPE_STRING, + description='Course ID (e.g., course-v1:edX+DemoX+Demo_Course)' + ), + 'scope': openapi.Schema( + type=openapi.TYPE_STRING, + description='Scope of ban: "course" or "organization"', + enum=['course', 'organization'], + default='course' + ), + 'reason': openapi.Schema( + type=openapi.TYPE_STRING, + description='Reason for the ban (optional)', + max_length=1000 + ), + }, + ), + responses={ + 201: openapi.Response( + description='User banned successfully', + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'status': openapi.Schema(type=openapi.TYPE_STRING, example='success'), + 'message': openapi.Schema(type=openapi.TYPE_STRING), + 'ban_id': openapi.Schema(type=openapi.TYPE_INTEGER), + 'user_id': openapi.Schema(type=openapi.TYPE_INTEGER), + 'username': openapi.Schema(type=openapi.TYPE_STRING), + 'scope': openapi.Schema(type=openapi.TYPE_STRING), + 'course_id': openapi.Schema(type=openapi.TYPE_STRING), + }, + ), + ), + 400: 'Invalid request data or user already banned.', + 401: 'The requester is not authenticated.', + 403: 'The requester does not have permission to ban users.', + 404: 'The specified user does not exist.', + }, + ) + def _validate_ban_request_and_get_user(self, request, serializer_data): + """ + Validate ban request and retrieve target user. + + Returns tuple of (user, course_key, ban_scope, reason) or Response object on error. + """ + user_id = serializer_data.get('user_id') + lookup_username = serializer_data.get('lookup_username') + course_id_str = serializer_data['course_id'] + ban_scope = serializer_data.get('scope', 'course') + reason = serializer_data.get('reason', '').strip() + + course_key = CourseKey.from_string(course_id_str) + + try: + if user_id: + user = User.objects.get(id=user_id) + elif lookup_username: + user = User.objects.get(username=lookup_username) + else: + return Response( + {'error': 'Either user_id or username must be provided'}, + status=status.HTTP_400_BAD_REQUEST + ) + except User.DoesNotExist: + identifier = user_id if user_id else lookup_username + return Response( + {'error': f'User {identifier} does not exist'}, + status=status.HTTP_404_NOT_FOUND + ) + + return user, course_key, ban_scope, reason + + def _check_ban_permissions(self, request, ban_scope, course_key): + """ + Check if user has permission to ban at the specified scope. + + Returns Response object on permission denied, None if permitted. + """ + from lms.djangoapps.discussion.rest_api.permissions import can_take_action_on_spam + from common.djangoapps.student.roles import GlobalStaff + + if ban_scope == 'course': + if not can_take_action_on_spam(request.user, course_key): + return Response( + {'error': 'You do not have permission to ban users in this course'}, + status=status.HTTP_403_FORBIDDEN + ) + else: + if not (GlobalStaff().has_user(request.user) or request.user.is_staff): + return Response( + {'error': 'Organization-level bans require global staff permissions'}, + status=status.HTTP_403_FORBIDDEN + ) + + if not ENABLE_DISCUSSION_BAN.is_enabled(course_key): + return Response( + {'error': 'Discussion ban feature is not enabled for this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + return None + + def _get_or_create_ban(self, user, course_key, ban_scope, reason, request): + """ + Get existing ban or create new one. + + Returns tuple of (ban, action_type, message) or Response object on error. + """ + from forum import api as forum_api + + # Check if already banned + if forum_api.is_user_banned(user, course_key, check_org=(ban_scope == 'course')): + existing_ban = forum_api.get_ban( + user=user, + course_id=course_key, + scope=ban_scope + ) + if existing_ban and existing_ban['is_active']: + return Response( + { + 'error': f'User {user.username} is already banned at {ban_scope} level', + 'ban_id': existing_ban['id'] + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Use forum API to ban user + ban_result = forum_api.ban_user( + user=user, + banned_by=request.user, + course_id=course_key, + scope=ban_scope, + reason=reason + ) + + # Determine action type and message + if ban_result.get('reactivated'): + action_type = 'ban_reactivate' + message = f'User {user.username} ban reactivated at {ban_scope} level' + else: + action_type = 'ban_user' + message = f'User {user.username} banned at {ban_scope} level' + + return ban_result, action_type, message + + def ban_user(self, request): + """ + Ban a user from discussions without deleting posts. + + **Use Cases** + + * Ban user directly from UI moderation interface + * Prevent future posts without removing existing content + * Apply preventive bans based on behavior patterns + + **Example Requests** + + POST /api/discussion/v1/moderation/ban-user/ + + Course-level ban: + ```json + { + "user_id": 12345, + "course_id": "course-v1:HarvardX+CS50+2024", + "scope": "course", + "reason": "Repeated policy violations" + } + ``` + + Organization-level ban (requires global staff): + ```json + { + "username": "spammer123", + "course_id": "course-v1:HarvardX+CS50+2024", + "scope": "organization", + "reason": "Spam across multiple courses" + } + ``` + + **Response Values** + + * status: Success status + * message: Human-readable message + * ban_id: ID of the created ban record + * user_id: Banned user's ID + * username: Banned user's username + * scope: Scope of the ban + * course_id: Course ID (if course-level ban) + + **Notes** + + * Creates ban without deleting existing posts + * Course-level bans require course moderation permissions + * Organization-level bans require global staff permissions + * Reactivates existing inactive bans if found + * All ban actions are logged in ModerationAuditLog + """ + from forum import api as forum_api + from lms.djangoapps.discussion.rest_api.serializers import BanUserRequestSerializer + + # Check if ban API is available + if not hasattr(forum_api, 'ban_user') or not hasattr(forum_api, 'is_user_banned'): + return Response( + {'error': 'Ban functionality is not available in this forum version'}, + status=status.HTTP_501_NOT_IMPLEMENTED + ) + + serializer = BanUserRequestSerializer(data=request.data, context={'request': request}) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Validate and get user + result = self._validate_ban_request_and_get_user(request, serializer.validated_data) + if isinstance(result, Response): + return result + user, course_key, ban_scope, reason = result + + # Check permissions + permission_error = self._check_ban_permissions(request, ban_scope, course_key) + if permission_error: + return permission_error + + # Get or create ban + result = self._get_or_create_ban(user, course_key, ban_scope, reason, request) + if isinstance(result, Response): + return result + ban, action_type, message = result + + # Audit log + org_key = course_key.org if ban_scope == 'organization' else None + forum_api.create_audit_log( + action_type=action_type, + target_user=user, + moderator=request.user, + course_id=str(course_key), + scope=ban_scope, + reason=reason or 'No reason provided', + metadata={ + 'ban_id': ban['id'], + 'organization': org_key + } + ) + + return Response({ + 'status': 'success', + 'message': message, + 'ban_id': ban['id'], + 'user_id': user.id, + 'username': user.username, + 'scope': ban_scope, + 'course_id': str(course_key) if ban_scope == 'course' else None, + }, status=status.HTTP_201_CREATED) + + @apidocs.schema( + body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['user_id', 'course_id', 'scope'], + properties={ + 'user_id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description='ID of the user to unban' + ), + 'username': openapi.Schema( + type=openapi.TYPE_STRING, + description='Username of the user to unban (alternative to user_id)' + ), + 'course_id': openapi.Schema( + type=openapi.TYPE_STRING, + description='Course ID (e.g., course-v1:edX+DemoX+Demo_Course)' + ), + 'scope': openapi.Schema( + type=openapi.TYPE_STRING, + description='Scope of ban to lift: "course" or "organization"', + enum=['course', 'organization'] + ), + 'reason': openapi.Schema( + type=openapi.TYPE_STRING, + description='Reason for unbanning', + max_length=1000 + ), + }, + ), + responses={ + 200: openapi.Response( + description='User unbanned successfully', + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'status': openapi.Schema(type=openapi.TYPE_STRING, example='success'), + 'message': openapi.Schema(type=openapi.TYPE_STRING), + 'ban_id': openapi.Schema(type=openapi.TYPE_INTEGER), + 'user_id': openapi.Schema(type=openapi.TYPE_INTEGER), + 'username': openapi.Schema(type=openapi.TYPE_STRING), + 'scope': openapi.Schema(type=openapi.TYPE_STRING), + }, + ), + ), + 400: 'Invalid request data or user not currently banned.', + 401: 'The requester is not authenticated.', + 403: 'The requester does not have permission to unban users.', + 404: 'The specified user or ban does not exist.', + }, + ) + def unban_user(self, request): + """ + Unban a user from discussions. + + **Use Cases** + + * Lift ban after user appeal + * Remove accidental or temporary bans + * Restore discussion access + + **Example Requests** + + POST /api/discussion/v1/moderation/unban-user/ + + Course-level unban: + ```json + { + "user_id": 12345, + "course_id": "course-v1:HarvardX+CS50+2024", + "scope": "course", + "reason": "User appealed and corrected behavior" + } + ``` + + Organization-level unban: + ```json + { + "username": "student123", + "course_id": "course-v1:HarvardX+CS50+2024", + "scope": "organization", + "reason": "Ban lifted after review" + } + ``` + + **Response Values** + + * status: Success status + * message: Human-readable message + * ban_id: ID of the unbanned record + * user_id: Unbanned user's ID + * username: Unbanned user's username + * scope: Scope of the ban that was lifted + + **Notes** + + * Deactivates the ban without deleting the record + * Course-level unbans require course moderation permissions + * Organization-level unbans require global staff permissions + * All unban actions are logged in ModerationAuditLog + """ + from forum import api as forum_api + from lms.djangoapps.discussion.rest_api.serializers import BanUserRequestSerializer + + # Check if ban API is available + if not hasattr(forum_api, 'unban_user') or not hasattr(forum_api, 'is_user_banned'): + return Response( + {'error': 'Ban functionality is not available in this forum version'}, + status=status.HTTP_501_NOT_IMPLEMENTED + ) + + serializer = BanUserRequestSerializer(data=request.data, context={'request': request}) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Validate and get user + result = self._validate_ban_request_and_get_user(request, serializer.validated_data) + if isinstance(result, Response): + return result + + user, course_key, ban_scope, reason = result + + # Permission check + permission_error = self._check_ban_permissions(request, ban_scope, course_key) + if permission_error: + return permission_error + + # Check if user has an active ban + if not forum_api.is_user_banned(user, course_key, check_org=(ban_scope == 'organization')): + return Response( + { + 'error': f'User {user.username} does not have an active ban at {ban_scope} level', + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get ban details before unbanning + ban_data = forum_api.get_ban( + user=user, + course_id=course_key, + scope=ban_scope + ) + + # Unban using forum API + unban_result = forum_api.unban_user( + user=user, + unbanned_by=request.user, + course_id=course_key, + scope=ban_scope + ) + + # Prepare ban parameters based on scope + org_key = course_key.org if ban_scope == 'organization' else None + + # Audit log + forum_api.create_audit_log( + action_type='unban_user', + target_user=user, + moderator=request.user, + course_id=str(course_key), + scope=ban_scope, + reason=reason or 'No reason provided', + metadata={ + 'ban_id': ban_data.get('id') if ban_data else None, + 'organization': org_key + }, + ) + + return Response({ + 'status': 'success', + 'message': f'User {user.username} unbanned at {ban_scope} level', + 'ban_id': ban_data.get('id') if ban_data else None, + 'user_id': user.id, + 'username': user.username, + 'scope': ban_scope, + }, status=status.HTTP_200_OK) + + @apidocs.schema( + body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['user_id', 'course_id'], + properties={ + 'user_id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description='ID of the user whose posts should be deleted' + ), + 'course_id': openapi.Schema( + type=openapi.TYPE_STRING, + description='Course ID (e.g., course-v1:edX+DemoX+Demo_Course)' + ), + 'ban_user': openapi.Schema( + type=openapi.TYPE_BOOLEAN, + description='If true, ban the user after deleting posts', + default=False + ), + 'ban_scope': openapi.Schema( + type=openapi.TYPE_STRING, + description='Scope of ban: "course" or "organization"', + enum=['course', 'organization'], + default='course' + ), + 'reason': openapi.Schema( + type=openapi.TYPE_STRING, + description='Reason for ban (required if ban_user is true)', + max_length=1000 + ), + }, + ), + responses={ + 202: openapi.Response( + description='Deletion task queued successfully', + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'status': openapi.Schema(type=openapi.TYPE_STRING, example='success'), + 'message': openapi.Schema(type=openapi.TYPE_STRING), + 'task_id': openapi.Schema(type=openapi.TYPE_STRING), + }, + ), + ), + 400: 'Invalid request data or missing required parameters.', + 401: 'The requester is not authenticated.', + 403: 'The requester does not have permission to perform bulk delete.', + 404: 'The specified user does not exist.', + }, + ) + def bulk_delete_ban(self, request): + """ + Delete all user posts in a course and optionally ban the user. + + **Use Cases** + + * Remove all discussion content from a spam account + * Ban user from course or organization discussions + * Bulk cleanup of policy-violating content + + **Example Request** + + POST /api/discussion/v1/moderation/bulk-delete-ban/ + + ```json + { + "user_id": 12345, + "course_id": "course-v1:HarvardX+CS50+2024", + "ban_user": true, + "ban_scope": "course", + "reason": "Posting spam and scam content" + } + ``` + + **Response Values** + + * status: Success status of the request + * message: Human-readable message about the queued task + * task_id: Celery task ID for tracking the asynchronous operation + + **Notes** + + * This operation is asynchronous and returns a task ID + * If ban_user is true, a ban record will be created after content deletion + * Reason is required when ban_user is true + * Email notification is sent to partner-support upon ban + """ + from lms.djangoapps.discussion.rest_api.serializers import BulkDeleteBanRequestSerializer + + serializer = BulkDeleteBanRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + validated_data = serializer.validated_data + + # Check if ban feature is enabled for this course + if validated_data['ban_user']: + course_key = CourseKey.from_string(validated_data['course_id']) + if not ENABLE_DISCUSSION_BAN.is_enabled(course_key): + return Response( + {'error': 'Discussion ban feature is not enabled for this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Enqueue Celery task (backward compatible with new parameters) + task = delete_course_post_for_user.apply_async( + kwargs={ + 'user_id': validated_data['user_id'], + 'username': get_object_or_404(User, id=validated_data['user_id']).username, + 'course_ids': [validated_data['course_id']], + 'ban_user': validated_data['ban_user'], + 'ban_scope': validated_data.get('ban_scope', 'course'), + 'moderator_id': request.user.id, + 'reason': validated_data.get('reason', ''), + } + ) + + message = ( + 'Deletion task queued. User will be banned upon completion.' + if validated_data['ban_user'] + else 'Deletion task queued.' + ) + return Response({ + 'status': 'success', + 'message': message, + 'task_id': task.id, + }, status=status.HTTP_202_ACCEPTED) + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'course_id', + apidocs.ParameterLocation.QUERY, + description='Course ID to filter banned users (required)' + ), + apidocs.string_parameter( + 'scope', + apidocs.ParameterLocation.QUERY, + description='Filter by ban scope: "course" or "organization"' + ), + ], + responses={ + 200: openapi.Response( + description='List of banned users', + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'count': openapi.Schema( + type=openapi.TYPE_INTEGER, + description='Total number of banned users' + ), + 'results': openapi.Schema( + type=openapi.TYPE_ARRAY, + description='Array of banned user records', + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema(type=openapi.TYPE_INTEGER), + 'username': openapi.Schema(type=openapi.TYPE_STRING), + 'email': openapi.Schema(type=openapi.TYPE_STRING), + 'user_id': openapi.Schema(type=openapi.TYPE_INTEGER), + 'course_id': openapi.Schema(type=openapi.TYPE_STRING), + 'organization': openapi.Schema(type=openapi.TYPE_STRING), + 'scope': openapi.Schema(type=openapi.TYPE_STRING), + 'reason': openapi.Schema(type=openapi.TYPE_STRING), + 'banned_at': openapi.Schema(type=openapi.TYPE_STRING, format='date-time'), + 'banned_by_username': openapi.Schema(type=openapi.TYPE_STRING), + 'is_active': openapi.Schema(type=openapi.TYPE_BOOLEAN), + }, + ), + ), + }, + ), + ), + 400: 'Missing required course_id parameter.', + 401: 'The requester is not authenticated.', + 403: 'The requester does not have permission to view banned users.', + }, + ) + def banned_users(self, request, course_id=None): + """ + Retrieve list of banned users for a specific course. + + **Use Cases** + + * View all currently banned users in a course + * Filter banned users by scope (course-level vs organization-level) + * Audit moderation actions + + **Example Requests** + + GET /api/discussion/v1/moderation/banned-users/course-v1:HarvardX+CS50+2024 + GET /api/discussion/v1/moderation/banned-users/course-v1:edX+DemoX+Demo?scope=course + + **Response Values** + + * count: Total number of active bans for the course + * results: Array of ban records with user information + + **Notes** + + * Only returns active bans (is_active=True) + * Course-level bans are specific to one course + * Organization-level bans apply to all courses in the organization + """ + from forum import api as forum_api + from lms.djangoapps.discussion.rest_api.permissions import can_take_action_on_spam + + if not course_id: + return Response( + {'error': 'course_id parameter is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + course_key = CourseKey.from_string(course_id) + + # Permission check: user must be able to moderate in this course + if not can_take_action_on_spam(request.user, course_key): + return Response( + {'error': 'You do not have permission to view banned users in this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Check if ban feature is enabled for this course + if not ENABLE_DISCUSSION_BAN.is_enabled(course_key): + return Response( + {'error': 'Discussion ban feature is not enabled for this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Optional scope filter + scope = request.query_params.get('scope') + + # Get banned users using forum API + banned_users_data = forum_api.get_banned_users( + course_id=course_key, + scope=scope + ) + + return Response({ + 'count': len(banned_users_data), + 'results': banned_users_data + }) + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'pk', + apidocs.ParameterLocation.PATH, + description='Ban ID to unban' + ), + ], + body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'course_id': openapi.Schema( + type=openapi.TYPE_STRING, + description='Course ID for organization-level ban exceptions' + ), + 'reason': openapi.Schema( + type=openapi.TYPE_STRING, + description='Reason for unbanning' + ), + }, + required=['reason'], + ), + responses={ + 200: openapi.Response( + description='User unbanned successfully', + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'status': openapi.Schema(type=openapi.TYPE_STRING, example='success'), + 'message': openapi.Schema(type=openapi.TYPE_STRING), + 'exception_created': openapi.Schema( + type=openapi.TYPE_BOOLEAN, + description='True if org-level ban exception was created' + ), + }, + ), + ), + 401: 'The requester is not authenticated.', + 403: 'The requester does not have permission to unban users.', + 404: 'Active ban not found with the specified ID.', + }, + ) + def unban_user_by_id(self, request, pk=None): + """ + Unban a user from discussions or create course-level exception (by ban ID). + + **Use Cases** + + * Lift a course-level ban completely + * Lift an organization-level ban completely + * Create course-specific exception to organization-level ban + * Process user appeals + + **Example Requests** + + POST /api/discussion/v1/moderation/123/unban/ + + ```json + { + "reason": "User appeal approved - first offense" + } + ``` + + Create exception for org-level ban: + + ```json + { + "course_id": "course-v1:HarvardX+CS50+2024", + "reason": "Exception approved for CS50 only" + } + ``` + + **Response Values** + + * status: Success status of the operation + * message: Human-readable message describing the action taken + * exception_created: Boolean indicating if an org-level exception was created + + **Notes** + + * For course-level bans: Deactivates the ban completely + * For org-level bans without course_id: Deactivates entire org-level ban + * For org-level bans with course_id: Creates exception allowing user in that course only + * All unban actions are logged in ModerationAuditLog + """ + from forum import api as forum_api + from lms.djangoapps.discussion.rest_api.permissions import can_take_action_on_spam + + # Get ban using forum API + try: + ban = forum_api.get_ban(pk) + except Exception: # pylint: disable=broad-exception-caught + return Response( + {'error': 'Active ban not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check if ban is active + if not ban.get('is_active'): + return Response( + {'error': 'Active ban not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + course_id = request.data.get('course_id') + reason = request.data.get('reason', '').strip() + + # Import dependencies + from common.djangoapps.student.roles import GlobalStaff + + # Permission check: depends on ban type and what user is trying to do + ban_course_id = ban.get('course_id') + if ban_course_id: + # Course-level ban - check permissions for that specific course + course_key_obj = CourseKey.from_string(ban_course_id) + if not can_take_action_on_spam(request.user, course_key_obj): + return Response( + {'error': 'You do not have permission to unban users in this course'}, + status=status.HTTP_403_FORBIDDEN + ) + else: + # Org-level ban + if course_id: + # Creating exception for specific course - check permissions in that course + if not can_take_action_on_spam(request.user, course_id): + return Response( + {'error': 'You do not have permission to create exceptions in this course'}, + status=status.HTTP_403_FORBIDDEN + ) + else: + # Fully unbanning org-level ban - only global staff can do this + if not (GlobalStaff().has_user(request.user) or request.user.is_staff): + return Response( + {'error': 'Only global staff can fully unban organization-level bans'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Check if ban feature is enabled + # Determine which course_key to use for flag check + if ban_course_id: + # Course-level ban - use ban's course_id + course_key_for_flag = CourseKey.from_string(ban_course_id) + elif course_id: + # Org-level ban with course exception - use provided course_id + course_key_for_flag = CourseKey.from_string(course_id) + elif ban.get('scope') == 'organization' and ban.get('org_key'): + # Org-level ban without course_id - find any course in org to check flag + from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + try: + # Find any course in the organization to check the flag + org_course = CourseOverview.objects.filter(org=ban['org_key']).first() + if org_course: + course_key_for_flag = org_course.id + else: + # No courses found in org - deny unless global staff + if not (GlobalStaff().has_user(request.user) or request.user.is_staff): + return Response( + {'error': 'Discussion ban feature check requires course context or global staff access'}, + status=status.HTTP_403_FORBIDDEN + ) + # Global staff can proceed without flag check for org-level operations + course_key_for_flag = None + except Exception: # pylint: disable=broad-exception-caught + # Fallback: deny unless global staff + if not (GlobalStaff().has_user(request.user) or request.user.is_staff): + return Response( + {'error': 'Discussion ban feature check requires course context or global staff access'}, + status=status.HTTP_403_FORBIDDEN + ) + course_key_for_flag = None + else: + course_key_for_flag = None + + # Check flag if we have a course_key + if course_key_for_flag: + if not ENABLE_DISCUSSION_BAN.is_enabled(course_key_for_flag): + return Response( + {'error': 'Discussion ban feature is not enabled for this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Validate that reason is provided + if not reason: + return Response( + {'error': 'reason field is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Use forum API to unban - it handles both full unban and exceptions + try: + unban_result = forum_api.unban_user( + ban_id=pk, + unbanned_by=request.user, + course_id=course_id, + reason=reason + ) + + return Response({ + 'status': unban_result.get('status', 'success'), + 'message': unban_result.get('message', 'User unbanned successfully'), + 'exception_created': unban_result.get('exception_created', False) + }) + except ValueError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"Error unbanning user: {e}") + return Response( + {'error': 'An error occurred while unbanning the user'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) diff --git a/lms/djangoapps/discussion/templates/discussion/ban_escalation_email.txt b/lms/djangoapps/discussion/templates/discussion/ban_escalation_email.txt new file mode 100644 index 000000000000..f8a02b664b18 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/ban_escalation_email.txt @@ -0,0 +1,28 @@ +DISCUSSION BAN ALERT +================================================================================ + +A user has been banned from course discussions. + +Banned User: {{ banned_username }} ({{ banned_email }}) +User ID: {{ banned_user_id }} + +Moderator: {{ moderator_username }} ({{ moderator_email }}) +Moderator ID: {{ moderator_id }} + +Course ID: {{ course_id }} +Ban Scope: {{ scope|upper }} + +Reason: {{ reason }} + +Content Deleted: +- Threads: {{ threads_deleted }} +- Comments: {{ comments_deleted }} +- Total: {{ total_deleted }} + +================================================================================ + +ACTION REQUIRED: +Please review this moderation action and follow up as needed. If this ban was +applied in error or requires adjustment, contact the moderator or course staff. + +================================================================================ diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/body.html b/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/body.html new file mode 100644 index 000000000000..d6a57962433f --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/body.html @@ -0,0 +1,82 @@ +{% extends 'ace_common/edx_ace/common/base_body.html' %} + +{% load i18n %} +{% load django_markup %} +{% load static %} + +{% block content %} + + + + +
+

+ {% filter force_escape %} + {% blocktrans %} + Discussion Ban Alert + {% endblocktrans %} + {% endfilter %} +

+ +

+ {% filter force_escape %} + {% blocktrans %} + A user has been banned from course discussions. Please review this moderation action. + {% endblocktrans %} + {% endfilter %} +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Banned User{{ banned_username }} ({{ banned_email }})
User ID{{ banned_user_id }}
Moderator{{ moderator_username }} ({{ moderator_email }})
Moderator ID{{ moderator_id }}
Course ID{{ course_id }}
Ban Scope + {{ scope|upper }}{% if scope == 'organization' %} (All courses in organization){% endif %} +
Reason{{ reason }}
Content Deleted + {{ threads_deleted }} thread{{ threads_deleted|pluralize }}, + {{ comments_deleted }} comment{{ comments_deleted|pluralize }} +
+ Total: {{ total_deleted }} item{{ total_deleted|pluralize }} +
+ +

+ {% trans "Action Required:" as action_required %}{{ action_required|force_escape }} + {% trans "Please review this moderation action and follow up as needed. If this ban was applied in error or requires adjustment, contact the moderator or course staff." as review_instructions %}{{ review_instructions|force_escape }} +

+ + {% block google_analytics_pixel %} + {% if ga_tracking_pixel_url %} + + {% endif %} + {% endblock %} +
+{% endblock %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/body.txt new file mode 100644 index 000000000000..f8a02b664b18 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/body.txt @@ -0,0 +1,28 @@ +DISCUSSION BAN ALERT +================================================================================ + +A user has been banned from course discussions. + +Banned User: {{ banned_username }} ({{ banned_email }}) +User ID: {{ banned_user_id }} + +Moderator: {{ moderator_username }} ({{ moderator_email }}) +Moderator ID: {{ moderator_id }} + +Course ID: {{ course_id }} +Ban Scope: {{ scope|upper }} + +Reason: {{ reason }} + +Content Deleted: +- Threads: {{ threads_deleted }} +- Comments: {{ comments_deleted }} +- Total: {{ total_deleted }} + +================================================================================ + +ACTION REQUIRED: +Please review this moderation action and follow up as needed. If this ban was +applied in error or requires adjustment, contact the moderator or course staff. + +================================================================================ diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/from_name.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/from_name.txt new file mode 100644 index 000000000000..fb090bda4e0e --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/from_name.txt @@ -0,0 +1 @@ +edX Discussion Moderation \ No newline at end of file diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/head.html b/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/head.html new file mode 100644 index 000000000000..366ada7ad92e --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/head.html @@ -0,0 +1 @@ +{% extends 'ace_common/edx_ace/common/base_head.html' %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/subject.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/subject.txt new file mode 100644 index 000000000000..a3e4a972368b --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/ban_escalation/email/subject.txt @@ -0,0 +1 @@ +Discussion Moderation Alert: User Banned \ No newline at end of file diff --git a/lms/djangoapps/discussion/toggles.py b/lms/djangoapps/discussion/toggles.py index 61286686b759..9de8d0250555 100644 --- a/lms/djangoapps/discussion/toggles.py +++ b/lms/djangoapps/discussion/toggles.py @@ -37,3 +37,20 @@ # .. toggle_creation_date: 2025-07-29 # .. toggle_target_removal_date: 2026-07-29 ENABLE_RATE_LIMIT_IN_DISCUSSION = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.enable_rate_limit', __name__) + + +# .. toggle_name: discussions.enable_discussion_ban +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable ban user functionality in discussion moderation. +# When enabled, moderators can ban users from discussions at course or organization level +# during bulk delete operations. This addresses crypto spam attacks and harassment. +# .. toggle_use_cases: opt_in +# .. toggle_creation_date: 2024-11-24 +# .. toggle_target_removal_date: 2025-06-01 +# .. toggle_warning: This feature requires proper moderator training to prevent misuse. +# Ensure DISCUSSION_MODERATION_BAN_EMAIL_ENABLED is configured appropriately for your environment. +# .. toggle_tickets: COSMO2-736 +ENABLE_DISCUSSION_BAN = CourseWaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.enable_discussion_ban', __name__ +) diff --git a/lms/envs/common.py b/lms/envs/common.py index 0419633f583e..a8dd834e582e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3022,6 +3022,40 @@ AVAILABLE_DISCUSSION_TOURS = [] +############## DISCUSSION MODERATION ############## + +# .. toggle_name: settings.DISCUSSION_MODERATION_BAN_EMAIL_ENABLED +# .. toggle_implementation: DjangoSetting +# .. toggle_default: True +# .. toggle_description: Enable/disable email notifications when users are banned from discussions. +# Set to False in development/test environments to prevent spam to partner-support@edx.org. +# When enabled, escalation emails are sent to DISCUSSION_MODERATION_ESCALATION_EMAIL address. +# .. toggle_use_cases: opt_in +# .. toggle_creation_date: 2024-11-24 +# .. toggle_tickets: COSMO2-736 +DISCUSSION_MODERATION_BAN_EMAIL_ENABLED = True + +# .. setting_name: DISCUSSION_MODERATION_ESCALATION_EMAIL +# .. setting_default: 'partner-support@edx.org' +# .. setting_description: Email address to receive ban escalation notifications when users are banned +# from discussions. Override in development to use a test email address. +# .. setting_use_cases: opt_in +# .. setting_creation_date: 2024-11-24 +# .. setting_tickets: COSMO2-736 +DISCUSSION_MODERATION_ESCALATION_EMAIL = 'partner-support@edx.org' + +# .. setting_name: DISCUSSION_MODERATION_BAN_REASON_MAX_LENGTH +# .. setting_default: 1000 +# .. setting_description: Maximum character length for ban reason text. +# .. setting_use_cases: opt_in +# .. setting_creation_date: 2024-11-24 +# .. setting_tickets: COSMO2-736 +DISCUSSION_MODERATION_BAN_REASON_MAX_LENGTH = 1000 + +############## NOTIFICATIONS ############## +NOTIFICATION_TYPE_ICONS = {} +DEFAULT_NOTIFICATION_ICON_URL = "" + ############## SELF PACED EMAIL ############## SELF_PACED_BANNER_URL = "" SELF_PACED_CLOUD_URL = "" diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index dd7097df7178..f53bc6d35f13 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -43,6 +43,10 @@ CLEAR_REQUEST_CACHE_ON_TASK_COMPLETION = False HTTPS = 'off' +# Disable ban emails in local development to prevent spam +DISCUSSION_MODERATION_BAN_EMAIL_ENABLED = False +DISCUSSION_MODERATION_ESCALATION_EMAIL = 'devnull@example.com' + LMS_ROOT_URL = f'http://{LMS_BASE}' LMS_INTERNAL_ROOT_URL = LMS_ROOT_URL ENTERPRISE_API_URL = f'{LMS_INTERNAL_ROOT_URL}/enterprise/api/v1/' diff --git a/lms/envs/test.py b/lms/envs/test.py index e396549501c1..092b7ef77f89 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -45,6 +45,18 @@ # can test everything else :) DISABLE_START_DATES = True +# Most tests don't use the discussion service, so we turn it off to speed them up. +# Tests that do can enable this flag, but must use the UrlResetMixin class to force urls.py +# to reload. For consistency in user-experience, keep the value of this setting in sync with +# the one in cms/envs/test.py +ENABLE_DISCUSSION_SERVICE = False + +# Disable ban emails in tests to prevent spam and speed up tests +DISCUSSION_MODERATION_BAN_EMAIL_ENABLED = False +DISCUSSION_MODERATION_ESCALATION_EMAIL = 'test@example.com' + +ENABLE_SERVICE_STATUS = True + ENABLE_VERIFIED_CERTIFICATES = True ENABLE_BULK_ENROLLMENT_VIEW = True