From 3e45c8646c0fbd2563e16b740f68ed86dc1834bc Mon Sep 17 00:00:00 2001 From: naincy128 Date: Mon, 1 Dec 2025 09:43:43 +0000 Subject: [PATCH 1/2] feat: implement discussion mute/unmute feature with user and staff-level controls --- lms/djangoapps/discussion/rest_api/api.py | 119 ++- .../discussion/rest_api/permissions.py | 150 ++++ .../discussion/rest_api/serializers.py | 136 ++++ .../rest_api/tests/test_permissions.py | 112 +++ .../discussion/rest_api/tests/test_views.py | 492 ++++++++++++ lms/djangoapps/discussion/rest_api/urls.py | 30 + lms/djangoapps/discussion/rest_api/views.py | 707 +++++++++++++++++- .../0010_discussion_muting_models.py | 83 ++ ...add_timestamped_fields_to_moderationlog.py | 34 + ...011_update_moderation_log_related_names.py | 34 + .../migrations/0012_merge_20251127_0622.py | 14 + .../django_comment_common/models.py | 231 ++++++ 12 files changed, 2135 insertions(+), 7 deletions(-) create mode 100644 openedx/core/djangoapps/django_comment_common/migrations/0010_discussion_muting_models.py create mode 100644 openedx/core/djangoapps/django_comment_common/migrations/0011_add_timestamped_fields_to_moderationlog.py create mode 100644 openedx/core/djangoapps/django_comment_common/migrations/0011_update_moderation_log_related_names.py create mode 100644 openedx/core/djangoapps/django_comment_common/migrations/0012_merge_20251127_0622.py diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index b87852c16cfa..ddfbaa6b885a 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -4,6 +4,7 @@ from __future__ import annotations import itertools +import logging import re from collections import defaultdict from datetime import datetime @@ -61,7 +62,8 @@ FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_MODERATOR, CourseDiscussionSettings, - Role + Role, + DiscussionMute, ) from openedx.core.djangoapps.django_comment_common.signals import ( comment_created, @@ -135,6 +137,93 @@ User = get_user_model() + +def get_muted_user_ids(request_user, course_key): + """ + Get list of user IDs that should be muted for the requesting user. + + Args: + request_user: The user making the request + course_key: The course key + + Returns: + set: Set of user IDs that are muted (personal + course-wide) + """ + try: + # Get personal mutes by this user + personal_mutes = DiscussionMute.objects.filter( + muted_by=request_user, + course_id=course_key, + scope='personal', + is_active=True + ).values_list('muted_user_id', flat=True) + + # Get course-wide mutes (applies to everyone) + course_mutes = DiscussionMute.objects.filter( + course_id=course_key, + scope='course', + is_active=True + ).values_list('muted_user_id', flat=True) + + # Combine both sets + muted_ids = set(personal_mutes) | set(course_mutes) + return muted_ids + + except Exception as e: + # If there's any error, don't filter anything + logging.warning(f"Error getting muted users: {e}") + return set() + + +def filter_muted_content(request_user, course_key, content_list): + """ + Filter out content from muted users. + + Args: + request_user: The user making the request + course_key: The course key + content_list: List of thread or comment objects + + Returns: + list: Filtered list with muted users' content removed + """ + if not request_user.is_authenticated: + return content_list + + # Get muted user IDs + muted_user_ids = get_muted_user_ids(request_user, course_key) + + if not muted_user_ids: + return content_list + + # Filter out content from muted users + filtered_content = [] + for item in content_list: + # Get user_id from the content item (works for both threads and comments) + user_id = None + if hasattr(item, 'get') and callable(getattr(item, 'get')): + # Dictionary-like object + user_id = item.get('user_id') + elif hasattr(item, 'user_id'): + # Object with user_id attribute + user_id = item.user_id + elif hasattr(item, 'get_user_id') and callable(getattr(item, 'get_user_id')): + # Object with get_user_id method + user_id = item.get_user_id() + + # Convert to int if it's a string + try: + if user_id is not None: + user_id = int(user_id) + except (ValueError, TypeError): + pass + + # Keep content if user is not muted + if user_id not in muted_user_ids: + filtered_content.append(item) + + return filtered_content + ThreadType = Literal["discussion", "question"] ViewType = Literal["unread", "unanswered"] ThreadOrderingType = Literal["last_activity_at", "comment_count", "vote_count"] @@ -1046,8 +1135,15 @@ def get_thread_list( if paginated_results.page != page: raise PageNotFoundError("Page not found (No results on this page).") + # Filter out content from muted users + filtered_threads = filter_muted_content( + request.user, + course_key, + paginated_results.collection + ) + results = _serialize_discussion_entities( - request, context, paginated_results.collection, requested_fields, DiscussionEntity.thread + request, context, filtered_threads, requested_fields, DiscussionEntity.thread ) paginator = DiscussionAPIPagination( @@ -1173,8 +1269,16 @@ def get_learner_active_thread_list(request, course_key, query_params): try: threads, page, num_pages = comment_client_user.active_threads(query_params) threads = set_attribute(threads, "pinned", False) + + # Filter out content from muted users + filtered_threads = filter_muted_content( + request.user, + course_key, + threads + ) + results = _serialize_discussion_entities( - request, context, threads, {'profile_image'}, DiscussionEntity.thread + request, context, filtered_threads, {'profile_image'}, DiscussionEntity.thread ) paginator = DiscussionAPIPagination( request, @@ -1272,7 +1376,14 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals raise PageNotFoundError("Page not found (No results on this page).") num_pages = (resp_total + page_size - 1) // page_size if resp_total else 1 - results = _serialize_discussion_entities(request, context, responses, requested_fields, DiscussionEntity.comment) + # Filter out content from muted users + filtered_responses = filter_muted_content( + request.user, + context["course"].id, + responses + ) + + results = _serialize_discussion_entities(request, context, filtered_responses, requested_fields, DiscussionEntity.comment) paginator = DiscussionAPIPagination(request, page, num_pages, resp_total) track_thread_viewed_event(request, context["course"], cc_thread, from_mfe_sidebar) diff --git a/lms/djangoapps/discussion/rest_api/permissions.py b/lms/djangoapps/discussion/rest_api/permissions.py index cfcea5b32834..c3cdf6c405b2 100644 --- a/lms/djangoapps/discussion/rest_api/permissions.py +++ b/lms/djangoapps/discussion/rest_api/permissions.py @@ -228,3 +228,153 @@ def has_permission(self, request, view): course_id = view.kwargs.get("course_id") return can_take_action_on_spam(request.user, course_id) + + +def can_mute_user(requesting_user, target_user, course_id, scope='personal'): + """ + Check if the requesting user can mute the target user. + + Args: + requesting_user: User attempting to mute + target_user: User to be muted + course_id: Course context + scope: 'personal' or 'course' + + Returns: + bool: True if mute is allowed, False otherwise + """ + # Users cannot mute themselves + if requesting_user.id == target_user.id: + return False + + # Check if target user is staff - staff cannot be muted by learners + target_is_staff = ( + CourseStaffRole(course_id).has_user(target_user) or + CourseInstructorRole(course_id).has_user(target_user) or + GlobalStaff().has_user(target_user) + ) + + # Check if requesting user has privileges + requesting_is_staff = ( + CourseStaffRole(course_id).has_user(requesting_user) or + CourseInstructorRole(course_id).has_user(requesting_user) or + GlobalStaff().has_user(requesting_user) + ) + + # Learners cannot mute staff + if target_is_staff and not requesting_is_staff: + return False + + # For course-wide muting, user must be staff + if scope == 'course' and not requesting_is_staff: + return False + + # Check if user is enrolled in course + if not requesting_is_staff: + try: + enrollment = CourseEnrollment.objects.get( + user=requesting_user, + course_id=course_id, + is_active=True + ) + except CourseEnrollment.DoesNotExist: + return False + + return True + + +def can_unmute_user(requesting_user, target_user, course_id, scope='personal'): + """ + Determine whether the requesting user can unmute the target user. + + Rules: + - Users cannot unmute themselves as the target. + - Staff (instructors, TAs, global staff) can unmute anyone at any scope. + - Course-wide unmute is restricted to staff. + - Personal unmute is always allowed (the view checks if the mute belongs to the user). + """ + # Users cannot unmute themselves as the target + if requesting_user.id == target_user.id: + return False + + # Check if requesting user is staff + requesting_is_staff = ( + CourseStaffRole(course_id).has_user(requesting_user) + or CourseInstructorRole(course_id).has_user(requesting_user) + or GlobalStaff().has_user(requesting_user) + ) + + # Staff can unmute anyone + if requesting_is_staff: + return True + + # For course-wide unmuting, only staff is allowed + if scope == 'course': + return False + + # PERSONAL UNMUTE: + # Any enrolled learner can unmute a personal mute. + # The view will verify that the mute was created by this user. + return True + + +def can_view_muted_users(requesting_user, course_id, scope='personal'): + """ + Check if the requesting user can view muted users list. + + Args: + requesting_user: User attempting to view muted users + course_id: Course context + scope: 'personal', 'course', or 'all' + + Returns: + bool: True if viewing is allowed, False otherwise + """ + # Check if requesting user has privileges + requesting_is_staff = ( + CourseStaffRole(course_id).has_user(requesting_user) or + CourseInstructorRole(course_id).has_user(requesting_user) or + GlobalStaff().has_user(requesting_user) + ) + + # Staff can view all scopes + if requesting_is_staff: + return True + + # Learners can only view their personal mutes + if scope in ['course', 'all']: + return False + + return True + + +class CanMuteUsers(permissions.BasePermission): + """ + Permission to check if user can mute other users. + """ + + def has_permission(self, request, view): + """Check basic mute permissions""" + if not request.user.is_authenticated: + return False + + course_id = request.data.get('course_id') or view.kwargs.get('course_id') + if not course_id: + return False + + try: + course_key = CourseKey.from_string(course_id) + except: + return False + + # Check course enrollment + try: + enrollment = CourseEnrollment.objects.get( + user=request.user, + course_id=course_key, + is_active=True + ) + return bool(enrollment) + except CourseEnrollment.DoesNotExist: + return False + diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index 8a7ab16e0903..d3f5101528ad 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -966,3 +966,139 @@ 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", ) + + +# Muting-related serializers +class UserBriefSerializer(serializers.Serializer): + """ + Serializer for brief user information in mute-related responses. + """ + id = serializers.IntegerField() + username = serializers.CharField() + email = serializers.EmailField(required=False) + + +class MuteRequestSerializer(serializers.Serializer): + """ + Serializer for mute user requests. + """ + muted_user_id = serializers.IntegerField( + help_text="ID of the user to be muted" + ) + course_id = serializers.CharField( + help_text="Course ID where the mute applies" + ) + scope = serializers.ChoiceField( + choices=['personal', 'course'], + default='personal', + help_text="Scope of the mute (personal or course-wide)" + ) + reason = serializers.CharField( + required=False, + allow_blank=True, + help_text="Optional reason for muting" + ) + + +class MuteAndReportRequestSerializer(MuteRequestSerializer): + """ + Serializer for mute and report requests. + """ + thread_id = serializers.CharField( + required=False, + allow_blank=True, + help_text="ID of the thread being reported" + ) + comment_id = serializers.CharField( + required=False, + allow_blank=True, + help_text="ID of the comment being reported" + ) + + +class UnmuteRequestSerializer(serializers.Serializer): + """ + Serializer for unmute user requests. + """ + muted_user_id = serializers.IntegerField( + help_text="ID of the user to be unmuted" + ) + course_id = serializers.CharField( + help_text="Course ID where the unmute applies" + ) + scope = serializers.ChoiceField( + choices=['personal', 'course'], + default='personal', + help_text="Scope of the unmute (personal or course-wide)" + ) + + +class MuteRecordSerializer(serializers.Serializer): + """ + Serializer for mute record responses. + """ + id = serializers.IntegerField() + muted_user = UserBriefSerializer() + muted_by = UserBriefSerializer(required=False) + course_id = serializers.CharField() + scope = serializers.CharField() + reason = serializers.CharField(allow_blank=True) + created = serializers.DateTimeField() + is_active = serializers.BooleanField() + + +class MuteResponseSerializer(serializers.Serializer): + """ + Serializer for mute operation responses. + """ + status = serializers.CharField() + message = serializers.CharField() + mute_record = MuteRecordSerializer() + + +class ReportRecordSerializer(serializers.Serializer): + """ + Serializer for report record responses. + """ + id = serializers.IntegerField() + content_type = serializers.CharField() + content_id = serializers.CharField() + created = serializers.DateTimeField() + + +class MuteAndReportResponseSerializer(serializers.Serializer): + """ + Serializer for mute and report operation responses. + """ + status = serializers.CharField() + message = serializers.CharField() + mute_record = MuteRecordSerializer() + report_record = ReportRecordSerializer() + + +class UnmuteResponseSerializer(serializers.Serializer): + """ + Serializer for unmute operation responses. + """ + status = serializers.CharField() + message = serializers.CharField() + unmute_timestamp = serializers.DateTimeField() + + +class MutedUsersListSerializer(serializers.Serializer): + """ + Serializer for paginated list of muted users. + """ + count = serializers.IntegerField() + next = serializers.URLField(allow_null=True, required=False) + previous = serializers.URLField(allow_null=True, required=False) + results = MuteRecordSerializer(many=True) + + +class MuteStatusSerializer(serializers.Serializer): + """ + Serializer for mute status check responses. + """ + is_muted = serializers.BooleanField() + mute_type = serializers.CharField(allow_blank=True) + mute_details = serializers.DictField(required=False) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_permissions.py b/lms/djangoapps/discussion/rest_api/tests/test_permissions.py index 405726e2125b..058394d3f7d8 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_permissions.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_permissions.py @@ -202,3 +202,115 @@ def test_comment(self, is_author, is_thread_author, is_privileged): thread=Thread(user_id="5" if is_thread_author else "6") ) assert can_delete(comment, context) == (is_author or is_privileged) + + +@ddt.ddt +class ModerationPermissionsTest(ModuleStoreTestCase): + """Tests for discussion moderation permissions""" + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + + def test_can_mute_user_self_mute_prevention(self): + """Test that users cannot mute themselves""" + from lms.djangoapps.discussion.rest_api.permissions import can_mute_user + from common.djangoapps.student.tests.factories import UserFactory + + user = UserFactory.create() + + # Self-mute should always return False + result = can_mute_user(user, user, self.course.id, 'personal') + assert result is False + + result = can_mute_user(user, user, self.course.id, 'course') + assert result is False + + def test_can_mute_user_basic_logic(self): + """Test basic mute permission logic""" + from lms.djangoapps.discussion.rest_api.permissions import can_mute_user + from common.djangoapps.student.tests.factories import UserFactory + from common.djangoapps.student.models import CourseEnrollment + + user1 = UserFactory.create() + user2 = UserFactory.create() + + # Create enrollments + CourseEnrollment.objects.create(user=user1, course_id=self.course.id, is_active=True) + CourseEnrollment.objects.create(user=user2, course_id=self.course.id, is_active=True) + + # Basic personal mute should work + result = can_mute_user(user1, user2, self.course.id, 'personal') + assert result is True + + # Course-wide mute should fail for non-staff + result = can_mute_user(user1, user2, self.course.id, 'course') + assert result is False + + def test_can_mute_user_staff_permissions(self): + """Test staff mute permissions""" + from lms.djangoapps.discussion.rest_api.permissions import can_mute_user + from common.djangoapps.student.tests.factories import UserFactory + from common.djangoapps.student.models import CourseEnrollment + from common.djangoapps.student.roles import CourseStaffRole + + staff_user = UserFactory.create() + learner = UserFactory.create() + + # Create enrollments + CourseEnrollment.objects.create(user=staff_user, course_id=self.course.id, is_active=True) + CourseEnrollment.objects.create(user=learner, course_id=self.course.id, is_active=True) + + # Make user staff + CourseStaffRole(self.course.id).add_users(staff_user) + + # Staff should be able to do course-wide mutes + result = can_mute_user(staff_user, learner, self.course.id, 'course') + assert result is True + + # Staff should also be able to do personal mutes + result = can_mute_user(staff_user, learner, self.course.id, 'personal') + assert result is True + + def test_can_unmute_user_basic_logic(self): + """Test basic unmute permission logic""" + from lms.djangoapps.discussion.rest_api.permissions import can_unmute_user + from common.djangoapps.student.tests.factories import UserFactory + + user1 = UserFactory.create() + user2 = UserFactory.create() + + # Personal unmute should work + result = can_unmute_user(user1, user2, self.course.id, 'personal') + assert result is True + + # Course unmute should fail for non-staff + result = can_unmute_user(user1, user2, self.course.id, 'course') + assert result is False + + def test_can_view_muted_users_permissions(self): + """Test viewing muted users permissions""" + from lms.djangoapps.discussion.rest_api.permissions import can_view_muted_users + from common.djangoapps.student.tests.factories import UserFactory + from common.djangoapps.student.roles import CourseStaffRole + + learner = UserFactory.create() + staff_user = UserFactory.create() + + # Make user staff + CourseStaffRole(self.course.id).add_users(staff_user) + + # Learners can view personal mutes + result = can_view_muted_users(learner, self.course.id, 'personal') + assert result is True + + # Learners cannot view course mutes + result = can_view_muted_users(learner, self.course.id, 'course') + assert result is False + + # Staff can view all mutes + result = can_view_muted_users(staff_user, self.course.id, 'personal') + assert result is True + + result = can_view_muted_users(staff_user, self.course.id, 'course') + assert result is True diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index e4d46168c46d..e73dfdb54d1c 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -55,6 +55,9 @@ from openedx.core.djangoapps.django_comment_common.models import ( CourseDiscussionSettings, Role, + DiscussionMuteException, + DiscussionModerationLog, + DiscussionMute, ) from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user @@ -2026,3 +2029,492 @@ def test_with_username_param_case(self, username_search_string): """ response = get_usernames_from_search_string(self.course_key, username_search_string, 1, 1) assert response == (username_search_string.lower(), 1, 1) + + +@ddt.ddt +class DiscussionModerationTestCase(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """ + Test suite for discussion moderation functionality (mute/unmute). + Tests all 11 requirements from the user's specification. + """ + + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + + # Create additional users for testing + self.target_learner = UserFactory.create(password=self.password) + self.target_learner.profile.year_of_birth = 1970 + self.target_learner.profile.save() + CourseEnrollmentFactory.create(user=self.target_learner, course_id=self.course.id) + + self.other_learner = UserFactory.create(password=self.password) + self.other_learner.profile.year_of_birth = 1970 + self.other_learner.profile.save() + CourseEnrollmentFactory.create(user=self.other_learner, course_id=self.course.id) + + # Create staff user + self.staff_user = UserFactory.create(password=self.password) + self.staff_user.profile.year_of_birth = 1970 + self.staff_user.profile.save() + CourseEnrollmentFactory.create(user=self.staff_user, course_id=self.course.id) + CourseStaffRole(self.course.id).add_users(self.staff_user) + + # Create instructor user + self.instructor = UserFactory.create(password=self.password) + self.instructor.profile.year_of_birth = 1970 + self.instructor.profile.save() + CourseEnrollmentFactory.create(user=self.instructor, course_id=self.course.id) + CourseInstructorRole(self.course.id).add_users(self.instructor) + + # URLs + self.mute_url = reverse('mute_user', kwargs={'course_id': str(self.course.id)}) + self.unmute_url = reverse('unmute_user', kwargs={'course_id': str(self.course.id)}) + self.mute_and_report_url = reverse('mute_and_report', kwargs={'course_id': str(self.course.id)}) + self.muted_users_url = reverse('muted_users_list', kwargs={'course_id': str(self.course.id)}) + self.mute_status_url = reverse('mute_status', kwargs={'course_id': str(self.course.id)}) + + # Set url for DiscussionAPIViewTestMixin compatibility + self.url = self.mute_url + + def _create_test_mute(self, muted_user, muted_by, scope='personal', is_active=True): + """Helper method to create a mute record for testing""" + return DiscussionMute.objects.create( + muted_user=muted_user, + muted_by=muted_by, + course_id=self.course.id, + scope=scope, + reason='Test reason', + is_active=is_active + ) + + def _login_user(self, user): + """Helper method to login a user""" + self.client.login(username=user.username, password=self.password) + + def test_basic(self): + """Basic test for DiscussionAPIViewTestMixin compatibility""" + self._login_user(self.user) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id), + 'scope': 'personal' + } + response = self.client.post(self.mute_url, data, format='json') + assert response.status_code in [status.HTTP_201_CREATED, status.HTTP_200_OK] + + # Test 1: Personal Mute (Learner → Learner & Staff → Learner) + def test_personal_mute_learner_to_learner(self): + """Test that learners can perform personal mutes on other learners""" + + self._login_user(self.user) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id), + 'scope': 'personal', + 'reason': 'Testing personal mute' + } + + response = self.client.post(self.mute_url, data, format='json') + + # Assert response is successful + assert response.status_code == status.HTTP_201_CREATED + response_data = response.json() + assert response_data['status'] == 'success' + assert response_data['message'] == 'User muted successfully' + + # Assert mute record was created + mute = DiscussionMute.objects.get( + muted_user=self.target_learner, + muted_by=self.user, + course_id=self.course.id, + scope='personal' + ) + assert mute.is_active is True + assert mute.reason == 'Testing personal mute' + + # Assert moderation log was created + log = DiscussionModerationLog.objects.get( + action_type=DiscussionModerationLog.ACTION_MUTE, + target_user=self.target_learner, + moderator=self.user, + course_id=self.course.id + ) + assert log.scope == 'personal' + + def test_personal_mute_staff_to_learner(self): + """Test that staff can perform personal mutes on learners""" + + self._login_user(self.staff_user) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id), + 'scope': 'personal', + 'reason': 'Staff personal mute' + } + + response = self.client.post(self.mute_url, data, format='json') + + assert response.status_code == status.HTTP_201_CREATED + assert DiscussionMute.objects.filter( + muted_user=self.target_learner, + muted_by=self.staff_user, + scope='personal' + ).exists() + + # Test 2: Self-Mute Prevention + def test_learner_cannot_mute_self(self): + """Test that learners cannot mute themselves""" + self._login_user(self.user) + data = { + 'muted_user_id': self.user.id, + 'course_id': str(self.course.id), + 'scope': 'personal' + } + + response = self.client.post(self.mute_url, data, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + response_data = response.json() + assert response_data['status'] == 'error' + assert 'cannot mute themselves' in response_data['message'] + + def test_staff_cannot_mute_self(self): + """Test that staff cannot mute themselves""" + self._login_user(self.staff_user) + data = { + 'muted_user_id': self.staff_user.id, + 'course_id': str(self.course.id), + 'scope': 'course' + } + + response = self.client.post(self.mute_url, data, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + response_data = response.json() + assert 'cannot mute themselves' in response_data['message'] + + # Test 3: Course-Level Mute (Staff Only) + def test_course_level_mute_by_staff(self): + """Test that staff can perform course-level mutes""" + + self._login_user(self.staff_user) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id), + 'scope': 'course', + 'reason': 'Course-wide mute for disruptive behavior' + } + + response = self.client.post(self.mute_url, data, format='json') + + assert response.status_code == status.HTTP_201_CREATED + mute = DiscussionMute.objects.get( + muted_user=self.target_learner, + muted_by=self.staff_user, + scope='course' + ) + assert mute.is_active is True + + def test_learner_cannot_do_course_level_mute(self): + """Test that learners cannot perform course-level mutes""" + self._login_user(self.user) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id), + 'scope': 'course' + } + + response = self.client.post(self.mute_url, data, format='json') + + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Test 4: Prevent Muting Staff + def test_learner_cannot_mute_staff(self): + """Test that learners cannot mute staff members""" + self._login_user(self.user) + data = { + 'muted_user_id': self.staff_user.id, + 'course_id': str(self.course.id), + 'scope': 'personal' + } + + response = self.client.post(self.mute_url, data, format='json') + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_learner_cannot_mute_instructor(self): + """Test that learners cannot mute instructors""" + self._login_user(self.user) + data = { + 'muted_user_id': self.instructor.id, + 'course_id': str(self.course.id), + 'scope': 'personal' + } + + response = self.client.post(self.mute_url, data, format='json') + + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Test 5: Mute + Report + @mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.Thread.find') + def test_mute_and_report_with_thread(self, mock_thread_find): + """Test mute and report functionality with thread ID""" + + # Mock the thread + mock_thread = mock.Mock() + mock_thread.flagAbuse = mock.Mock() + mock_thread_find.return_value = mock_thread + + self._login_user(self.user) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id), + 'scope': 'personal', + 'reason': 'Inappropriate content', + 'thread_id': 'test_thread_123' + } + + response = self.client.post(self.mute_and_report_url, data, format='json') + + assert response.status_code == status.HTTP_201_CREATED + + # Assert mute record was created + assert DiscussionMute.objects.filter( + muted_user=self.target_learner, + muted_by=self.user + ).exists() + + # Assert moderation log was created + log = DiscussionModerationLog.objects.get( + action_type=DiscussionModerationLog.ACTION_MUTE_AND_REPORT, + target_user=self.target_learner + ) + assert log.metadata['thread_id'] == 'test_thread_123' + + # Test 6: Personal Unmute + def test_personal_unmute(self): + """Test that users can unmute their own personal mutes, but not others'.""" + + # Create an existing personal mute by self.user + mute = self._create_test_mute(self.target_learner, self.user, 'personal') + # Login as the user who muted + self._login_user(self.user) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id), + 'scope': 'personal' + } + # User should be able to unmute + response = self.client.post(self.unmute_url, data, format='json') + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data['status'] == 'success' + assert response_data.get('unmute_type') == 'deactivated' + # Assert mute was deactivated + mute.refresh_from_db() + assert mute.is_active is False + + # Assert unmute log was created + assert DiscussionModerationLog.objects.filter( + action_type=DiscussionModerationLog.ACTION_UNMUTE, + target_user=self.target_learner, + moderator=self.user + ).exists() + + # --- Negative test: other user cannot unmute this personal mute --- + other_user = self.other_learner + self._login_user(other_user) + response = self.client.post(self.unmute_url, data, format='json') + assert response.status_code in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) + response_data = response.json() + msg = response_data.get('message', '').lower() + assert any(sub in msg for sub in ('permission', 'no active mute')) + + # Test 7: Course-Level Mute With Personal Unmute Exception + def test_course_mute_with_personal_unmute_exception(self): + """Test that personal unmute creates exception for course-wide mute""" + + # Create a course-wide mute by staff + self._create_test_mute(self.target_learner, self.staff_user, 'course') + + # Learner tries to unmute personally + self._login_user(self.user) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id), + 'scope': 'personal' + } + + response = self.client.post(self.unmute_url, data, format='json') + + assert response.status_code == status.HTTP_201_CREATED + response_data = response.json() + assert response_data['unmute_type'] == 'exception' + + # Assert exception was created + exception = DiscussionMuteException.objects.get( + muted_user=self.target_learner, + exception_user=self.user, + course_id=self.course.id + ) + assert exception is not None + + # Test 8: List Muted Users + def test_list_personal_muted_users(self): + """Test listing personal muted users""" + # Create some mutes + self._create_test_mute(self.target_learner, self.user, 'personal') + self._create_test_mute(self.other_learner, self.user, 'personal') + + self._login_user(self.user) + response = self.client.get(self.muted_users_url + '?scope=personal') + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['count'] == 2 + assert len(data['results']) == 2 + + def test_list_course_muted_users_staff_only(self): + """Test that only staff can list course-wide muted users""" + # Create course-wide mute + self._create_test_mute(self.target_learner, self.staff_user, 'course') + + # Learner tries to access course mutes + self._login_user(self.user) + response = self.client.get(self.muted_users_url + '?scope=course') + + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Staff can access course mutes + self._login_user(self.staff_user) + response = self.client.get(self.muted_users_url + '?scope=course') + + assert response.status_code == status.HTTP_200_OK + + # Test 9: Mute Status + def test_mute_status_personal_mute(self): + """Test mute status for personal mute""" + # Create personal mute + self._create_test_mute(self.target_learner, self.user, 'personal') + + self._login_user(self.user) + response = self.client.get( + self.mute_status_url + f'?user_id={self.target_learner.id}' + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['is_muted'] is True + assert data['mute_type'] == 'personal' + + def test_mute_status_course_mute(self): + """Test mute status for course-wide mute""" + # Create course-wide mute + self._create_test_mute(self.target_learner, self.staff_user, 'course') + + self._login_user(self.user) + response = self.client.get( + self.mute_status_url + f'?user_id={self.target_learner.id}' + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['is_muted'] is True + assert data['mute_type'] == 'course' + + def test_mute_status_no_mute(self): + """Test mute status when user is not muted""" + self._login_user(self.user) + response = self.client.get( + self.mute_status_url + f'?user_id={self.target_learner.id}' + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['is_muted'] is False + assert data['mute_type'] == '' + + # Test 10: Duplicate Mute Prevention + def test_duplicate_mute_prevention(self): + """Test that duplicate mutes are prevented""" + # Create initial mute + self._create_test_mute(self.target_learner, self.user, 'personal') + + # Try to create duplicate mute + self._login_user(self.user) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id), + 'scope': 'personal' + } + + response = self.client.post(self.mute_url, data, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + response_data = response.json() + assert 'already muted' in response_data['message'] + + # Test 11: Authentication and Authorization + def test_mute_requires_authentication(self): + """Test that mute endpoints require authentication""" + self.client.logout() + + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id) + } + + response = self.client.post(self.mute_url, data, format='json') + # CanMuteUsers permission returns 401 for unauthenticated users + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_mute_requires_course_enrollment(self): + """Test that mute requires course enrollment""" + # Create user not enrolled in course + non_enrolled_user = UserFactory.create(password=self.password) + + self.client.login(username=non_enrolled_user.username, password=self.password) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id) + } + + response = self.client.post(self.mute_url, data, format='json') + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Test 12: Invalid Data Handling + def test_mute_invalid_user_id(self): + """Test mute with invalid user ID""" + self._login_user(self.user) + data = { + 'muted_user_id': 99999, + 'course_id': str(self.course.id) + } + + response = self.client.post(self.mute_url, data, format='json') + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_mute_invalid_course_id(self): + """Test mute with invalid course ID""" + self._login_user(self.user) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': 'invalid_course_id' + } + + response = self.client.post(self.mute_url, data, format='json') + # Permission check happens first and fails for invalid course ID + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_unmute_nonexistent_mute(self): + """Test unmuting when no mute exists""" + self._login_user(self.user) + data = { + 'muted_user_id': self.target_learner.id, + 'course_id': str(self.course.id), + 'scope': 'personal' + } + + response = self.client.post(self.unmute_url, data, format='json') + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/lms/djangoapps/discussion/rest_api/urls.py b/lms/djangoapps/discussion/rest_api/urls.py index f102dc41f249..e40ab6682085 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -19,6 +19,11 @@ CourseView, CourseViewV2, LearnerThreadView, + MuteAndReportView, + MutedUsersListView, + MuteStatusView, + MuteUserView, + UnmuteUserView, ReplaceUsernamesView, RetireUserView, ThreadViewSet, @@ -93,5 +98,30 @@ BulkDeleteUserPosts.as_view(), name="bulk_delete_user_posts" ), + re_path( + fr"^v1/moderation/mute/{settings.COURSE_ID_PATTERN}", + MuteUserView.as_view(), + name="mute_user" + ), + re_path( + fr"^v1/moderation/unmute/{settings.COURSE_ID_PATTERN}", + UnmuteUserView.as_view(), + name="unmute_user" + ), + re_path( + fr"^v1/moderation/mute-and-report/{settings.COURSE_ID_PATTERN}", + MuteAndReportView.as_view(), + name="mute_and_report" + ), + re_path( + fr"^v1/moderation/muted/{settings.COURSE_ID_PATTERN}", + MutedUsersListView.as_view(), + name="muted_users_list" + ), + re_path( + fr"^v1/moderation/mute-status/{settings.COURSE_ID_PATTERN}", + MuteStatusView.as_view(), + name="mute_status" + ), path('v1/', include(ROUTER.urls)), ] diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index ba9818124e08..ef19f07abe6a 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -3,12 +3,15 @@ """ import logging import uuid +from datetime import datetime import edx_api_doc_tools as apidocs from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import BadRequest, ValidationError from django.shortcuts import get_object_or_404 +from django.core.paginator import Paginator from drf_yasg import openapi from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -24,11 +27,13 @@ from xmodule.modulestore.django import modulestore from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff from common.djangoapps.util.file import store_uploaded_file +from forum.backends.mysql.models import AbuseFlagger, CommentThread as ForumThread, Comment as ForumComment from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.course_goals.models import UserActivity from lms.djangoapps.discussion.rate_limit import is_content_creation_rate_limited -from lms.djangoapps.discussion.rest_api.permissions import IsAllowedToBulkDelete +from lms.djangoapps.discussion.rest_api.permissions import IsAllowedToBulkDelete, CanMuteUsers 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.django_comment_client import settings as cc_settings @@ -38,7 +43,7 @@ from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider from openedx.core.djangoapps.discussions.serializers import DiscussionSettingsSerializer from openedx.core.djangoapps.django_comment_common import comment_client -from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role +from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role, DiscussionMute, DiscussionModerationLog, DiscussionMuteException from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.user_api.accounts.permissions import CanReplaceUsername, CanRetireUser @@ -77,13 +82,18 @@ UserCommentListGetForm, UserOrdering, ) -from ..rest_api.permissions import IsStaffOrAdmin, IsStaffOrCourseTeamOrEnrolled +from ..rest_api.permissions import IsStaffOrAdmin, IsStaffOrCourseTeamOrEnrolled, can_mute_user, can_unmute_user, can_view_muted_users from ..rest_api.serializers import ( CourseMetadataSerailizer, DiscussionRolesListSerializer, DiscussionRolesSerializer, DiscussionTopicSerializerV2, TopicOrdering, + MuteRequestSerializer, + MuteResponseSerializer, + UserBriefSerializer, + UnmuteRequestSerializer, + MuteAndReportRequestSerializer, ) from .utils import ( create_blocks_params, @@ -1615,3 +1625,694 @@ def post(self, request, course_id): {"comment_count": comment_count, "thread_count": thread_count}, status=status.HTTP_202_ACCEPTED ) + + +class MuteUserView(DeveloperErrorViewMixin, APIView): + """ + API endpoint to mute a user in discussions. + + **POST /api/discussion/v1/moderation/mute/** + + Allows users to mute other users either personally or course-wide (if they have permissions). + """ + authentication_classes = [ + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ] + permission_classes = [CanMuteUsers] + + # API documentation removed to fix startup error + # TODO: Add proper API documentation using available edx_api_doc_tools methods + def post(self, request, course_id): + """Mute a user in discussions""" + + # Validate request data + serializer = MuteRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"status": "error", "message": "Invalid request data", "errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST + ) + + data = serializer.validated_data + + # Get target user + try: + User = get_user_model() + target_user = User.objects.get(id=data['muted_user_id']) + except User.DoesNotExist: + return Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Parse course key + try: + course_key = CourseKey.from_string(data['course_id']) + except: + return Response( + {"status": "error", "message": "Invalid course ID"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Prevent self-muting + if request.user.id == target_user.id: + return Response( + {"status": "error", "message": "Users cannot mute themselves"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check permissions + if not can_mute_user(request.user, target_user, course_key, data.get('scope', 'personal')): + return Response( + {"status": "error", "message": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN + ) + + # Check for existing active mute + existing_mute = DiscussionMute.objects.filter( + muted_user=target_user, + muted_by=request.user, + course_id=course_key, + scope=data.get('scope', 'personal'), + is_active=True + ).first() + + if existing_mute: + return Response( + {"status": "error", "message": "User is already muted"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Create mute record + mute_record = DiscussionMute.objects.create( + muted_user=target_user, + muted_by=request.user, + + course_id=course_key, + scope=data.get('scope', 'personal'), + reason=data.get('reason', ''), + is_active=True + ) + + # Log the action + DiscussionModerationLog.objects.create( + action_type=DiscussionModerationLog.ACTION_MUTE, + target_user=target_user, + moderator=request.user, + course_id=course_key, + scope=data.get('scope', 'personal'), + reason=data.get('reason', ''), + metadata={ + 'mute_record_id': mute_record.id, + } + ) + + # Prepare response + response_data = { + 'status': 'success', + 'message': 'User muted successfully', + 'mute_record': { + 'id': mute_record.id, + 'muted_user': { + 'id': target_user.id, + 'username': target_user.username, + }, + 'scope': mute_record.scope, + 'created': mute_record.created, + 'is_active': mute_record.is_active, + } + } + + return Response(response_data, status=status.HTTP_201_CREATED) + + +class UnmuteUserView(DeveloperErrorViewMixin, APIView): + """ + API endpoint to unmute a user in discussions. + + **POST /api/discussion/v1/moderation/unmute/** + """ + authentication_classes = [ + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ] + permission_classes = [CanMuteUsers] + + def post(self, request, course_id): + """Unmute a user in discussions""" + + # Validate request data + serializer = UnmuteRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"status": "error", "message": "Invalid request data", "errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST + ) + + data = serializer.validated_data + + # Get target user + try: + User = get_user_model() + target_user = User.objects.get(id=data['muted_user_id']) + except User.DoesNotExist: + return Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Parse course key + try: + course_key = CourseKey.from_string(data['course_id']) + except: + return Response( + {"status": "error", "message": "Invalid course ID"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Prevent self-unmuting + if request.user.id == target_user.id: + return Response( + {"status": "error", "message": "Users cannot unmute themselves"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check permissions + if not can_unmute_user(request.user, target_user, course_key, data.get('scope', 'personal')): + return Response( + {"status": "error", "message": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN + ) + + requesting_is_staff = ( + CourseStaffRole(course_key).has_user(request.user) or + CourseInstructorRole(course_key).has_user(request.user) or + GlobalStaff().has_user(request.user) + ) + + scope = data.get('scope', 'personal') + + # Special handling for course-level mutes with personal unmute exceptions + if scope == 'personal' and not requesting_is_staff: + # Check if there's an active course-level mute + course_mute = DiscussionMute.objects.filter( + muted_user=target_user, + course_id=course_key, + scope='course', + is_active=True + ).first() + + if course_mute: + # Create a personal unmute exception instead of deactivating the course mute + exception, created = DiscussionMuteException.objects.get_or_create( + muted_user=target_user, + exception_user=request.user, + course_id=course_key + ) + + # Log the action as unmute with exception metadata + DiscussionModerationLog.objects.create( + action_type=DiscussionModerationLog.ACTION_UNMUTE, + target_user=target_user, + moderator=request.user, + course_id=course_key, + scope='personal', + reason='Personal exception from course-wide mute', + metadata={ + 'course_mute_id': course_mute.id, + 'exception_id': exception.id, + 'unmute_type': 'exception', + } + ) + + return Response({ + 'status': 'success', + 'message': 'Personal unmute exception created for course-wide mute', + 'unmute_type': 'exception', + 'exception_id': exception.id, + }, status=status.HTTP_201_CREATED) + + # Find active mute records to revoke + mute_records = DiscussionMute.objects.filter( + muted_user=target_user, + course_id=course_key, + scope=scope, + is_active=True + ) + + # For personal scope, only allow unmuting own mutes unless user is staff + if scope == 'personal' and not requesting_is_staff: + mute_records = mute_records.filter(muted_by=request.user) + + if not mute_records.exists(): + return Response( + {"status": "error", "message": "No active mute found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Revoke mutes + unmute_timestamp = datetime.now() + mute_records.update(is_active=False) + + # Log the action + for mute_record in mute_records: + DiscussionModerationLog.objects.create( + action_type=DiscussionModerationLog.ACTION_UNMUTE, + target_user=target_user, + moderator=request.user, + course_id=course_key, + scope=scope, + reason='', + metadata={ + 'revoked_mute_record_id': mute_record.id, + } + ) + + return Response({ + 'status': 'success', + 'message': 'User unmuted successfully', + 'unmute_type': 'deactivated', + 'unmute_timestamp': unmute_timestamp, + }, status=status.HTTP_200_OK) + + +class MuteAndReportView(DeveloperErrorViewMixin, APIView): + """ + API endpoint to mute a user and report their content. + + **POST /api/discussion/v1/moderation/mute-and-report/** + """ + authentication_classes = [ + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ] + permission_classes = [CanMuteUsers] + + def post(self, request, course_id): + """Mute a user and report their content""" + + # Parse course key first for permission checks + try: + course_key = CourseKey.from_string(course_id) + except: + return Response( + {"status": "error", "message": "Invalid course ID"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check if user is staff - mute-and-report is only for learners + if (GlobalStaff().has_user(request.user) or + CourseStaffRole(course_key).has_user(request.user) or + CourseInstructorRole(course_key).has_user(request.user)): + return Response( + {"status": "error", "message": "Mute-and-report action is only available to learners. Staff should use the separate mute action."}, + status=status.HTTP_403_FORBIDDEN + ) + + # Validate request data + serializer = MuteAndReportRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"status": "error", "message": "Invalid request data", "errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST + ) + + data = serializer.validated_data + + # Get target user + try: + User = get_user_model() + target_user = User.objects.get(id=data['muted_user_id']) + except User.DoesNotExist: + return Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Prevent self-muting + if request.user.id == target_user.id: + return Response( + {"status": "error", "message": "Users cannot mute themselves"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check permissions + if not can_mute_user(request.user, target_user, course_key, data.get('scope', 'personal')): + return Response( + {"status": "error", "message": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN + ) + + # Check for existing active mute + existing_mute = DiscussionMute.objects.filter( + muted_user=target_user, + muted_by=request.user, + course_id=course_key, + scope=data.get('scope', 'personal'), + is_active=True + ).first() + + if existing_mute: + return Response( + {"status": "error", "message": "User is already muted"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Create mute record + mute_record = DiscussionMute.objects.create( + muted_user=target_user, + muted_by=request.user, + course_id=course_key, + scope=data.get('scope', 'personal'), + reason=data.get('reason', ''), + is_active=True + ) + + # Handle content reporting using forum's AbuseFlagger system + report_record = None + thread_id = data.get('thread_id') + comment_id = data.get('comment_id') + + if thread_id or comment_id: + try: + if thread_id: + # Report thread using AbuseFlagger + try: + forum_thread = ForumThread.objects.get(pk=thread_id) + content_type = ContentType.objects.get_for_model(ForumThread) + abuse_record, created = AbuseFlagger.objects.get_or_create( + content_type=content_type, + content_object_id=thread_id, + user=request.user, + defaults={'flagged_at': datetime.now()} + ) + # Also flag via comment client for compatibility + thread = Thread.find(thread_id) + if thread: + thread.flagAbuse(request.user, reason=data.get('reason', '')) + + report_record = { + 'id': abuse_record.id, + 'content_type': 'thread', + 'content_id': thread_id, + 'created': abuse_record.flagged_at, + } + except Exception as thread_error: + logging.warning(f"Forum thread reporting failed: {thread_error}") + # Fallback to comment client only + thread = Thread.find(thread_id) + if thread: + thread.flagAbuse(request.user, reason=data.get('reason', '')) + report_record = { + 'id': f"thread_{thread_id}_{request.user.id}", + 'content_type': 'thread', + 'content_id': thread_id, + 'created': mute_record.created, + } + + elif comment_id: + # Report comment using AbuseFlagger + try: + forum_comment = ForumComment.objects.get(pk=comment_id) + content_type = ContentType.objects.get_for_model(ForumComment) + abuse_record, created = AbuseFlagger.objects.get_or_create( + content_type=content_type, + content_object_id=comment_id, + user=request.user, + defaults={'flagged_at': datetime.now()} + ) + # Also flag via comment client for compatibility + comment = Comment.find(comment_id) + if comment: + comment.flagAbuse(request.user, reason=data.get('reason', '')) + + report_record = { + 'id': abuse_record.id, + 'content_type': 'comment', + 'content_id': comment_id, + 'created': abuse_record.flagged_at, + } + except Exception as comment_error: + logging.warning(f"Forum comment reporting failed: {comment_error}") + # Fallback to comment client only + comment = Comment.find(comment_id) + if comment: + comment.flagAbuse(request.user, reason=data.get('reason', '')) + report_record = { + 'id': f"comment_{comment_id}_{request.user.id}", + 'content_type': 'comment', + 'content_id': comment_id, + 'created': mute_record.created, + } + except Exception as e: + logging.warning(f"Content reporting failed: {e}") + # Try fallback to comment client only + try: + if thread_id: + thread = Thread.find(thread_id) + if thread: + thread.flagAbuse(request.user, reason=data.get('reason', '')) + elif comment_id: + comment = Comment.find(comment_id) + if comment: + comment.flagAbuse(request.user, reason=data.get('reason', '')) + except Exception as fallback_error: + logging.error(f"Fallback content reporting also failed: {fallback_error}") + + # Log the action + DiscussionModerationLog.objects.create( + action_type=DiscussionModerationLog.ACTION_MUTE_AND_REPORT, + target_user=target_user, + moderator=request.user, + course_id=course_key, + scope=data.get('scope', 'personal'), + reason=data.get('reason', ''), + metadata={ + 'mute_record_id': mute_record.id, + 'thread_id': thread_id, + 'comment_id': comment_id, + } + ) + + # Prepare response + response_data = { + 'status': 'success', + 'message': 'User muted and content reported', + 'mute_record': { + 'id': mute_record.id, + 'scope': mute_record.scope, + 'created': mute_record.created, + } + } + + if report_record: + response_data['report_record'] = report_record + + return Response(response_data, status=status.HTTP_201_CREATED) + + +class MutedUsersListView(DeveloperErrorViewMixin, APIView): + """ + API endpoint to list muted users. + + **GET /api/discussion/v1/moderation/muted/** + """ + authentication_classes = [ + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ] + permission_classes = [CanMuteUsers] + + def get(self, request, course_id): + """Get list of muted users""" + + # Parse course key + try: + course_key = CourseKey.from_string(course_id) + except: + return Response( + {"status": "error", "message": "Invalid course ID"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get query parameters + scope = request.GET.get('scope', 'personal') + page = int(request.GET.get('page', 1)) + page_size = int(request.GET.get('page_size', 20)) + + # Check permissions + if not can_view_muted_users(request.user, course_key, scope): + return Response( + {"status": "error", "message": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN + ) + + # Build query + query = DiscussionMute.objects.filter( + course_id=course_key, + is_active=True + ).select_related('muted_user', 'muted_by').order_by('-created') + + # Filter by scope + requesting_is_staff = ( + CourseStaffRole(course_key).has_user(request.user) or + CourseInstructorRole(course_key).has_user(request.user) or + GlobalStaff().has_user(request.user) + ) + + if scope == 'personal': + if not requesting_is_staff: + query = query.filter(muted_by=request.user, scope='personal') + else: + query = query.filter(scope='personal') + elif scope == 'course': + if not requesting_is_staff: + return Response( + {"status": "error", "message": "Permission denied for course-wide mutes"}, + status=status.HTTP_403_FORBIDDEN + ) + query = query.filter(scope='course') + elif scope == 'all': + if not requesting_is_staff: + query = query.filter(muted_by=request.user, scope='personal') + + # Paginate + paginator = Paginator(query, page_size) + page_obj = paginator.get_page(page) + + # Serialize results + results = [] + for mute in page_obj: + results.append({ + 'id': mute.id, + 'muted_user': { + 'id': mute.muted_user.id, + 'username': mute.muted_user.username, + 'email': mute.muted_user.email, + }, + 'muted_by': { + 'id': mute.muted_by.id, + 'username': mute.muted_by.username, + }, + 'course_id': str(mute.course_id), + 'scope': mute.scope, + 'reason': mute.reason, + 'created': mute.created, + 'is_active': mute.is_active, + }) + + # Build pagination URLs + next_url = None + previous_url = None + if page_obj.has_next(): + next_url = f"{request.build_absolute_uri()}?page={page_obj.next_page_number()}&scope={scope}&page_size={page_size}" + if page_obj.has_previous(): + previous_url = f"{request.build_absolute_uri()}?page={page_obj.previous_page_number()}&scope={scope}&page_size={page_size}" + + return Response({ + 'count': paginator.count, + 'next': next_url, + 'previous': previous_url, + 'results': results, + }) + + +class MuteStatusView(DeveloperErrorViewMixin, APIView): + """ + API endpoint to check if a user is muted. + + **GET /api/discussion/v1/moderation/mute-status/** + """ + authentication_classes = [ + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ] + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, course_id): + """Check mute status for a user""" + + # Get query parameters + user_id = request.GET.get('user_id') + if not user_id: + return Response( + {"status": "error", "message": "user_id parameter required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Parse course key + try: + course_key = CourseKey.from_string(course_id) + except: + return Response( + {"status": "error", "message": "Invalid course ID"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get target user + try: + User = get_user_model() + target_user = User.objects.get(id=user_id) + except (User.DoesNotExist, ValueError): + return Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check for active mutes + # Priority: course-wide mutes override personal mutes + course_mute = DiscussionMute.objects.filter( + muted_user=target_user, + course_id=course_key, + scope='course', + is_active=True + ).select_related('muted_by').first() + + if course_mute: + return Response({ + 'is_muted': True, + 'mute_type': 'course', + 'mute_details': { + 'muted_by': { + 'id': course_mute.muted_by.id, + 'username': course_mute.muted_by.username, + }, + 'created': course_mute.created, + 'scope': 'course', + } + }) + + # Check for personal mute by requesting user + personal_mute = DiscussionMute.objects.filter( + muted_user=target_user, + muted_by=request.user, + course_id=course_key, + scope='personal', + is_active=True + ).first() + + if personal_mute: + return Response({ + 'is_muted': True, + 'mute_type': 'personal', + 'mute_details': { + 'muted_by': { + 'id': personal_mute.muted_by.id, + 'username': personal_mute.muted_by.username, + }, + 'created': personal_mute.created, + 'scope': 'personal', + } + }) + + return Response({ + 'is_muted': False, + 'mute_type': '', + 'mute_details': {} + }) diff --git a/openedx/core/djangoapps/django_comment_common/migrations/0010_discussion_muting_models.py b/openedx/core/djangoapps/django_comment_common/migrations/0010_discussion_muting_models.py new file mode 100644 index 000000000000..8a9dcce226dd --- /dev/null +++ b/openedx/core/djangoapps/django_comment_common/migrations/0010_discussion_muting_models.py @@ -0,0 +1,83 @@ +# Generated manually - add discussion muting models + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import jsonfield.fields +import model_utils.fields +import opaque_keys.edx.django.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('django_comment_common', '0009_coursediscussionsettings_reported_content_email_notifications'), + ] + + operations = [ + migrations.CreateModel( + name='DiscussionMute', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('course_id', opaque_keys.edx.django.models.CourseKeyField(help_text='Course in which mute applies', max_length=255)), + ('scope', models.CharField(choices=[('personal', 'Personal'), ('course', 'Course-wide')], default='personal', help_text='Scope of the mute (personal or course-wide)', max_length=10)), + ('reason', models.TextField(blank=True, help_text='Optional reason for muting')), + ('is_active', models.BooleanField(default=True, help_text='Whether the mute is currently active')), + ('muted_at', models.DateTimeField(auto_now_add=True)), + ('unmuted_at', models.DateTimeField(blank=True, null=True)), + ('muted_by', models.ForeignKey(help_text='User performing the mute', on_delete=django.db.models.deletion.CASCADE, related_name='muted_users', to=settings.AUTH_USER_MODEL)), + ('muted_user', models.ForeignKey(help_text='User being muted', on_delete=django.db.models.deletion.CASCADE, related_name='muted_by_users', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'indexes': [ + models.Index(fields=['muted_user', 'course_id', 'is_active'], name='django_comment_muted_user_course_active_idx'), + models.Index(fields=['muted_by', 'course_id', 'scope'], name='django_comment_muted_by_course_scope_idx'), + ], + 'unique_together': {('muted_user', 'muted_by', 'course_id', 'scope')}, + }, + ), + migrations.CreateModel( + name='DiscussionMuteException', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('course_id', opaque_keys.edx.django.models.CourseKeyField(help_text='Course where the exception applies', max_length=255)), + ('exception_user', models.ForeignKey(help_text='User who unmuted the muted_user for themselves', on_delete=django.db.models.deletion.CASCADE, related_name='mute_exceptions', to=settings.AUTH_USER_MODEL)), + ('muted_user', models.ForeignKey(help_text='User who is globally muted in this course', on_delete=django.db.models.deletion.CASCADE, related_name='mute_exceptions_for', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'indexes': [ + models.Index(fields=['muted_user', 'course_id'], name='django_comment_mute_exception_user_course_idx'), + models.Index(fields=['exception_user', 'course_id'], name='django_comment_mute_exception_exception_user_idx'), + ], + 'unique_together': {('muted_user', 'exception_user', 'course_id')}, + }, + ), + migrations.CreateModel( + name='DiscussionModerationLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('action_type', models.CharField(choices=[('mute', 'Mute'), ('unmute', 'Unmute'), ('mute_and_report', 'Mute and Report')], help_text='Type of moderation action performed', max_length=20)), + ('course_id', opaque_keys.edx.django.models.CourseKeyField(help_text='Course where the action was performed', max_length=255)), + ('scope', models.CharField(choices=[('personal', 'Personal'), ('course', 'Course-wide')], default='personal', help_text='Scope of the moderation action', max_length=10)), + ('reason', models.TextField(blank=True, help_text='Optional reason for moderation')), + ('metadata', jsonfield.fields.JSONField(blank=True, default=dict, help_text='Additional metadata for the action')), + ('moderator', models.ForeignKey(help_text='User performing the moderation action', on_delete=django.db.models.deletion.CASCADE, related_name='moderation_logs', to=settings.AUTH_USER_MODEL)), + ('target_user', models.ForeignKey(help_text='User on whom the action was performed', on_delete=django.db.models.deletion.CASCADE, related_name='moderation_actions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'indexes': [ + models.Index(fields=['target_user', 'course_id', 'created'], name='django_comment_moderation_log_target_course_created_idx'), + models.Index(fields=['moderator', 'course_id', 'action_type'], name='django_comment_moderation_log_moderator_course_action_idx'), + models.Index(fields=['course_id', 'action_type', 'created'], name='django_comment_moderation_log_course_action_created_idx'), + ], + }, + ), + ] \ No newline at end of file diff --git a/openedx/core/djangoapps/django_comment_common/migrations/0011_add_timestamped_fields_to_moderationlog.py b/openedx/core/djangoapps/django_comment_common/migrations/0011_add_timestamped_fields_to_moderationlog.py new file mode 100644 index 000000000000..492a0704c34c --- /dev/null +++ b/openedx/core/djangoapps/django_comment_common/migrations/0011_add_timestamped_fields_to_moderationlog.py @@ -0,0 +1,34 @@ +# Migration to add TimeStampedModel fields to existing DiscussionModerationLog table + +from django.db import migrations, models +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_comment_common', '0010_discussion_muting_models'), + ] + + operations = [ + # Add created and modified fields from TimeStampedModel + migrations.AddField( + model_name='discussionmoderationlog', + name='created', + field=model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name='created' + ), + ), + migrations.AddField( + model_name='discussionmoderationlog', + name='modified', + field=model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name='modified' + ), + ), + ] \ No newline at end of file diff --git a/openedx/core/djangoapps/django_comment_common/migrations/0011_update_moderation_log_related_names.py b/openedx/core/djangoapps/django_comment_common/migrations/0011_update_moderation_log_related_names.py new file mode 100644 index 000000000000..9102448e9756 --- /dev/null +++ b/openedx/core/djangoapps/django_comment_common/migrations/0011_update_moderation_log_related_names.py @@ -0,0 +1,34 @@ +# Generated manually to fix related_name conflicts +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_comment_common', '0010_discussion_muting_models'), + ] + + operations = [ + migrations.AlterField( + model_name='discussionmoderationlog', + name='moderator', + field=models.ForeignKey( + help_text='User performing the moderation action', + on_delete=django.db.models.deletion.CASCADE, + related_name='discussion_moderation_logs', + to=settings.AUTH_USER_MODEL + ), + ), + migrations.AlterField( + model_name='discussionmoderationlog', + name='target_user', + field=models.ForeignKey( + help_text='User on whom the action was performed', + on_delete=django.db.models.deletion.CASCADE, + related_name='discussion_moderation_targets', + to=settings.AUTH_USER_MODEL + ), + ), + ] \ No newline at end of file diff --git a/openedx/core/djangoapps/django_comment_common/migrations/0012_merge_20251127_0622.py b/openedx/core/djangoapps/django_comment_common/migrations/0012_merge_20251127_0622.py new file mode 100644 index 000000000000..52248ac9b07f --- /dev/null +++ b/openedx/core/djangoapps/django_comment_common/migrations/0012_merge_20251127_0622.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.7 on 2025-11-27 06:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_comment_common', '0011_add_timestamped_fields_to_moderationlog'), + ('django_comment_common', '0011_update_moderation_log_related_names'), + ] + + operations = [ + ] diff --git a/openedx/core/djangoapps/django_comment_common/models.py b/openedx/core/djangoapps/django_comment_common/models.py index bd7b8fe66e67..798f00236649 100644 --- a/openedx/core/djangoapps/django_comment_common/models.py +++ b/openedx/core/djangoapps/django_comment_common/models.py @@ -8,12 +8,15 @@ from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.db import models +from django.db.models import Q +from django.core.exceptions import ValidationError from django.db.models.signals import post_save from django.dispatch import receiver from django.utils.translation import gettext_noop from jsonfield.fields import JSONField from opaque_keys.edx.django.models import CourseKeyField +from model_utils.models import TimeStampedModel from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager from openedx.core.lib.cache_utils import request_cached @@ -336,3 +339,231 @@ def update_mapping(cls, course_key, discussions_id_map): if not created: mapping_entry.mapping = discussions_id_map mapping_entry.save() + + +class DiscussionMute(TimeStampedModel): + """ + Tracks muted users in discussions. + A mute can be personal or course-wide. + """ + + class Scope(models.TextChoices): + PERSONAL = "personal", "Personal" + COURSE = "course", "Course-wide" + + muted_user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='muted_by_users', + help_text='User being muted', + db_index=True, + ) + muted_by = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='muted_users', + help_text='User performing the mute', + db_index=True, + ) + unmuted_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="mute_unactions", + help_text="User who performed the unmute action" + ) + course_id = CourseKeyField( + max_length=255, + db_index=True, + help_text='Course in which mute applies' + ) + scope = models.CharField( + max_length=10, + choices=Scope.choices, + default=Scope.PERSONAL, + help_text='Scope of the mute (personal or course-wide)', + db_index=True, + ) + reason = models.TextField( + blank=True, + help_text='Optional reason for muting' + ) + is_active = models.BooleanField( + default=True, + help_text='Whether the mute is currently active' + ) + + muted_at = models.DateTimeField(auto_now_add=True) + unmuted_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = 'discussion_user_mute' + constraints = [ + # Only one active personal mute per (muted_by → muted_user) in a course + models.UniqueConstraint( + fields=['muted_user', 'muted_by', 'course_id', 'scope'], + condition=Q(is_active=True, scope='personal'), + name='unique_active_personal_mute' + ), + # Only one active course-wide mute per user per course + models.UniqueConstraint( + fields=['muted_user', 'course_id'], + condition=Q(is_active=True, scope='course'), + name='unique_active_course_mute' + ), + ] + + indexes = [ + models.Index(fields=['muted_user', 'course_id', 'is_active']), + models.Index(fields=['muted_by', 'course_id', 'scope']), + models.Index(fields=['scope', 'course_id', 'is_active']), + ] + + def clean(self): + """Additional validation depending on mute scope.""" + super().clean() + + # Personal mute must have a muted_by different from muted_user + if self.scope == self.Scope.PERSONAL: + if self.muted_by == self.muted_user: + raise ValidationError("Personal mute cannot be self-applied.") + + # Course-wide mute must not be self-applied + if self.scope == self.Scope.COURSE: + if self.muted_by == self.muted_user: + raise ValidationError("Course-wide mute cannot be self-applied.") + + def __str__(self): + return f"{self.muted_by} muted {self.muted_user} in {self.course_id} ({self.scope})" + + +class DiscussionMuteException(TimeStampedModel): + """ + Per-user exception for course-wide mutes. + Allows a specific user to unmute someone while the rest of the course remains muted. + """ + + muted_user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='mute_exceptions_for', + help_text='User who is globally muted in this course', + db_index=True, + ) + exception_user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='mute_exceptions', + help_text='User who unmuted the muted_user for themselves', + db_index=True, + ) + course_id = CourseKeyField( + max_length=255, + help_text='Course where the exception applies', + db_index=True, + ) + + class Meta: + db_table = 'discussion_mute_exception' + unique_together = [ + ['muted_user', 'exception_user', 'course_id'] + ] + indexes = [ + models.Index(fields=['muted_user', 'course_id']), + models.Index(fields=['exception_user', 'course_id']), + ] + + def clean(self): + """Ensure exception is only created if a course-wide mute is active.""" + super().clean() + + has_coursewide_mute = DiscussionMute.objects.filter( + muted_user=self.muted_user, + course_id=self.course_id, + scope=DiscussionMute.Scope.COURSE, + is_active=True + ).exists() + + if not has_coursewide_mute: + raise ValidationError( + "Exception can only be created for an active course-wide mute." + ) + + def __str__(self): + return f"{self.exception_user} unmuted {self.muted_user} in {self.course_id}" + +class DiscussionModerationLog(TimeStampedModel): + """ + Logs moderation actions such as mute, unmute, and mute_and_report. + """ + + class ActionType(models.TextChoices): + MUTE = "mute", "Mute" + UNMUTE = "unmute", "Unmute" + MUTE_AND_REPORT = "mute_and_report", "Mute and Report" + + class Scope(models.TextChoices): + PERSONAL = "personal", "Personal" + COURSE = "course", "Course-wide" + + # Convenience constants for backward compatibility + ACTION_MUTE = ActionType.MUTE + ACTION_UNMUTE = ActionType.UNMUTE + ACTION_MUTE_AND_REPORT = ActionType.MUTE_AND_REPORT + + action_type = models.CharField( + max_length=20, + choices=ActionType.choices, + help_text='Type of moderation action performed', + db_index=True, + ) + target_user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='discussion_moderation_targets', + help_text='User on whom the action was performed', + db_index=True, + ) + moderator = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='discussion_moderation_logs', + help_text='User performing the moderation action', + db_index=True, + ) + course_id = CourseKeyField( + max_length=255, + help_text='Course where the action was performed', + db_index=True, + ) + scope = models.CharField( + max_length=10, + choices=Scope.choices, + default=Scope.PERSONAL, + help_text='Scope of the moderation action' + ) + reason = models.TextField( + blank=True, + help_text='Optional reason for moderation' + ) + metadata = JSONField( + default=dict, + blank=True, + help_text='Additional metadata for the action' + ) + timestamp = models.DateTimeField( + auto_now_add=True, + help_text='When this action was performed' + ) + + class Meta: + db_table = 'discussion_moderation_log' + indexes = [ + models.Index(fields=['target_user', 'course_id', 'timestamp']), + models.Index(fields=['moderator', 'course_id', 'action_type']), + models.Index(fields=['course_id', 'action_type', 'timestamp']), + ] + + def __str__(self): + return f"{self.moderator} performed {self.action_type} on {self.target_user} in {self.course_id}" From 5b2555cd58b68b44f41b27a1adb09d7c741aa3a0 Mon Sep 17 00:00:00 2001 From: naincy128 Date: Thu, 15 Jan 2026 06:08:59 +0000 Subject: [PATCH 2/2] feat: implement discussion mute/unmute feature with user and staff-level controls --- .../discussion/forum_integration.py | 265 +++++ lms/djangoapps/discussion/rest_api/api.py | 178 ++-- lms/djangoapps/discussion/rest_api/forms.py | 2 + .../discussion/rest_api/forum_mute_views.py | 589 +++++++++++ .../discussion/rest_api/permissions.py | 39 +- .../discussion/rest_api/tests/test_api_v2.py | 2 + .../discussion/rest_api/tests/test_forms.py | 6 +- .../rest_api/tests/test_permissions.py | 4 +- .../rest_api/tests/test_serializers.py | 4 +- .../discussion/rest_api/tests/test_views.py | 492 --------- .../rest_api/tests/test_views_v2.py | 7 +- lms/djangoapps/discussion/rest_api/urls.py | 39 +- lms/djangoapps/discussion/rest_api/views.py | 980 ++++++++++-------- .../0010_discussion_muting_models.py | 83 -- ...add_timestamped_fields_to_moderationlog.py | 34 - ...011_update_moderation_log_related_names.py | 34 - .../migrations/0012_merge_20251127_0622.py | 14 - .../django_comment_common/models.py | 231 ----- 18 files changed, 1553 insertions(+), 1450 deletions(-) create mode 100644 lms/djangoapps/discussion/forum_integration.py create mode 100644 lms/djangoapps/discussion/rest_api/forum_mute_views.py delete mode 100644 openedx/core/djangoapps/django_comment_common/migrations/0010_discussion_muting_models.py delete mode 100644 openedx/core/djangoapps/django_comment_common/migrations/0011_add_timestamped_fields_to_moderationlog.py delete mode 100644 openedx/core/djangoapps/django_comment_common/migrations/0011_update_moderation_log_related_names.py delete mode 100644 openedx/core/djangoapps/django_comment_common/migrations/0012_merge_20251127_0622.py diff --git a/lms/djangoapps/discussion/forum_integration.py b/lms/djangoapps/discussion/forum_integration.py new file mode 100644 index 000000000000..685c194df4b9 --- /dev/null +++ b/lms/djangoapps/discussion/forum_integration.py @@ -0,0 +1,265 @@ +""" +Integration utilities for connecting edx-platform with forum models. +This module provides a bridge between the edx-platform discussion app and +the forum service models for muting functionality. +""" + +import logging +from typing import Dict, Any, Optional, Set + +from django.contrib.auth import get_user_model + +from forum import api as forum_api + +log = logging.getLogger(__name__) +User = get_user_model() + + +class ForumMuteService: + """ + Service class to handle mute operations using forum models. + Uses the existing backend selection pattern based on course configuration. + """ + + @staticmethod + def mute_user(muted_user_id: int, muted_by_id: int, course_id: str, + scope: str = "personal", reason: str = "") -> Dict[str, Any]: + """ + Mute a user using forum service. + + Args: + muted_user_id: ID of user to mute + muted_by_id: ID of user performing the mute + course_id: Course ID where mute applies + scope: Mute scope ('personal' or 'course') + reason: Optional reason for muting + + Returns: + Dict containing mute operation result + """ + + try: + result = forum_api.mute_user( + muted_user_id=str(muted_user_id), + muted_by_id=str(muted_by_id), + course_id=course_id, + scope=scope, + reason=reason + ) + return result + except Exception as e: + log.error(f"Error muting user {muted_user_id}: {e}") + raise + + @staticmethod + def unmute_user(muted_user_id: int, unmuted_by_id: int, course_id: str, + scope: str = "personal", muted_by_id: Optional[int] = None) -> Dict[str, Any]: + """ + Unmute a user using forum service. + + Args: + muted_user_id: ID of user to unmute + unmuted_by_id: ID of user performing the unmute + course_id: Course ID where unmute applies + scope: Unmute scope ('personal' or 'course') + muted_by_id: Original muter ID (for personal unmutes) + + Returns: + Dict containing unmute operation result + """ + + try: + result = forum_api.unmute_user( + muted_user_id=str(muted_user_id), + unmuted_by_id=str(unmuted_by_id), + course_id=course_id, + scope=scope, + muted_by_id=str(muted_by_id) if muted_by_id else None + ) + return result + except Exception as e: + log.error(f"Error unmuting user {muted_user_id}: {e}") + raise + + @staticmethod + def mute_and_report_user(muted_user_id: int, muted_by_id: int, course_id: str, + scope: str = "personal", reason: str = "") -> Dict[str, Any]: + """ + Mute and report a user using forum service. + + Args: + muted_user_id: ID of user to mute and report + muted_by_id: ID of user performing the action + course_id: Course ID where action applies + scope: Mute scope ('personal' or 'course') + reason: Reason for muting and reporting + + Returns: + Dict containing operation result + """ + + try: + result = forum_api.mute_and_report_user( + muted_user_id=str(muted_user_id), + muted_by_id=str(muted_by_id), + course_id=course_id, + scope=scope, + reason=reason + ) + return result + except Exception as e: + log.error(f"Error muting and reporting user {muted_user_id}: {e}") + raise + + @staticmethod + def get_user_mute_status(user_id: int, course_id: str, + viewer_id: int) -> Dict[str, Any]: + """ + Get mute status for a user using forum service. + + Args: + user_id: ID of user to check + course_id: Course ID + viewer_id: ID of user requesting the status + + Returns: + Dict containing mute status information + """ + + try: + result = forum_api.get_user_mute_status( + user_id=str(user_id), + course_id=course_id, + viewer_id=str(viewer_id) + ) + return result + except Exception as e: + log.error(f"Error getting mute status for user {user_id}: {e}") + raise + + @staticmethod + def get_all_muted_users_for_course(course_id: str, requester_id: Optional[int] = None, + scope: str = "all") -> Dict[str, Any]: + """ + Get all muted users in a course using forum service. + + Args: + course_id: Course ID + requester_id: ID of user requesting the list + scope: Scope filter ('personal', 'course', or 'all') + + Returns: + Dict containing list of muted users + """ + + try: + result = forum_api.get_all_muted_users_for_course( + course_id=course_id, + requester_id=str(requester_id) if requester_id else None, + scope=scope + ) + return result + except Exception as e: + log.error(f"Error getting muted users for course {course_id}: {e}") + raise + + +class ForumIntegrationService: + """ + Service class for general forum integration operations. + Handles backend-agnostic forum operations. + """ + + @staticmethod + def is_user_muted_by_viewer(target_user_id: int, viewer_id: int, course_id: str) -> bool: + """ + Check if a user is muted by the viewer. + + Args: + target_user_id: ID of the user to check + viewer_id: ID of the viewing user + course_id: Course identifier + + Returns: + True if target user is muted by viewer, False otherwise + """ + try: + mute_status = ForumMuteService.get_user_mute_status( + user_id=target_user_id, + course_id=course_id, + viewer_id=viewer_id + ) + return mute_status.get('is_muted', False) + except Exception as e: # pylint: disable=broad-except + log.warning(f"Error checking mute status: {e}") + return False + + @staticmethod + def get_muted_user_ids_for_course(course_id: str, viewer_id: int) -> Set[int]: + """ + Get set of user IDs that are muted in a course for the given viewer. + Used for content filtering. + + Args: + course_id: Course identifier + viewer_id: ID of the viewing user + + Returns: + Set of user IDs that should be filtered out for this viewer + """ + try: + # Use the forum mute service to get muted users + muted_ids = set() + + # Get course-wide mutes (apply to all users) + course_mutes = ForumMuteService.get_all_muted_users_for_course( + course_id=course_id, + requester_id=None, # No specific requester for course-wide + scope="course" + ) + course_muted_ids = {int(user['muted_user_id']) for user in course_mutes.get('muted_users', [])} + muted_ids.update(course_muted_ids) + + # Get personal mutes done by this specific viewer + personal_mutes = ForumMuteService.get_all_muted_users_for_course( + course_id=course_id, + requester_id=viewer_id, + scope="personal" + ) + # Filter to only include mutes done by this specific viewer + personal_muted_ids = set() + for user in personal_mutes.get('muted_users', []): + muted_by_id = user.get('muted_by_id') + muted_user_id = user.get('muted_user_id') + # Ensure both IDs are converted to int for comparison + try: + muted_by_id = int(muted_by_id) if muted_by_id is not None else None + muted_user_id = int(muted_user_id) if muted_user_id is not None else None + + if muted_by_id == viewer_id and muted_user_id is not None: + personal_muted_ids.add(muted_user_id) + except (ValueError, TypeError): + # Skip invalid data + continue + + muted_ids.update(personal_muted_ids) + + # Ensure the viewer's own ID is never included in the muted list + # since users cannot mute themselves (self-mute prevention) + muted_ids.discard(viewer_id) + + return muted_ids + except Exception as e: # pylint: disable=broad-except + log.warning(f"Error getting muted user IDs: {e}") + return set() + + +# Legacy function aliases for backward compatibility +def is_user_muted(target_user_id: int, viewer_id: int, course_id: str) -> bool: + """Legacy function - use ForumIntegrationService.is_user_muted_by_viewer instead.""" + return ForumIntegrationService.is_user_muted_by_viewer(target_user_id, viewer_id, course_id) + + +def get_muted_user_ids(course_id: str, viewer_id: int) -> Set[int]: + """Legacy function - use ForumIntegrationService.get_muted_user_ids_for_course instead.""" + return ForumIntegrationService.get_muted_user_ids_for_course(course_id, viewer_id) diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index ddfbaa6b885a..0a7ccc82c193 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -36,6 +36,7 @@ from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.courseware.exceptions import CourseAccessRedirect +from lms.djangoapps.discussion.forum_integration import ForumIntegrationService 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.views import is_privileged_user @@ -56,14 +57,14 @@ CommentClient500Error, CommentClientRequestError ) +from openedx.core.djangoapps.user_api.errors import UserNotFound from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_MODERATOR, CourseDiscussionSettings, - Role, - DiscussionMute, + Role ) from openedx.core.djangoapps.django_comment_common.signals import ( comment_created, @@ -135,93 +136,79 @@ can_user_notify_all_learners, is_captcha_enabled, get_captcha_site_key_by_platform ) +log = logging.getLogger(__name__) User = get_user_model() def get_muted_user_ids(request_user, course_key): """ Get list of user IDs that should be muted for the requesting user. - + Args: request_user: The user making the request course_key: The course key - + Returns: set: Set of user IDs that are muted (personal + course-wide) """ try: - # Get personal mutes by this user - personal_mutes = DiscussionMute.objects.filter( - muted_by=request_user, - course_id=course_key, - scope='personal', - is_active=True - ).values_list('muted_user_id', flat=True) - - # Get course-wide mutes (applies to everyone) - course_mutes = DiscussionMute.objects.filter( - course_id=course_key, - scope='course', - is_active=True - ).values_list('muted_user_id', flat=True) - - # Combine both sets - muted_ids = set(personal_mutes) | set(course_mutes) - return muted_ids - - except Exception as e: - # If there's any error, don't filter anything - logging.warning(f"Error getting muted users: {e}") + muted_ids = ForumIntegrationService.get_muted_user_ids_for_course( + course_id=str(course_key), + viewer_id=request_user.id + ) + return set(muted_ids) if muted_ids else set() + except Exception as e: # pylint: disable=broad-exception-caught return set() def filter_muted_content(request_user, course_key, content_list): """ Filter out content from muted users. - + Args: request_user: The user making the request course_key: The course key content_list: List of thread or comment objects - + Returns: list: Filtered list with muted users' content removed """ + if not request_user.is_authenticated: return content_list - + # Get muted user IDs muted_user_ids = get_muted_user_ids(request_user, course_key) - + if not muted_user_ids: return content_list - + # Filter out content from muted users filtered_content = [] - for item in content_list: + for i, item in enumerate(content_list): # Get user_id from the content item (works for both threads and comments) user_id = None - if hasattr(item, 'get') and callable(getattr(item, 'get')): + if hasattr(item, 'get') and callable(item.get): # Dictionary-like object user_id = item.get('user_id') elif hasattr(item, 'user_id'): # Object with user_id attribute user_id = item.user_id - elif hasattr(item, 'get_user_id') and callable(getattr(item, 'get_user_id')): + elif hasattr(item, 'get_user_id') and callable(item.get_user_id): # Object with get_user_id method user_id = item.get_user_id() - - # Convert to int if it's a string + + # Ensure user_id is an integer try: if user_id is not None: user_id = int(user_id) except (ValueError, TypeError): - pass - - # Keep content if user is not muted - if user_id not in muted_user_ids: + user_id = None + + # Keep content if user is not muted OR if it's the user's own content OR if user_id is invalid + if user_id is None or user_id not in muted_user_ids or user_id == request_user.id: filtered_content.append(item) - + return filtered_content ThreadType = Literal["discussion", "question"] @@ -861,12 +848,21 @@ def _get_user_profile_dict(request, usernames): A dict with username as key and user profile details as value. """ - if usernames: - username_list = usernames.split(",") - else: - username_list = [] - user_profile_details = get_account_settings(request, username_list) - return {user['username']: user for user in user_profile_details} + username_list = usernames.split(",") if usernames else [] + + if not username_list: + return {} + + try: + user_profile_details = get_account_settings(request, username_list) + except UserNotFound: + log.warning( + "UserNotFound while fetching account settings for usernames: %s", + username_list + ) + return {} + + return {user["username"]: user for user in user_profile_details} def _user_profile(user_profile): @@ -1005,6 +1001,7 @@ def get_thread_list( order_direction: Literal["desc"] = "desc", requested_fields: Optional[List[Literal["profile_image"]]] = None, count_flagged: bool = None, + include_muted: bool = None, ): """ Return the list of all discussion threads pertaining to the given course @@ -1110,6 +1107,7 @@ def get_thread_list( "sort_key": cc_map.get(order_by), "author_id": author_id, "flagged": flagged, + "include_muted": include_muted, "thread_type": thread_type, "count_flagged": count_flagged, } @@ -1135,12 +1133,15 @@ def get_thread_list( if paginated_results.page != page: raise PageNotFoundError("Page not found (No results on this page).") - # Filter out content from muted users - filtered_threads = filter_muted_content( - request.user, - course_key, - paginated_results.collection - ) + if include_muted: + # Don't filter muted content if explicitly requested + filtered_threads = paginated_results.collection + else: + filtered_threads = filter_muted_content( + request.user, + course_key, + paginated_results.collection + ) results = _serialize_discussion_entities( request, context, filtered_threads, requested_fields, DiscussionEntity.thread @@ -1266,25 +1267,42 @@ def get_learner_active_thread_list(request, course_key, query_params): else: comment_client_user = comment_client.User(id=user_id, course_id=course_key, group_id=group_id) + # Extract include_muted before passing to comment client + include_muted = query_params.pop('include_muted', False) + try: threads, page, num_pages = comment_client_user.active_threads(query_params) threads = set_attribute(threads, "pinned", False) - - # Filter out content from muted users - filtered_threads = filter_muted_content( - request.user, - course_key, - threads - ) - + + # Filter out content from muted users unless include_muted is True + for i, thread in enumerate(threads[:3]): # Log first 3 threads + author = None + if hasattr(thread, 'get') and callable(thread.get): + author = thread.get('author') + elif hasattr(thread, 'author'): + author = thread.author + + if include_muted: + filtered_threads = threads + else: + filtered_threads = filter_muted_content( + request.user, + course_key, + threads + ) + results = _serialize_discussion_entities( request, context, filtered_threads, {'profile_image'}, DiscussionEntity.thread ) + + # Use appropriate count for pagination based on include_muted + count_for_pagination = len(filtered_threads) if not include_muted else len(threads) + paginator = DiscussionAPIPagination( request, page, num_pages, - len(threads) + count_for_pagination ) return paginator.get_paginated_response({ "results": results, @@ -1300,7 +1318,7 @@ def get_learner_active_thread_list(request, course_key, query_params): def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=False, requested_fields=None, - merge_question_type_responses=False): + merge_question_type_responses=False, include_muted=False): """ Return the list of comments in the given thread. @@ -1376,14 +1394,18 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals raise PageNotFoundError("Page not found (No results on this page).") num_pages = (resp_total + page_size - 1) // page_size if resp_total else 1 - # Filter out content from muted users - filtered_responses = filter_muted_content( - request.user, - context["course"].id, - responses - ) + if include_muted: + filtered_responses = responses + else: + filtered_responses = filter_muted_content( + request.user, + context["course"].id, + responses + ) - results = _serialize_discussion_entities(request, context, filtered_responses, requested_fields, DiscussionEntity.comment) + results = _serialize_discussion_entities( + request, context, filtered_responses, requested_fields, DiscussionEntity.comment + ) paginator = DiscussionAPIPagination(request, page, num_pages, resp_total) track_thread_viewed_event(request, context["course"], cc_thread, from_mfe_sidebar) @@ -2026,6 +2048,22 @@ def get_course_discussion_user_stats( course_stats_response = get_course_user_stats(course_key, params) + # Filter out muted users from regular learner list (user-specific filtering) + if request.user.is_authenticated: + muted_user_ids = get_muted_user_ids(request.user, course_key) + if muted_user_ids: + # Convert user IDs to usernames to filter + muted_usernames = set( + User.objects.filter(id__in=muted_user_ids).values_list('username', flat=True) + ) + # Filter out muted users from the stats + course_stats_response["user_stats"] = [ + stat for stat in course_stats_response["user_stats"] + if stat.get('username') not in muted_usernames + ] + # Update the 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/forms.py b/lms/djangoapps/discussion/rest_api/forms.py index 8cc7127645b2..6074420bffba 100644 --- a/lms/djangoapps/discussion/rest_api/forms.py +++ b/lms/djangoapps/discussion/rest_api/forms.py @@ -58,6 +58,7 @@ class ThreadListGetForm(_PaginationForm): ) count_flagged = ExtendedNullBooleanField(required=False) flagged = ExtendedNullBooleanField(required=False) + include_muted = ExtendedNullBooleanField(required=False) view = ChoiceField( choices=[(choice, choice) for choice in ["unread", "unanswered", "unresponded"]], required=False, @@ -131,6 +132,7 @@ class CommentListGetForm(_PaginationForm): endorsed = ExtendedNullBooleanField(required=False) requested_fields = MultiValueField(required=False) merge_question_type_responses = BooleanField(required=False) + include_muted = BooleanField(required=False) class UserCommentListGetForm(_PaginationForm): diff --git a/lms/djangoapps/discussion/rest_api/forum_mute_views.py b/lms/djangoapps/discussion/rest_api/forum_mute_views.py new file mode 100644 index 000000000000..74c54e07e20f --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/forum_mute_views.py @@ -0,0 +1,589 @@ +""" +Updated Mute Views using Forum Service Integration. +These views replace the existing mute functionality to use the forum models and API. +""" + +import logging +from urllib.parse import unquote + +from django.contrib.auth import get_user_model +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser + +from lms.djangoapps.discussion.rest_api.permissions import CanMuteUsers, can_mute_user, can_unmute_user +from lms.djangoapps.discussion.rest_api.serializers import ( + MuteRequestSerializer, + UnmuteRequestSerializer, + MuteAndReportRequestSerializer +) +from lms.djangoapps.discussion.forum_integration import ForumMuteService +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin + +log = logging.getLogger(__name__) +User = get_user_model() + + +class ForumMuteUserView(DeveloperErrorViewMixin, APIView): + """ + API endpoint to mute a user in discussions using forum service. + + **POST /api/discussion/v1/moderation/forum-mute/** + + Allows users to mute other users either personally or course-wide (if they have permissions). + """ + authentication_classes = [ + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ] + permission_classes = [CanMuteUsers] + + def post(self, request, course_id): + """Mute a user in discussions using forum service""" + + # URL decode the course_id parameter to handle browser encoding + course_id = unquote(course_id) + + # Handle frontend format (username, is_course_wide) vs backend format (muted_user_id, scope) + raw_data = request.data.copy() + + # Check if this is frontend format + if 'username' in raw_data and 'muted_user_id' not in raw_data: + # Frontend format - transform to backend format + username = raw_data.get('username') + is_course_wide = raw_data.get('is_course_wide', False) + + if not username: + return Response( + {"status": "error", "message": "Username is required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + target_user = User.objects.get(username=username) + transformed_data = { + 'muted_user_id': target_user.id, + 'course_id': course_id, + 'scope': 'course' if is_course_wide else 'personal', + 'reason': raw_data.get('reason', '') + } + except User.DoesNotExist: + return Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + else: + # Backend format - use as is + transformed_data = raw_data + + # Validate request data + serializer = MuteRequestSerializer(data=transformed_data) + if not serializer.is_valid(): + return Response( + {"status": "error", "message": "Invalid request data", "errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST + ) + + data = serializer.validated_data + + # Get target user + try: + target_user = User.objects.get(id=data['muted_user_id']) + except User.DoesNotExist: + return Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Parse course key + try: + course_key = CourseKey.from_string(data['course_id']) + except Exception: # pylint: disable=broad-except + return Response( + {"status": "error", "message": "Invalid course ID"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Prevent self-muting + if request.user.id == target_user.id: + return Response( + {"status": "error", "message": "Users cannot mute themselves"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check permissions + if not can_mute_user(request.user, target_user, course_key, data.get('scope', 'personal')): + return Response( + {"status": "error", "message": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN + ) + + # Use forum service to handle mute operation + try: + result = ForumMuteService.mute_user( + muted_user_id=target_user.id, + muted_by_id=request.user.id, + course_id=str(course_key), + scope=data.get('scope', 'personal'), + reason=data.get('reason', '') + ) + except Exception as e: # pylint: disable=broad-except + log.error(f"Error during mute operation: {e}") + if "already muted" in str(e).lower(): + return Response( + {"status": "error", "message": "User is already muted"}, + status=status.HTTP_400_BAD_REQUEST + ) + return Response( + {"status": "error", "message": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # Prepare response + response_data = { + 'status': 'success', + 'message': 'User muted successfully', + 'result': result, + } + + return Response(response_data, status=status.HTTP_201_CREATED) + + +class ForumUnmuteUserView(DeveloperErrorViewMixin, APIView): + """ + API endpoint to unmute a user in discussions using forum service. + + **POST /api/discussion/v1/moderation/forum-unmute/** + + Allows users to unmute previously muted users. + """ + authentication_classes = [ + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ] + permission_classes = [CanMuteUsers] + + def post(self, request, course_id): + """Unmute a user in discussions using forum service""" + + # URL decode the course_id parameter to handle browser encoding + course_id = unquote(course_id) + + # Handle frontend format transformation if needed + raw_data = request.data.copy() + + if 'username' in raw_data and 'muted_user_id' not in raw_data: + username = raw_data.get('username') + is_course_wide = raw_data.get('is_course_wide', False) + + log.info(f"[UNMUTE_DEBUG] Received username={username}, is_course_wide={is_course_wide}") + if not username: + return Response( + {"status": "error", "message": "Username is required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + target_user = User.objects.get(username=username) + transformed_data = { + 'muted_user_id': target_user.id, + 'course_id': course_id, + 'scope': 'course' if is_course_wide else 'personal', + } + except User.DoesNotExist: + return Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + else: + transformed_data = raw_data + + # Validate request data + serializer = UnmuteRequestSerializer(data=transformed_data) + if not serializer.is_valid(): + return Response( + {"status": "error", "message": "Invalid request data", "errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST + ) + + data = serializer.validated_data + + # Get target user + try: + target_user = User.objects.get(id=data['muted_user_id']) + except User.DoesNotExist: + return Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Parse course key + try: + course_key = CourseKey.from_string(data['course_id']) + except Exception: # pylint: disable=broad-except + return Response( + {"status": "error", "message": "Invalid course ID"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Prevent self-unmuting + if request.user.id == target_user.id: + return Response( + {"status": "error", "message": "Users cannot unmute themselves"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check permissions + if not can_unmute_user(request.user, target_user, course_key, data.get('scope', 'personal')): + return Response( + {"status": "error", "message": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN + ) + + log.info( + '[UNMUTE_DEBUG] scope=%s, muted_user_id=%s, course_id=%s', + data.get("scope", "personal"), target_user.id, str(course_key) + ) + # Use forum service to handle unmute operation + try: + result = ForumMuteService.unmute_user( + muted_user_id=target_user.id, + unmuted_by_id=request.user.id, + course_id=str(course_key), + scope=data.get('scope', 'personal'), + muted_by_id=None # Forum service will handle the logic + ) + except Exception as e: # pylint: disable=broad-except + log.error(f"Error during unmute operation: {e}") + if "no active mute found" in str(e).lower(): + return Response( + {"status": "error", "message": "No active mute found"}, + status=status.HTTP_404_NOT_FOUND + ) + return Response( + {"status": "error", "message": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + return Response({ + 'status': 'success', + 'message': 'User unmuted successfully', + 'result': result, + }, status=status.HTTP_200_OK) + + +class ForumMuteAndReportView(DeveloperErrorViewMixin, APIView): + """ + API endpoint to mute a user and report their content using forum service. + + **POST /api/discussion/v1/moderation/forum-mute-and-report/** + """ + authentication_classes = [ + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ] + permission_classes = [CanMuteUsers] + + def post(self, request, course_id): + """Mute a user and report their content using forum service""" + + # URL decode the course_id parameter to handle browser encoding + course_id = unquote(course_id) + + # Parse course key first for permission checks + try: + course_key = CourseKey.from_string(course_id) + except Exception: # pylint: disable=broad-except + return Response( + {"status": "error", "message": "Invalid course ID"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Handle frontend format transformation if needed + raw_data = request.data.copy() + + if 'username' in raw_data and 'muted_user_id' not in raw_data: + username = raw_data.get('username') + is_course_wide = raw_data.get('is_course_wide', False) + + if not username: + return Response( + {"status": "error", "message": "Username is required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + target_user = User.objects.get(username=username) + transformed_data = { + 'muted_user_id': target_user.id, + 'course_id': course_id, + 'scope': 'course' if is_course_wide else 'personal', + 'reason': raw_data.get('reason', ''), + 'thread_id': raw_data.get('thread_id', ''), + 'comment_id': raw_data.get('comment_id', ''), + } + except User.DoesNotExist: + return Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + else: + transformed_data = raw_data + + # Validate request data + serializer = MuteAndReportRequestSerializer(data=transformed_data) + if not serializer.is_valid(): + return Response( + {"status": "error", "message": "Invalid request data", "errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST + ) + + data = serializer.validated_data + + # Get target user + try: + target_user = User.objects.get(id=data['muted_user_id']) + except User.DoesNotExist: + return Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Prevent self-muting + if request.user.id == target_user.id: + return Response( + {"status": "error", "message": "Users cannot mute themselves"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check permissions + if not can_mute_user(request.user, target_user, course_key, data.get('scope', 'personal')): + return Response( + {"status": "error", "message": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN + ) + + # Use forum service to handle mute and report operation + try: + result = ForumMuteService.mute_and_report_user( + muted_user_id=target_user.id, + muted_by_id=request.user.id, + course_id=str(course_key), + scope=data.get('scope', 'personal'), + reason=data.get('reason', '') + ) + except Exception as e: # pylint: disable=broad-except + log.error(f"Error during mute and report operation: {e}") + if "already muted" in str(e).lower(): + return Response( + {"status": "error", "message": "User is already muted"}, + status=status.HTTP_400_BAD_REQUEST + ) + return Response( + {"status": "error", "message": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + return Response({ + 'status': 'success', + 'message': 'User muted and reported successfully', + 'result': result, + }, status=status.HTTP_201_CREATED) + + +class ForumMutedUsersListView(DeveloperErrorViewMixin, APIView): + """ + API endpoint to get the list of muted users using forum service. + + **GET /api/discussion/v1/moderation/forum-muted-users/{course_id}/** + + Query Parameters: + - scope: Filter by mute scope ('personal', 'course', or 'all'). Default: 'all' + - muted_by: Filter by user ID who performed the mute operation. Default: current user + - include_usernames: Include username resolution. Default: true + """ + authentication_classes = [ + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ] + permission_classes = [CanMuteUsers] + + def get(self, request, course_id): + """Get list of muted users using forum service""" + + # URL decode the course_id parameter + course_id = unquote(course_id) + + # Parse course key + try: + course_key = CourseKey.from_string(course_id) + except Exception: # pylint: disable=broad-except + return Response( + {"status": "error", "message": "Invalid course ID"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get query parameters + scope = request.query_params.get('scope', 'all') + muted_by = request.query_params.get('muted_by') + include_usernames = request.query_params.get('include_usernames', 'true').lower() == 'true' + + # Determine the requester ID for filtering + # If muted_by is specified, use that; otherwise use current user for personal scope filtering + if muted_by: + try: + requester_id = int(muted_by) + except (ValueError, TypeError): + return Response( + {"status": "error", "message": "Invalid muted_by parameter"}, + status=status.HTTP_400_BAD_REQUEST + ) + else: + # For personal scope, default to current user; for course scope, use None + requester_id = request.user.id if scope in ['personal', 'all'] else None + + # Use forum service to get muted users + try: + result = ForumMuteService.get_all_muted_users_for_course( + course_id=str(course_key), + requester_id=requester_id, + scope=scope + ) + + # Process the result to include additional information for frontend + muted_users = result.get('muted_users', []) + processed_users = [] + + for user_data in muted_users: + # CRITICAL FIX: Only include users muted by the requester + # If requester_id is set, filter to only mutes by that user + # This ensures "Unmute" only appears for users YOU muted, not users muted by others + if requester_id and str(user_data.get('muted_by_id')) != str(requester_id): + continue + + user_info = { + 'muted_user_id': user_data.get('muted_user_id'), + 'muted_by_id': user_data.get('muted_by_id'), + 'scope': user_data.get('scope'), + 'is_active': user_data.get('is_active', True), + 'created_at': user_data.get('created_at'), + 'reason': user_data.get('reason', ''), + } + + # Add username resolution if requested + if include_usernames: + muted_user_id = user_data.get('muted_user_id') + if muted_user_id: + try: + user_obj = User.objects.get(id=muted_user_id) + user_info['username'] = user_obj.username + user_info['email'] = user_obj.email if request.user.is_staff else '' + except User.DoesNotExist: + user_info['username'] = f'User{muted_user_id}' + + # Add muted_by username if available + muted_by_id = user_data.get('muted_by_id') + if muted_by_id and include_usernames: + try: + muted_by_user = User.objects.get(id=muted_by_id) + user_info['muted_by_username'] = muted_by_user.username + except User.DoesNotExist: + user_info['muted_by_username'] = f'User{muted_by_id}' + + processed_users.append(user_info) + + # Filter by scope if needed (additional frontend-friendly filtering) + if scope != 'all': + processed_users = [ + user for user in processed_users + if user.get('scope') == scope + ] + + # Separate by scope for frontend convenience + personal_muted_users = [ + user for user in processed_users + if user.get('scope') == 'personal' + ] + course_wide_muted_users = [ + user for user in processed_users + if user.get('scope') == 'course' + ] + + return Response({ + 'status': 'success', + 'muted_users': processed_users, + 'personal_muted_users': personal_muted_users, + 'course_wide_muted_users': course_wide_muted_users, + 'total_count': len(processed_users), + 'personal_count': len(personal_muted_users), + 'course_wide_count': len(course_wide_muted_users), + 'requester_id': requester_id, + 'course_id': str(course_key), + 'scope_filter': scope, + }, status=status.HTTP_200_OK) + + except Exception as e: # pylint: disable=broad-except + log.error(f"Error getting muted users for course {course_id}: {e}") + return Response( + {"status": "error", "message": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class ForumMuteStatusView(DeveloperErrorViewMixin, APIView): + """ + API endpoint to get mute status for a user using forum service. + + **GET /api/discussion/v1/moderation/forum-mute-status/{user_id}/** + """ + authentication_classes = [ + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ] + permission_classes = [CanMuteUsers] + + def get(self, request, course_id, user_id): + """Get mute status for a user using forum service""" + + # URL decode parameters + course_id = unquote(course_id) + + # Parse course key + try: + course_key = CourseKey.from_string(course_id) + except Exception: # pylint: disable=broad-except + return Response( + {"status": "error", "message": "Invalid course ID"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Validate user_id + try: + user_id = int(user_id) + except (ValueError, TypeError): + return Response( + {"status": "error", "message": "Invalid user ID"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Use forum service to get mute status + try: + result = ForumMuteService.get_user_mute_status( + user_id=user_id, + course_id=str(course_key), + viewer_id=request.user.id + ) + except Exception as e: # pylint: disable=broad-except + log.error(f"Error getting mute status for user {user_id}: {e}") + return Response( + {"status": "error", "message": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + return Response({ + 'status': 'success', + 'result': result, + }, status=status.HTTP_200_OK) diff --git a/lms/djangoapps/discussion/rest_api/permissions.py b/lms/djangoapps/discussion/rest_api/permissions.py index c3cdf6c405b2..f356efa31e83 100644 --- a/lms/djangoapps/discussion/rest_api/permissions.py +++ b/lms/djangoapps/discussion/rest_api/permissions.py @@ -110,6 +110,7 @@ def get_editable_fields(cc_content: Union[Thread, Comment], context: Dict) -> Se "closed": is_thread and has_moderation_privilege, "close_reason_code": is_thread and has_moderation_privilege, "pinned": is_thread and (has_moderation_privilege or is_staff_or_admin), + "muted": is_thread and (has_moderation_privilege or is_staff_or_admin), "read": is_thread, } if is_thread: @@ -125,6 +126,7 @@ def get_editable_fields(cc_content: Union[Thread, Comment], context: Dict) -> Se "raw_body": has_moderation_privilege or is_author, "edit_reason_code": has_moderation_privilege and not is_author, "following": is_thread, + "muted_by": is_thread and (has_moderation_privilege or is_staff_or_admin), "topic_id": is_thread and (is_author or has_moderation_privilege), "type": is_thread and (is_author or has_moderation_privilege), "title": is_thread and (is_author or has_moderation_privilege), @@ -233,42 +235,42 @@ def has_permission(self, request, view): def can_mute_user(requesting_user, target_user, course_id, scope='personal'): """ Check if the requesting user can mute the target user. - + Args: requesting_user: User attempting to mute target_user: User to be muted course_id: Course context scope: 'personal' or 'course' - + Returns: bool: True if mute is allowed, False otherwise """ # Users cannot mute themselves if requesting_user.id == target_user.id: return False - + # Check if target user is staff - staff cannot be muted by learners target_is_staff = ( CourseStaffRole(course_id).has_user(target_user) or CourseInstructorRole(course_id).has_user(target_user) or GlobalStaff().has_user(target_user) ) - + # Check if requesting user has privileges requesting_is_staff = ( CourseStaffRole(course_id).has_user(requesting_user) or CourseInstructorRole(course_id).has_user(requesting_user) or GlobalStaff().has_user(requesting_user) ) - + # Learners cannot mute staff if target_is_staff and not requesting_is_staff: return False - + # For course-wide muting, user must be staff if scope == 'course' and not requesting_is_staff: return False - + # Check if user is enrolled in course if not requesting_is_staff: try: @@ -279,7 +281,7 @@ def can_mute_user(requesting_user, target_user, course_id, scope='personal'): ) except CourseEnrollment.DoesNotExist: return False - + return True @@ -321,12 +323,12 @@ def can_unmute_user(requesting_user, target_user, course_id, scope='personal'): def can_view_muted_users(requesting_user, course_id, scope='personal'): """ Check if the requesting user can view muted users list. - + Args: requesting_user: User attempting to view muted users course_id: Course context scope: 'personal', 'course', or 'all' - + Returns: bool: True if viewing is allowed, False otherwise """ @@ -336,15 +338,15 @@ def can_view_muted_users(requesting_user, course_id, scope='personal'): CourseInstructorRole(course_id).has_user(requesting_user) or GlobalStaff().has_user(requesting_user) ) - + # Staff can view all scopes if requesting_is_staff: return True - + # Learners can only view their personal mutes if scope in ['course', 'all']: return False - + return True @@ -352,21 +354,21 @@ class CanMuteUsers(permissions.BasePermission): """ Permission to check if user can mute other users. """ - + def has_permission(self, request, view): """Check basic mute permissions""" if not request.user.is_authenticated: return False - + course_id = request.data.get('course_id') or view.kwargs.get('course_id') if not course_id: return False - + try: course_key = CourseKey.from_string(course_id) - except: + except Exception: # pylint: disable=broad-except return False - + # Check course enrollment try: enrollment = CourseEnrollment.objects.get( @@ -377,4 +379,3 @@ def has_permission(self, request, view): return bool(enrollment) except CourseEnrollment.DoesNotExist: return False - diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index 53c12454aec9..eb15cd9572c3 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -371,6 +371,8 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit): "closed", "copy_link", "following", + "muted", + "muted_by", "pinned", "raw_body", "read", diff --git a/lms/djangoapps/discussion/rest_api/tests/test_forms.py b/lms/djangoapps/discussion/rest_api/tests/test_forms.py index 3be65964b6b9..89fb79eca28b 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_forms.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_forms.py @@ -77,7 +77,8 @@ def test_basic(self): 'view': '', 'order_by': 'last_activity_at', 'order_direction': 'desc', - 'requested_fields': set() + 'requested_fields': set(), + 'include_muted': None } def test_topic_id(self): @@ -208,7 +209,8 @@ def test_basic(self): 'page_size': 13, 'flagged': False, 'requested_fields': set(), - 'merge_question_type_responses': False + 'merge_question_type_responses': False, + 'include_muted': False } def test_missing_thread_id(self): diff --git a/lms/djangoapps/discussion/rest_api/tests/test_permissions.py b/lms/djangoapps/discussion/rest_api/tests/test_permissions.py index 058394d3f7d8..f64f69bf55c6 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_permissions.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_permissions.py @@ -69,7 +69,7 @@ def test_thread( "read", "title", "topic_id", "type" } if is_privileged: - expected |= {"closed", "pinned", "close_reason_code", "voted"} + expected |= {"closed", "pinned", "close_reason_code", "voted", "muted", "muted_by"} if is_privileged and is_cohorted: expected |= {"group_id"} if allow_anonymous: @@ -125,7 +125,7 @@ def test_thread( if has_moderation_privilege: expected |= {"closed", "close_reason_code"} if has_moderation_privilege or is_staff_or_admin: - expected |= {"pinned"} + expected |= {"pinned", "muted", "muted_by"} if has_moderation_privilege or not is_author or is_staff_or_admin: expected |= {"voted"} if has_moderation_privilege and not is_author: diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index a1443252a1ce..8b3b137d1147 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -294,7 +294,7 @@ def test_closed_by_label_field(self, role, visible): editable_fields.remove("voted") editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) elif role == FORUM_ROLE_MODERATOR: - editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', + editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'muted', 'muted_by', 'pinned', 'raw_body', 'title', 'topic_id', 'type']) expected = self.expected_thread_data({ "author": author.username, @@ -352,7 +352,7 @@ def test_edit_by_label_field(self, role, visible): editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) elif role == FORUM_ROLE_MODERATOR: - editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', + editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'muted', 'muted_by', 'pinned', 'raw_body', 'title', 'topic_id', 'type']) expected = self.expected_thread_data({ diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index e73dfdb54d1c..e4d46168c46d 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -55,9 +55,6 @@ from openedx.core.djangoapps.django_comment_common.models import ( CourseDiscussionSettings, Role, - DiscussionMuteException, - DiscussionModerationLog, - DiscussionMute, ) from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user @@ -2029,492 +2026,3 @@ def test_with_username_param_case(self, username_search_string): """ response = get_usernames_from_search_string(self.course_key, username_search_string, 1, 1) assert response == (username_search_string.lower(), 1, 1) - - -@ddt.ddt -class DiscussionModerationTestCase(DiscussionAPIViewTestMixin, ModuleStoreTestCase): - """ - Test suite for discussion moderation functionality (mute/unmute). - Tests all 11 requirements from the user's specification. - """ - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - - # Create additional users for testing - self.target_learner = UserFactory.create(password=self.password) - self.target_learner.profile.year_of_birth = 1970 - self.target_learner.profile.save() - CourseEnrollmentFactory.create(user=self.target_learner, course_id=self.course.id) - - self.other_learner = UserFactory.create(password=self.password) - self.other_learner.profile.year_of_birth = 1970 - self.other_learner.profile.save() - CourseEnrollmentFactory.create(user=self.other_learner, course_id=self.course.id) - - # Create staff user - self.staff_user = UserFactory.create(password=self.password) - self.staff_user.profile.year_of_birth = 1970 - self.staff_user.profile.save() - CourseEnrollmentFactory.create(user=self.staff_user, course_id=self.course.id) - CourseStaffRole(self.course.id).add_users(self.staff_user) - - # Create instructor user - self.instructor = UserFactory.create(password=self.password) - self.instructor.profile.year_of_birth = 1970 - self.instructor.profile.save() - CourseEnrollmentFactory.create(user=self.instructor, course_id=self.course.id) - CourseInstructorRole(self.course.id).add_users(self.instructor) - - # URLs - self.mute_url = reverse('mute_user', kwargs={'course_id': str(self.course.id)}) - self.unmute_url = reverse('unmute_user', kwargs={'course_id': str(self.course.id)}) - self.mute_and_report_url = reverse('mute_and_report', kwargs={'course_id': str(self.course.id)}) - self.muted_users_url = reverse('muted_users_list', kwargs={'course_id': str(self.course.id)}) - self.mute_status_url = reverse('mute_status', kwargs={'course_id': str(self.course.id)}) - - # Set url for DiscussionAPIViewTestMixin compatibility - self.url = self.mute_url - - def _create_test_mute(self, muted_user, muted_by, scope='personal', is_active=True): - """Helper method to create a mute record for testing""" - return DiscussionMute.objects.create( - muted_user=muted_user, - muted_by=muted_by, - course_id=self.course.id, - scope=scope, - reason='Test reason', - is_active=is_active - ) - - def _login_user(self, user): - """Helper method to login a user""" - self.client.login(username=user.username, password=self.password) - - def test_basic(self): - """Basic test for DiscussionAPIViewTestMixin compatibility""" - self._login_user(self.user) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id), - 'scope': 'personal' - } - response = self.client.post(self.mute_url, data, format='json') - assert response.status_code in [status.HTTP_201_CREATED, status.HTTP_200_OK] - - # Test 1: Personal Mute (Learner → Learner & Staff → Learner) - def test_personal_mute_learner_to_learner(self): - """Test that learners can perform personal mutes on other learners""" - - self._login_user(self.user) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id), - 'scope': 'personal', - 'reason': 'Testing personal mute' - } - - response = self.client.post(self.mute_url, data, format='json') - - # Assert response is successful - assert response.status_code == status.HTTP_201_CREATED - response_data = response.json() - assert response_data['status'] == 'success' - assert response_data['message'] == 'User muted successfully' - - # Assert mute record was created - mute = DiscussionMute.objects.get( - muted_user=self.target_learner, - muted_by=self.user, - course_id=self.course.id, - scope='personal' - ) - assert mute.is_active is True - assert mute.reason == 'Testing personal mute' - - # Assert moderation log was created - log = DiscussionModerationLog.objects.get( - action_type=DiscussionModerationLog.ACTION_MUTE, - target_user=self.target_learner, - moderator=self.user, - course_id=self.course.id - ) - assert log.scope == 'personal' - - def test_personal_mute_staff_to_learner(self): - """Test that staff can perform personal mutes on learners""" - - self._login_user(self.staff_user) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id), - 'scope': 'personal', - 'reason': 'Staff personal mute' - } - - response = self.client.post(self.mute_url, data, format='json') - - assert response.status_code == status.HTTP_201_CREATED - assert DiscussionMute.objects.filter( - muted_user=self.target_learner, - muted_by=self.staff_user, - scope='personal' - ).exists() - - # Test 2: Self-Mute Prevention - def test_learner_cannot_mute_self(self): - """Test that learners cannot mute themselves""" - self._login_user(self.user) - data = { - 'muted_user_id': self.user.id, - 'course_id': str(self.course.id), - 'scope': 'personal' - } - - response = self.client.post(self.mute_url, data, format='json') - - assert response.status_code == status.HTTP_400_BAD_REQUEST - response_data = response.json() - assert response_data['status'] == 'error' - assert 'cannot mute themselves' in response_data['message'] - - def test_staff_cannot_mute_self(self): - """Test that staff cannot mute themselves""" - self._login_user(self.staff_user) - data = { - 'muted_user_id': self.staff_user.id, - 'course_id': str(self.course.id), - 'scope': 'course' - } - - response = self.client.post(self.mute_url, data, format='json') - - assert response.status_code == status.HTTP_400_BAD_REQUEST - response_data = response.json() - assert 'cannot mute themselves' in response_data['message'] - - # Test 3: Course-Level Mute (Staff Only) - def test_course_level_mute_by_staff(self): - """Test that staff can perform course-level mutes""" - - self._login_user(self.staff_user) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id), - 'scope': 'course', - 'reason': 'Course-wide mute for disruptive behavior' - } - - response = self.client.post(self.mute_url, data, format='json') - - assert response.status_code == status.HTTP_201_CREATED - mute = DiscussionMute.objects.get( - muted_user=self.target_learner, - muted_by=self.staff_user, - scope='course' - ) - assert mute.is_active is True - - def test_learner_cannot_do_course_level_mute(self): - """Test that learners cannot perform course-level mutes""" - self._login_user(self.user) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id), - 'scope': 'course' - } - - response = self.client.post(self.mute_url, data, format='json') - - assert response.status_code == status.HTTP_403_FORBIDDEN - - # Test 4: Prevent Muting Staff - def test_learner_cannot_mute_staff(self): - """Test that learners cannot mute staff members""" - self._login_user(self.user) - data = { - 'muted_user_id': self.staff_user.id, - 'course_id': str(self.course.id), - 'scope': 'personal' - } - - response = self.client.post(self.mute_url, data, format='json') - - assert response.status_code == status.HTTP_403_FORBIDDEN - - def test_learner_cannot_mute_instructor(self): - """Test that learners cannot mute instructors""" - self._login_user(self.user) - data = { - 'muted_user_id': self.instructor.id, - 'course_id': str(self.course.id), - 'scope': 'personal' - } - - response = self.client.post(self.mute_url, data, format='json') - - assert response.status_code == status.HTTP_403_FORBIDDEN - - # Test 5: Mute + Report - @mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.Thread.find') - def test_mute_and_report_with_thread(self, mock_thread_find): - """Test mute and report functionality with thread ID""" - - # Mock the thread - mock_thread = mock.Mock() - mock_thread.flagAbuse = mock.Mock() - mock_thread_find.return_value = mock_thread - - self._login_user(self.user) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id), - 'scope': 'personal', - 'reason': 'Inappropriate content', - 'thread_id': 'test_thread_123' - } - - response = self.client.post(self.mute_and_report_url, data, format='json') - - assert response.status_code == status.HTTP_201_CREATED - - # Assert mute record was created - assert DiscussionMute.objects.filter( - muted_user=self.target_learner, - muted_by=self.user - ).exists() - - # Assert moderation log was created - log = DiscussionModerationLog.objects.get( - action_type=DiscussionModerationLog.ACTION_MUTE_AND_REPORT, - target_user=self.target_learner - ) - assert log.metadata['thread_id'] == 'test_thread_123' - - # Test 6: Personal Unmute - def test_personal_unmute(self): - """Test that users can unmute their own personal mutes, but not others'.""" - - # Create an existing personal mute by self.user - mute = self._create_test_mute(self.target_learner, self.user, 'personal') - # Login as the user who muted - self._login_user(self.user) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id), - 'scope': 'personal' - } - # User should be able to unmute - response = self.client.post(self.unmute_url, data, format='json') - assert response.status_code == status.HTTP_200_OK - response_data = response.json() - assert response_data['status'] == 'success' - assert response_data.get('unmute_type') == 'deactivated' - # Assert mute was deactivated - mute.refresh_from_db() - assert mute.is_active is False - - # Assert unmute log was created - assert DiscussionModerationLog.objects.filter( - action_type=DiscussionModerationLog.ACTION_UNMUTE, - target_user=self.target_learner, - moderator=self.user - ).exists() - - # --- Negative test: other user cannot unmute this personal mute --- - other_user = self.other_learner - self._login_user(other_user) - response = self.client.post(self.unmute_url, data, format='json') - assert response.status_code in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) - response_data = response.json() - msg = response_data.get('message', '').lower() - assert any(sub in msg for sub in ('permission', 'no active mute')) - - # Test 7: Course-Level Mute With Personal Unmute Exception - def test_course_mute_with_personal_unmute_exception(self): - """Test that personal unmute creates exception for course-wide mute""" - - # Create a course-wide mute by staff - self._create_test_mute(self.target_learner, self.staff_user, 'course') - - # Learner tries to unmute personally - self._login_user(self.user) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id), - 'scope': 'personal' - } - - response = self.client.post(self.unmute_url, data, format='json') - - assert response.status_code == status.HTTP_201_CREATED - response_data = response.json() - assert response_data['unmute_type'] == 'exception' - - # Assert exception was created - exception = DiscussionMuteException.objects.get( - muted_user=self.target_learner, - exception_user=self.user, - course_id=self.course.id - ) - assert exception is not None - - # Test 8: List Muted Users - def test_list_personal_muted_users(self): - """Test listing personal muted users""" - # Create some mutes - self._create_test_mute(self.target_learner, self.user, 'personal') - self._create_test_mute(self.other_learner, self.user, 'personal') - - self._login_user(self.user) - response = self.client.get(self.muted_users_url + '?scope=personal') - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data['count'] == 2 - assert len(data['results']) == 2 - - def test_list_course_muted_users_staff_only(self): - """Test that only staff can list course-wide muted users""" - # Create course-wide mute - self._create_test_mute(self.target_learner, self.staff_user, 'course') - - # Learner tries to access course mutes - self._login_user(self.user) - response = self.client.get(self.muted_users_url + '?scope=course') - - assert response.status_code == status.HTTP_403_FORBIDDEN - - # Staff can access course mutes - self._login_user(self.staff_user) - response = self.client.get(self.muted_users_url + '?scope=course') - - assert response.status_code == status.HTTP_200_OK - - # Test 9: Mute Status - def test_mute_status_personal_mute(self): - """Test mute status for personal mute""" - # Create personal mute - self._create_test_mute(self.target_learner, self.user, 'personal') - - self._login_user(self.user) - response = self.client.get( - self.mute_status_url + f'?user_id={self.target_learner.id}' - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data['is_muted'] is True - assert data['mute_type'] == 'personal' - - def test_mute_status_course_mute(self): - """Test mute status for course-wide mute""" - # Create course-wide mute - self._create_test_mute(self.target_learner, self.staff_user, 'course') - - self._login_user(self.user) - response = self.client.get( - self.mute_status_url + f'?user_id={self.target_learner.id}' - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data['is_muted'] is True - assert data['mute_type'] == 'course' - - def test_mute_status_no_mute(self): - """Test mute status when user is not muted""" - self._login_user(self.user) - response = self.client.get( - self.mute_status_url + f'?user_id={self.target_learner.id}' - ) - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data['is_muted'] is False - assert data['mute_type'] == '' - - # Test 10: Duplicate Mute Prevention - def test_duplicate_mute_prevention(self): - """Test that duplicate mutes are prevented""" - # Create initial mute - self._create_test_mute(self.target_learner, self.user, 'personal') - - # Try to create duplicate mute - self._login_user(self.user) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id), - 'scope': 'personal' - } - - response = self.client.post(self.mute_url, data, format='json') - - assert response.status_code == status.HTTP_400_BAD_REQUEST - response_data = response.json() - assert 'already muted' in response_data['message'] - - # Test 11: Authentication and Authorization - def test_mute_requires_authentication(self): - """Test that mute endpoints require authentication""" - self.client.logout() - - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id) - } - - response = self.client.post(self.mute_url, data, format='json') - # CanMuteUsers permission returns 401 for unauthenticated users - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - def test_mute_requires_course_enrollment(self): - """Test that mute requires course enrollment""" - # Create user not enrolled in course - non_enrolled_user = UserFactory.create(password=self.password) - - self.client.login(username=non_enrolled_user.username, password=self.password) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id) - } - - response = self.client.post(self.mute_url, data, format='json') - assert response.status_code == status.HTTP_403_FORBIDDEN - - # Test 12: Invalid Data Handling - def test_mute_invalid_user_id(self): - """Test mute with invalid user ID""" - self._login_user(self.user) - data = { - 'muted_user_id': 99999, - 'course_id': str(self.course.id) - } - - response = self.client.post(self.mute_url, data, format='json') - assert response.status_code == status.HTTP_404_NOT_FOUND - - def test_mute_invalid_course_id(self): - """Test mute with invalid course ID""" - self._login_user(self.user) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': 'invalid_course_id' - } - - response = self.client.post(self.mute_url, data, format='json') - # Permission check happens first and fails for invalid course ID - assert response.status_code == status.HTTP_403_FORBIDDEN - - def test_unmute_nonexistent_mute(self): - """Test unmuting when no mute exists""" - self._login_user(self.user) - data = { - 'muted_user_id': self.target_learner.id, - 'course_id': str(self.course.id), - 'scope': 'personal' - } - - response = self.client.post(self.unmute_url, data, format='json') - assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py index 431304a9a2b5..4f9cf1bee188 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -12,6 +12,7 @@ import json from datetime import datetime from unittest import mock +from unittest.mock import patch import ddt from forum.backends.mongodb.comments import Comment @@ -523,7 +524,11 @@ def test_404(self): {"developer_message": "Course not found."} ) - def test_basic(self): + @patch( + "lms.djangoapps.discussion.rest_api.api.ForumIntegrationService.get_muted_user_ids_for_course", + return_value=set() + ) + def test_basic(self, mock_get_muted_user_ids): self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) source_threads = [ self.create_source_thread( diff --git a/lms/djangoapps/discussion/rest_api/urls.py b/lms/djangoapps/discussion/rest_api/urls.py index e40ab6682085..3dfa60d3747c 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -29,6 +29,14 @@ ThreadViewSet, UploadFileView, ) +# Import new forum-based mute views +from lms.djangoapps.discussion.rest_api.forum_mute_views import ( + ForumMuteUserView, + ForumUnmuteUserView, + ForumMuteAndReportView, + ForumMutedUsersListView, + ForumMuteStatusView, +) ROUTER = SimpleRouter() ROUTER.register("threads", ThreadViewSet, basename="thread") @@ -98,30 +106,31 @@ BulkDeleteUserPosts.as_view(), name="bulk_delete_user_posts" ), + # New forum-based mute endpoints re_path( - fr"^v1/moderation/mute/{settings.COURSE_ID_PATTERN}", - MuteUserView.as_view(), - name="mute_user" + fr"^v1/moderation/forum-mute/{settings.COURSE_ID_PATTERN}/$", + ForumMuteUserView.as_view(), + name="forum_mute_user" ), re_path( - fr"^v1/moderation/unmute/{settings.COURSE_ID_PATTERN}", - UnmuteUserView.as_view(), - name="unmute_user" + fr"^v1/moderation/forum-unmute/{settings.COURSE_ID_PATTERN}/$", + ForumUnmuteUserView.as_view(), + name="forum_unmute_user" ), re_path( - fr"^v1/moderation/mute-and-report/{settings.COURSE_ID_PATTERN}", - MuteAndReportView.as_view(), - name="mute_and_report" + fr"^v1/moderation/forum-mute-and-report/{settings.COURSE_ID_PATTERN}/$", + ForumMuteAndReportView.as_view(), + name="forum_mute_and_report" ), re_path( - fr"^v1/moderation/muted/{settings.COURSE_ID_PATTERN}", - MutedUsersListView.as_view(), - name="muted_users_list" + fr"^v1/moderation/forum-muted-users/{settings.COURSE_ID_PATTERN}/$", + ForumMutedUsersListView.as_view(), + name="forum_muted_users_list" ), re_path( - fr"^v1/moderation/mute-status/{settings.COURSE_ID_PATTERN}", - MuteStatusView.as_view(), - name="mute_status" + fr"^v1/moderation/forum-mute-status/{settings.COURSE_ID_PATTERN}/(?P[0-9]+)/$", + ForumMuteStatusView.as_view(), + name="forum_mute_status" ), path('v1/', include(ROUTER.urls)), ] diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index ef19f07abe6a..bef623b774ff 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -4,14 +4,14 @@ import logging import uuid from datetime import datetime +from urllib.parse import unquote import edx_api_doc_tools as apidocs - from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.exceptions import BadRequest, ValidationError from django.shortcuts import get_object_or_404 -from django.core.paginator import Paginator +from django.utils import timezone from drf_yasg import openapi from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -30,6 +30,7 @@ from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff from common.djangoapps.util.file import store_uploaded_file from forum.backends.mysql.models import AbuseFlagger, CommentThread as ForumThread, Comment as ForumComment +from forum import api as forum_api from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.course_goals.models import UserActivity from lms.djangoapps.discussion.rate_limit import is_content_creation_rate_limited @@ -43,7 +44,13 @@ from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider from openedx.core.djangoapps.discussions.serializers import DiscussionSettingsSerializer from openedx.core.djangoapps.django_comment_common import comment_client -from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role, DiscussionMute, DiscussionModerationLog, DiscussionMuteException +from openedx.core.djangoapps.django_comment_common.models import ( + CourseDiscussionSettings, + Role, +) +from lms.djangoapps.discussion.forum_integration import ( + ForumMuteService, +) from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.user_api.accounts.permissions import CanReplaceUsername, CanRetireUser @@ -82,16 +89,20 @@ UserCommentListGetForm, UserOrdering, ) -from ..rest_api.permissions import IsStaffOrAdmin, IsStaffOrCourseTeamOrEnrolled, can_mute_user, can_unmute_user, can_view_muted_users +from ..rest_api.permissions import ( + IsStaffOrAdmin, + IsStaffOrCourseTeamOrEnrolled, + can_mute_user, + can_unmute_user, + can_view_muted_users +) from ..rest_api.serializers import ( CourseMetadataSerailizer, DiscussionRolesListSerializer, DiscussionRolesSerializer, DiscussionTopicSerializerV2, TopicOrdering, - MuteRequestSerializer, - MuteResponseSerializer, - UserBriefSerializer, + MuteRequestSerializer, UnmuteRequestSerializer, MuteAndReportRequestSerializer, ) @@ -104,6 +115,154 @@ is_only_student, ) + +def _transform_mute_request_data(raw_data, course_id): + """ + Transform frontend format (username, is_course_wide) to backend format (muted_user_id, scope). + + Args: + raw_data: Raw request data + course_id: Course ID string + + Returns: + tuple: (transformed_data dict, error_response or None) + """ + if 'username' in raw_data and 'muted_user_id' not in raw_data: + # Frontend format - transform to backend format + username = raw_data.get('username') + is_course_wide = raw_data.get('is_course_wide', False) + + if not username: + return None, Response( + {"status": "error", "message": "Username is required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + User = get_user_model() # pylint: disable=redefined-outer-name + target_user = User.objects.get(username=username) + transformed_data = { + 'muted_user_id': target_user.id, + 'course_id': course_id, + 'scope': 'course' if is_course_wide else 'personal', + 'reason': raw_data.get('reason', '') + } + return transformed_data, None + except User.DoesNotExist: + return None, Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + else: + # Backend format - use as is + return raw_data, None + + +def _transform_mute_and_report_request_data(raw_data, course_id): + """ + Transform frontend format for mute-and-report (username, post_id) to backend format. + + Args: + raw_data: Raw request data + course_id: Course ID string + + Returns: + tuple: (transformed_data dict, error_response or None) + """ + if 'username' in raw_data and 'muted_user_id' not in raw_data: + # Frontend format - transform to backend format + username = raw_data.get('username') + post_id = raw_data.get('post_id') + + if not username: + return None, Response( + {"status": "error", "message": "Username is required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + target_user = User.objects.get(username=username) + transformed_data = { + 'muted_user_id': target_user.id, + 'course_id': course_id, + 'scope': 'personal', # Mute and report is typically personal + 'thread_id': post_id, + } + return transformed_data, None + except User.DoesNotExist: + return None, Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + else: + # Backend format - use as is + return raw_data, None + + +def _get_user_id_from_request(request): + """ + Get user ID from request parameters, handling both user_id and username formats. + + Args: + request: Django request object + + Returns: + tuple: (user_id, error_response or None) + """ + user_id = request.GET.get('user_id') + username = request.GET.get('username') + + if username and not user_id: + # Frontend format - get user_id from username + try: + target_user = User.objects.get(username=username) + return target_user.id, None + except User.DoesNotExist: + return None, Response( + {"status": "error", "message": "Target user not found"}, + status=status.HTTP_404_NOT_FOUND + ) + elif not user_id: + return None, Response( + {"status": "error", "message": "user_id or username parameter required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return user_id, None + + +def _create_error_response(message, status_code): + """ + Create standardized error response for mute views. + + Args: + message: Error message + status_code: HTTP status code + + Returns: + Response object + """ + return Response( + {"status": "error", "message": message}, + status=status_code + ) + + +def _parse_course_key(course_id): + """ + Parse course ID string to CourseKey, returning error response on failure. + + Args: + course_id: Course ID string + + Returns: + tuple: (CourseKey object, error_response or None) + """ + try: + return CourseKey.from_string(course_id), None + except Exception: # pylint: disable=broad-except + return None, _create_error_response("Invalid course ID", status.HTTP_400_BAD_REQUEST) + log = logging.getLogger(__name__) User = get_user_model() @@ -670,6 +829,7 @@ class docstring. form.cleaned_data["order_direction"], form.cleaned_data["requested_fields"], form.cleaned_data["count_flagged"], + form.cleaned_data["include_muted"], ) def retrieve(self, request, thread_id=None): @@ -775,7 +935,11 @@ def get(self, request, course_id=None): page_num = request.GET.get('page', 1) threads_per_page = request.GET.get('page_size', 10) count_flagged = request.GET.get('count_flagged', False) - thread_type = request.GET.get('thread_type') + include_muted = request.GET.get('include_muted', False) + + if isinstance(include_muted, str): + include_muted = include_muted.lower() == 'true' + order_by = request.GET.get('order_by') order_by_mapping = { "last_activity_at": "activity", @@ -784,6 +948,7 @@ def get(self, request, course_id=None): } order_by = order_by_mapping.get(order_by, 'activity') post_status = request.GET.get('status', None) + thread_type = request.GET.get('thread_type', None) discussion_id = None username = request.GET.get('username', None) user = get_object_or_404(User, username=username) @@ -802,6 +967,7 @@ def get(self, request, course_id=None): "count_flagged": count_flagged, "thread_type": thread_type, "sort_key": order_by, + "include_muted": include_muted, } if post_status: if post_status not in ['flagged', 'unanswered', 'unread', 'unresponded']: @@ -1020,7 +1186,8 @@ def list_by_thread(self, request): form.cleaned_data["page_size"], form.cleaned_data["flagged"], form.cleaned_data["requested_fields"], - form.cleaned_data["merge_question_type_responses"] + form.cleaned_data["merge_question_type_responses"], + form.cleaned_data["include_muted"] ) def list_by_user(self, request): @@ -1630,9 +1797,9 @@ def post(self, request, course_id): class MuteUserView(DeveloperErrorViewMixin, APIView): """ API endpoint to mute a user in discussions. - + **POST /api/discussion/v1/moderation/mute/** - + Allows users to mute other users either personally or course-wide (if they have permissions). """ authentication_classes = [ @@ -1641,117 +1808,79 @@ class MuteUserView(DeveloperErrorViewMixin, APIView): SessionAuthenticationAllowInactiveUser, ] permission_classes = [CanMuteUsers] - - # API documentation removed to fix startup error - # TODO: Add proper API documentation using available edx_api_doc_tools methods + def post(self, request, course_id): """Mute a user in discussions""" - + + # URL decode the course_id parameter to handle browser encoding + course_id = unquote(course_id) + + # Handle frontend format (username, is_course_wide) vs backend format (muted_user_id, scope) + transformed_data, error_response = _transform_mute_request_data(request.data.copy(), course_id) + if error_response: + return error_response + # Validate request data - serializer = MuteRequestSerializer(data=request.data) + serializer = MuteRequestSerializer(data=transformed_data) if not serializer.is_valid(): return Response( {"status": "error", "message": "Invalid request data", "errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST ) - + data = serializer.validated_data - + # Get target user try: - User = get_user_model() - target_user = User.objects.get(id=data['muted_user_id']) - except User.DoesNotExist: + target_user = get_user_model().objects.get(id=data['muted_user_id']) + except get_user_model().DoesNotExist: return Response( {"status": "error", "message": "Target user not found"}, status=status.HTTP_404_NOT_FOUND ) - + # Parse course key - try: - course_key = CourseKey.from_string(data['course_id']) - except: - return Response( - {"status": "error", "message": "Invalid course ID"}, - status=status.HTTP_400_BAD_REQUEST - ) - + course_key, error_response = _parse_course_key(data['course_id']) + if error_response: + return error_response + # Prevent self-muting if request.user.id == target_user.id: - return Response( - {"status": "error", "message": "Users cannot mute themselves"}, - status=status.HTTP_400_BAD_REQUEST - ) - + return _create_error_response("Users cannot mute themselves", status.HTTP_400_BAD_REQUEST) + # Check permissions if not can_mute_user(request.user, target_user, course_key, data.get('scope', 'personal')): - return Response( - {"status": "error", "message": "Permission denied"}, - status=status.HTTP_403_FORBIDDEN - ) - - # Check for existing active mute - existing_mute = DiscussionMute.objects.filter( - muted_user=target_user, - muted_by=request.user, - course_id=course_key, - scope=data.get('scope', 'personal'), - is_active=True - ).first() - - if existing_mute: - return Response( - {"status": "error", "message": "User is already muted"}, - status=status.HTTP_400_BAD_REQUEST + return _create_error_response("Permission denied", status.HTTP_403_FORBIDDEN) + + # Use forum service to handle mute operation + try: + result = ForumMuteService.mute_user( + muted_user_id=target_user.id, + muted_by_id=request.user.id, + course_id=str(course_key), + scope=data.get('scope', 'personal'), + reason=data.get('reason', '') ) - - # Create mute record - mute_record = DiscussionMute.objects.create( - muted_user=target_user, - muted_by=request.user, - - course_id=course_key, - scope=data.get('scope', 'personal'), - reason=data.get('reason', ''), - is_active=True - ) - - # Log the action - DiscussionModerationLog.objects.create( - action_type=DiscussionModerationLog.ACTION_MUTE, - target_user=target_user, - moderator=request.user, - course_id=course_key, - scope=data.get('scope', 'personal'), - reason=data.get('reason', ''), - metadata={ - 'mute_record_id': mute_record.id, - } - ) - + except Exception as e: # pylint: disable=broad-except + log.error(f"Error during mute operation: {e}") + if "already muted" in str(e).lower(): + return _create_error_response("User is already muted", status.HTTP_400_BAD_REQUEST) + return _create_error_response("Internal server error", status.HTTP_500_INTERNAL_SERVER_ERROR) + # Prepare response response_data = { 'status': 'success', 'message': 'User muted successfully', - 'mute_record': { - 'id': mute_record.id, - 'muted_user': { - 'id': target_user.id, - 'username': target_user.username, - }, - 'scope': mute_record.scope, - 'created': mute_record.created, - 'is_active': mute_record.is_active, - } + 'mute_record': result.get('mute_record', {}), } - + return Response(response_data, status=status.HTTP_201_CREATED) class UnmuteUserView(DeveloperErrorViewMixin, APIView): """ API endpoint to unmute a user in discussions. - + **POST /api/discussion/v1/moderation/unmute/** """ authentication_classes = [ @@ -1760,149 +1889,120 @@ class UnmuteUserView(DeveloperErrorViewMixin, APIView): SessionAuthenticationAllowInactiveUser, ] permission_classes = [CanMuteUsers] - + def post(self, request, course_id): """Unmute a user in discussions""" - + + # URL decode the course_id parameter to handle browser encoding + course_id = unquote(course_id) + + # Handle frontend format (username, is_course_wide) vs backend format (muted_user_id, scope) + transformed_data, error_response = _transform_mute_request_data(request.data.copy(), course_id) + if error_response: + return error_response + # Validate request data - serializer = UnmuteRequestSerializer(data=request.data) + serializer = UnmuteRequestSerializer(data=transformed_data) if not serializer.is_valid(): return Response( {"status": "error", "message": "Invalid request data", "errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST ) - + data = serializer.validated_data - + # Get target user try: - User = get_user_model() - target_user = User.objects.get(id=data['muted_user_id']) - except User.DoesNotExist: + target_user = get_user_model().objects.get(id=data['muted_user_id']) + except get_user_model().DoesNotExist: return Response( {"status": "error", "message": "Target user not found"}, status=status.HTTP_404_NOT_FOUND ) - + # Parse course key - try: - course_key = CourseKey.from_string(data['course_id']) - except: - return Response( - {"status": "error", "message": "Invalid course ID"}, - status=status.HTTP_400_BAD_REQUEST - ) - + course_key, error_response = _parse_course_key(data['course_id']) + if error_response: + return error_response + # Prevent self-unmuting if request.user.id == target_user.id: - return Response( - {"status": "error", "message": "Users cannot unmute themselves"}, - status=status.HTTP_400_BAD_REQUEST - ) - + return _create_error_response("Users cannot unmute themselves", status.HTTP_400_BAD_REQUEST) + # Check permissions if not can_unmute_user(request.user, target_user, course_key, data.get('scope', 'personal')): - return Response( - {"status": "error", "message": "Permission denied"}, - status=status.HTTP_403_FORBIDDEN - ) - + return _create_error_response("Permission denied", status.HTTP_403_FORBIDDEN) + requesting_is_staff = ( CourseStaffRole(course_key).has_user(request.user) or CourseInstructorRole(course_key).has_user(request.user) or GlobalStaff().has_user(request.user) ) - + scope = data.get('scope', 'personal') - + # Special handling for course-level mutes with personal unmute exceptions if scope == 'personal' and not requesting_is_staff: - # Check if there's an active course-level mute - course_mute = DiscussionMute.objects.filter( - muted_user=target_user, - course_id=course_key, - scope='course', - is_active=True - ).first() - - if course_mute: - # Create a personal unmute exception instead of deactivating the course mute - exception, created = DiscussionMuteException.objects.get_or_create( - muted_user=target_user, - exception_user=request.user, - course_id=course_key + # Check if there's an active course-level mute using forum API + try: + mute_status = forum_api.get_user_mute_status( + user_id=str(target_user.id), + course_id=str(course_key), + viewer_id=str(request.user.id) ) - - # Log the action as unmute with exception metadata - DiscussionModerationLog.objects.create( - action_type=DiscussionModerationLog.ACTION_UNMUTE, - target_user=target_user, - moderator=request.user, - course_id=course_key, - scope='personal', - reason='Personal exception from course-wide mute', - metadata={ - 'course_mute_id': course_mute.id, - 'exception_id': exception.id, + + # If there's an active course mute, create an exception + if mute_status.get('is_muted') and mute_status.get('scope') == 'course': + # Use API to create unmute exception (handled internally by unmute_user) + result = forum_api.unmute_user( + muted_user_id=str(target_user.id), + unmuted_by_id=str(request.user.id), + course_id=str(course_key), + scope='personal' + ) + + return Response({ + 'status': 'success', + 'message': 'Personal unmute exception created for course-wide mute', 'unmute_type': 'exception', - } + 'result': result, + }, status=status.HTTP_201_CREATED) + except Exception as e: # pylint: disable=broad-except + return _create_error_response( + f"Error checking mute status: {str(e)}", + status.HTTP_500_INTERNAL_SERVER_ERROR ) - - return Response({ - 'status': 'success', - 'message': 'Personal unmute exception created for course-wide mute', - 'unmute_type': 'exception', - 'exception_id': exception.id, - }, status=status.HTTP_201_CREATED) - - # Find active mute records to revoke - mute_records = DiscussionMute.objects.filter( - muted_user=target_user, - course_id=course_key, - scope=scope, - is_active=True - ) - - # For personal scope, only allow unmuting own mutes unless user is staff - if scope == 'personal' and not requesting_is_staff: - mute_records = mute_records.filter(muted_by=request.user) - - if not mute_records.exists(): - return Response( - {"status": "error", "message": "No active mute found"}, - status=status.HTTP_404_NOT_FOUND + + # Use forum API to unmute user + try: + muted_by_filter = ( + str(request.user.id) if scope == 'personal' and not requesting_is_staff else None ) - - # Revoke mutes - unmute_timestamp = datetime.now() - mute_records.update(is_active=False) - - # Log the action - for mute_record in mute_records: - DiscussionModerationLog.objects.create( - action_type=DiscussionModerationLog.ACTION_UNMUTE, - target_user=target_user, - moderator=request.user, - course_id=course_key, + result = forum_api.unmute_user( + muted_user_id=str(target_user.id), + unmuted_by_id=str(request.user.id), + course_id=str(course_key), scope=scope, - reason='', - metadata={ - 'revoked_mute_record_id': mute_record.id, - } + muted_by_id=muted_by_filter ) - + + if not result.get('success', False): + return _create_error_response("No active mute found", status.HTTP_404_NOT_FOUND) + + except (ValueError, KeyError) as e: + return _create_error_response(f"Error unmuting user: {str(e)}", status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response({ 'status': 'success', 'message': 'User unmuted successfully', - 'unmute_type': 'deactivated', - 'unmute_timestamp': unmute_timestamp, + 'result': result, }, status=status.HTTP_200_OK) class MuteAndReportView(DeveloperErrorViewMixin, APIView): """ API endpoint to mute a user and report their content. - + **POST /api/discussion/v1/moderation/mute-and-report/** """ authentication_classes = [ @@ -1911,92 +2011,80 @@ class MuteAndReportView(DeveloperErrorViewMixin, APIView): SessionAuthenticationAllowInactiveUser, ] permission_classes = [CanMuteUsers] - - def post(self, request, course_id): + + def post(self, request, course_id): # pylint: disable=too-many-statements """Mute a user and report their content""" - + + # URL decode the course_id parameter to handle browser encoding + course_id = unquote(course_id) + # Parse course key first for permission checks - try: - course_key = CourseKey.from_string(course_id) - except: - return Response( - {"status": "error", "message": "Invalid course ID"}, - status=status.HTTP_400_BAD_REQUEST - ) - + course_key, error_response = _parse_course_key(course_id) + if error_response: + return error_response + # Check if user is staff - mute-and-report is only for learners - if (GlobalStaff().has_user(request.user) or - CourseStaffRole(course_key).has_user(request.user) or - CourseInstructorRole(course_key).has_user(request.user)): - return Response( - {"status": "error", "message": "Mute-and-report action is only available to learners. Staff should use the separate mute action."}, - status=status.HTTP_403_FORBIDDEN + if (GlobalStaff().has_user(request.user) or + CourseStaffRole(course_key).has_user(request.user) or + CourseInstructorRole(course_key).has_user(request.user)): + return _create_error_response( + "Mute-and-report action is only available to learners. Staff should use the separate mute action.", + status.HTTP_403_FORBIDDEN ) - + + # Handle frontend format (username, post_id) vs backend format (muted_user_id, thread_id) + transformed_data, error_response = _transform_mute_and_report_request_data(request.data.copy(), course_id) + if error_response: + return error_response + # Validate request data - serializer = MuteAndReportRequestSerializer(data=request.data) + serializer = MuteAndReportRequestSerializer(data=transformed_data) if not serializer.is_valid(): return Response( {"status": "error", "message": "Invalid request data", "errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST ) - + data = serializer.validated_data - + # Get target user try: - User = get_user_model() - target_user = User.objects.get(id=data['muted_user_id']) - except User.DoesNotExist: - return Response( - {"status": "error", "message": "Target user not found"}, - status=status.HTTP_404_NOT_FOUND - ) - + target_user = get_user_model().objects.get(id=data['muted_user_id']) + except get_user_model().DoesNotExist: + return _create_error_response("Target user not found", status.HTTP_404_NOT_FOUND) + # Prevent self-muting if request.user.id == target_user.id: - return Response( - {"status": "error", "message": "Users cannot mute themselves"}, - status=status.HTTP_400_BAD_REQUEST - ) - + return _create_error_response("Users cannot mute themselves", status.HTTP_400_BAD_REQUEST) + # Check permissions if not can_mute_user(request.user, target_user, course_key, data.get('scope', 'personal')): - return Response( - {"status": "error", "message": "Permission denied"}, - status=status.HTTP_403_FORBIDDEN - ) - - # Check for existing active mute - existing_mute = DiscussionMute.objects.filter( - muted_user=target_user, - muted_by=request.user, - course_id=course_key, - scope=data.get('scope', 'personal'), - is_active=True - ).first() - - if existing_mute: - return Response( - {"status": "error", "message": "User is already muted"}, - status=status.HTTP_400_BAD_REQUEST + return _create_error_response("Permission denied", status.HTTP_403_FORBIDDEN) + + # Use forum API to mute user (includes duplicate check) + try: + result = forum_api.mute_user( + muted_user_id=str(target_user.id), + muted_by_id=str(request.user.id), + course_id=str(course_key), + scope=data.get('scope', 'personal'), + reason=data.get('reason', '') ) - - # Create mute record - mute_record = DiscussionMute.objects.create( - muted_user=target_user, - muted_by=request.user, - course_id=course_key, - scope=data.get('scope', 'personal'), - reason=data.get('reason', ''), - is_active=True - ) - + + if not result.get('success', False): + error_msg = result.get('message', 'Failed to mute user') + if 'already muted' in error_msg.lower(): + return _create_error_response("User is already muted", status.HTTP_400_BAD_REQUEST) + return _create_error_response(error_msg, status.HTTP_400_BAD_REQUEST) + + except (ValueError, KeyError) as e: + return _create_error_response(f"Error muting user: {str(e)}", status.HTTP_500_INTERNAL_SERVER_ERROR) + # Handle content reporting using forum's AbuseFlagger system report_record = None thread_id = data.get('thread_id') comment_id = data.get('comment_id') - + if thread_id or comment_id: try: if thread_id: @@ -2013,27 +2101,27 @@ def post(self, request, course_id): # Also flag via comment client for compatibility thread = Thread.find(thread_id) if thread: - thread.flagAbuse(request.user, reason=data.get('reason', '')) - + thread.flagAbuse(request.user, voteable=thread) + report_record = { 'id': abuse_record.id, 'content_type': 'thread', 'content_id': thread_id, 'created': abuse_record.flagged_at, } - except Exception as thread_error: - logging.warning(f"Forum thread reporting failed: {thread_error}") + except Exception as thread_error: # pylint: disable=broad-except + log.warning(f"Forum thread reporting failed: {thread_error}") # Fallback to comment client only thread = Thread.find(thread_id) if thread: - thread.flagAbuse(request.user, reason=data.get('reason', '')) + thread.flagAbuse(request.user, voteable=thread) report_record = { 'id': f"thread_{thread_id}_{request.user.id}", 'content_type': 'thread', 'content_id': thread_id, - 'created': mute_record.created, + 'created': timezone.now(), } - + elif comment_id: # Report comment using AbuseFlagger try: @@ -2048,77 +2136,62 @@ def post(self, request, course_id): # Also flag via comment client for compatibility comment = Comment.find(comment_id) if comment: - comment.flagAbuse(request.user, reason=data.get('reason', '')) - + comment.flagAbuse(request.user, voteable=comment) + report_record = { 'id': abuse_record.id, 'content_type': 'comment', 'content_id': comment_id, 'created': abuse_record.flagged_at, } - except Exception as comment_error: - logging.warning(f"Forum comment reporting failed: {comment_error}") + except Exception as comment_error: # pylint: disable=broad-except + log.warning(f"Forum comment reporting failed: {comment_error}") # Fallback to comment client only comment = Comment.find(comment_id) if comment: - comment.flagAbuse(request.user, reason=data.get('reason', '')) - report_record = { - 'id': f"comment_{comment_id}_{request.user.id}", - 'content_type': 'comment', - 'content_id': comment_id, - 'created': mute_record.created, - } - except Exception as e: - logging.warning(f"Content reporting failed: {e}") - # Try fallback to comment client only - try: - if thread_id: - thread = Thread.find(thread_id) - if thread: - thread.flagAbuse(request.user, reason=data.get('reason', '')) - elif comment_id: - comment = Comment.find(comment_id) - if comment: - comment.flagAbuse(request.user, reason=data.get('reason', '')) - except Exception as fallback_error: - logging.error(f"Fallback content reporting also failed: {fallback_error}") - - # Log the action - DiscussionModerationLog.objects.create( - action_type=DiscussionModerationLog.ACTION_MUTE_AND_REPORT, - target_user=target_user, - moderator=request.user, - course_id=course_key, - scope=data.get('scope', 'personal'), - reason=data.get('reason', ''), - metadata={ - 'mute_record_id': mute_record.id, - 'thread_id': thread_id, - 'comment_id': comment_id, - } - ) - + comment.flagAbuse(request.user, voteable=comment) + except Exception as e: # pylint: disable=broad-except + return _create_error_response( + f"Error with mute and report: {str(e)}", + status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # Use forum API for mute and report as fallback if no specific content reporting needed + else: + try: + result = forum_api.mute_and_report_user( + muted_user_id=str(target_user.id), + muted_by_id=str(request.user.id), + course_id=str(course_key), + scope=data.get('scope', 'personal'), + reason=data.get('reason', '') + ) + + if not result.get('success', False): + return _create_error_response( + result.get('message', 'Failed to mute and report user'), + status.HTTP_400_BAD_REQUEST + ) + + except Exception as e: # pylint: disable=broad-except + return _create_error_response( + f"Error with mute and report: {str(e)}", + status.HTTP_500_INTERNAL_SERVER_ERROR + ) + # Prepare response response_data = { 'status': 'success', 'message': 'User muted and content reported', - 'mute_record': { - 'id': mute_record.id, - 'scope': mute_record.scope, - 'created': mute_record.created, - } } - - if report_record: - response_data['report_record'] = report_record - + return Response(response_data, status=status.HTTP_201_CREATED) class MutedUsersListView(DeveloperErrorViewMixin, APIView): """ API endpoint to list muted users. - + **GET /api/discussion/v1/moderation/muted/** """ authentication_classes = [ @@ -2127,95 +2200,114 @@ class MutedUsersListView(DeveloperErrorViewMixin, APIView): SessionAuthenticationAllowInactiveUser, ] permission_classes = [CanMuteUsers] - + def get(self, request, course_id): """Get list of muted users""" - + + # URL decode the course_id parameter to handle browser encoding + course_id = unquote(course_id) + # Parse course key - try: - course_key = CourseKey.from_string(course_id) - except: - return Response( - {"status": "error", "message": "Invalid course ID"}, - status=status.HTTP_400_BAD_REQUEST - ) - + course_key, error_response = _parse_course_key(course_id) + if error_response: + return error_response + # Get query parameters scope = request.GET.get('scope', 'personal') page = int(request.GET.get('page', 1)) page_size = int(request.GET.get('page_size', 20)) - + # Check permissions if not can_view_muted_users(request.user, course_key, scope): - return Response( - {"status": "error", "message": "Permission denied"}, - status=status.HTTP_403_FORBIDDEN - ) - - # Build query - query = DiscussionMute.objects.filter( - course_id=course_key, - is_active=True - ).select_related('muted_user', 'muted_by').order_by('-created') - - # Filter by scope + return _create_error_response("Permission denied", status.HTTP_403_FORBIDDEN) + + # Check staff permissions requesting_is_staff = ( CourseStaffRole(course_key).has_user(request.user) or CourseInstructorRole(course_key).has_user(request.user) or GlobalStaff().has_user(request.user) ) - - if scope == 'personal': - if not requesting_is_staff: - query = query.filter(muted_by=request.user, scope='personal') + + # Use forum API to get muted users + try: + if requesting_is_staff and scope in ['course', 'all']: + # Staff can see all muted users in the course + result = forum_api.get_all_muted_users_for_course( + course_id=str(course_key), + scope=scope if scope != 'all' else 'all' + ) + mute_records = result.get('muted_users', []) else: - query = query.filter(scope='personal') - elif scope == 'course': - if not requesting_is_staff: - return Response( - {"status": "error", "message": "Permission denied for course-wide mutes"}, - status=status.HTTP_403_FORBIDDEN + # Non-staff can only see their own mutes, or staff viewing personal mutes + if scope == 'course' and not requesting_is_staff: + return _create_error_response("Permission denied for course-wide mutes", status.HTTP_403_FORBIDDEN) + + mute_records = forum_api.get_muted_users( + muted_by_id=str(request.user.id), + course_id=str(course_key), + scope=scope if scope != 'all' else 'all' ) - query = query.filter(scope='course') - elif scope == 'all': - if not requesting_is_staff: - query = query.filter(muted_by=request.user, scope='personal') - - # Paginate - paginator = Paginator(query, page_size) - page_obj = paginator.get_page(page) - + + # Handle pagination manually since API returns list + total_count = len(mute_records) + start_idx = (page - 1) * page_size + end_idx = start_idx + page_size + paginated_records = mute_records[start_idx:end_idx] + + except Exception as e: # pylint: disable=broad-except + return _create_error_response( + f"Error retrieving muted users: {str(e)}", + status.HTTP_500_INTERNAL_SERVER_ERROR + ) + # Serialize results results = [] - for mute in page_obj: - results.append({ - 'id': mute.id, - 'muted_user': { - 'id': mute.muted_user.id, - 'username': mute.muted_user.username, - 'email': mute.muted_user.email, - }, - 'muted_by': { - 'id': mute.muted_by.id, - 'username': mute.muted_by.username, - }, - 'course_id': str(mute.course_id), - 'scope': mute.scope, - 'reason': mute.reason, - 'created': mute.created, - 'is_active': mute.is_active, - }) - + for mute in paginated_records: + # Get user objects for additional info (username, email) + try: + muted_user = get_user_model().objects.get(id=mute.get('muted_user_id')) + muted_by = get_user_model().objects.get(id=mute.get('muted_by_id')) + + results.append({ + 'id': mute.get('id', ''), + 'muted_user': { + 'id': muted_user.id, + 'username': muted_user.username, + 'email': muted_user.email, + }, + 'muted_by': { + 'id': muted_by.id, + 'username': muted_by.username, + }, + 'course_id': mute.get('course_id', ''), + 'scope': mute.get('scope', ''), + 'reason': mute.get('reason', ''), + 'created': mute.get('created', ''), + 'is_active': mute.get('is_active', True), + }) + except (get_user_model().DoesNotExist, ValueError, TypeError): + # Skip records with missing user data + continue + # Build pagination URLs next_url = None previous_url = None - if page_obj.has_next(): - next_url = f"{request.build_absolute_uri()}?page={page_obj.next_page_number()}&scope={scope}&page_size={page_size}" - if page_obj.has_previous(): - previous_url = f"{request.build_absolute_uri()}?page={page_obj.previous_page_number()}&scope={scope}&page_size={page_size}" - + has_next = end_idx < total_count + has_previous = page > 1 + + if has_next: + next_url = ( + f"{request.build_absolute_uri()}?page={page + 1}" + f"&scope={scope}&page_size={page_size}" + ) + if has_previous: + previous_url = ( + f"{request.build_absolute_uri()}?page={page - 1}" + f"&scope={scope}&page_size={page_size}" + ) + return Response({ - 'count': paginator.count, + 'count': total_count, 'next': next_url, 'previous': previous_url, 'results': results, @@ -2225,7 +2317,7 @@ def get(self, request, course_id): class MuteStatusView(DeveloperErrorViewMixin, APIView): """ API endpoint to check if a user is muted. - + **GET /api/discussion/v1/moderation/mute-status/** """ authentication_classes = [ @@ -2234,83 +2326,69 @@ class MuteStatusView(DeveloperErrorViewMixin, APIView): SessionAuthenticationAllowInactiveUser, ] permission_classes = [permissions.IsAuthenticated] - + def get(self, request, course_id): """Check mute status for a user""" - - # Get query parameters - user_id = request.GET.get('user_id') - if not user_id: - return Response( - {"status": "error", "message": "user_id parameter required"}, - status=status.HTTP_400_BAD_REQUEST - ) - + + # URL decode the course_id parameter to handle browser encoding + course_id = unquote(course_id) + + # Handle frontend format (username) vs backend format (user_id) + user_id, error_response = _get_user_id_from_request(request) + if error_response: + return error_response + # Parse course key - try: - course_key = CourseKey.from_string(course_id) - except: - return Response( - {"status": "error", "message": "Invalid course ID"}, - status=status.HTTP_400_BAD_REQUEST - ) - + course_key, error_response = _parse_course_key(course_id) + if error_response: + return error_response + # Get target user try: - User = get_user_model() - target_user = User.objects.get(id=user_id) - except (User.DoesNotExist, ValueError): - return Response( - {"status": "error", "message": "Target user not found"}, - status=status.HTTP_404_NOT_FOUND + target_user = get_user_model().objects.get(id=user_id) + except (get_user_model().DoesNotExist, ValueError): + return _create_error_response("Target user not found", status.HTTP_404_NOT_FOUND) + + # Use forum API to get mute status + try: + mute_status = forum_api.get_user_mute_status( + user_id=str(target_user.id), + course_id=str(course_key), + viewer_id=str(request.user.id) ) - - # Check for active mutes - # Priority: course-wide mutes override personal mutes - course_mute = DiscussionMute.objects.filter( - muted_user=target_user, - course_id=course_key, - scope='course', - is_active=True - ).select_related('muted_by').first() - - if course_mute: - return Response({ - 'is_muted': True, - 'mute_type': 'course', - 'mute_details': { - 'muted_by': { - 'id': course_mute.muted_by.id, - 'username': course_mute.muted_by.username, - }, - 'created': course_mute.created, - 'scope': 'course', - } - }) - - # Check for personal mute by requesting user - personal_mute = DiscussionMute.objects.filter( - muted_user=target_user, - muted_by=request.user, - course_id=course_key, - scope='personal', - is_active=True - ).first() - - if personal_mute: - return Response({ - 'is_muted': True, - 'mute_type': 'personal', - 'mute_details': { - 'muted_by': { - 'id': personal_mute.muted_by.id, - 'username': personal_mute.muted_by.username, - }, - 'created': personal_mute.created, - 'scope': 'personal', - } - }) - + + is_muted = mute_status.get('is_muted', False) + if is_muted: + scope = mute_status.get('scope', 'personal') + muted_by_id = mute_status.get('muted_by_id') + + # Get muted_by user info + try: + muted_by_user = get_user_model().objects.get(id=muted_by_id) + muted_by_info = { + 'id': muted_by_user.id, + 'username': muted_by_user.username, + } + except (get_user_model().DoesNotExist, ValueError, TypeError): + muted_by_info = {'id': muted_by_id, 'username': 'Unknown'} + + return Response({ + 'is_muted': True, + 'mute_type': scope, + 'mute_details': { + 'muted_by': muted_by_info, + 'created': mute_status.get('created', ''), + 'scope': scope, + 'reason': mute_status.get('reason', ''), + } + }) + + except Exception as e: # pylint: disable=broad-except + return _create_error_response( + f"Error checking mute status: {str(e)}", + status.HTTP_500_INTERNAL_SERVER_ERROR + ) + return Response({ 'is_muted': False, 'mute_type': '', diff --git a/openedx/core/djangoapps/django_comment_common/migrations/0010_discussion_muting_models.py b/openedx/core/djangoapps/django_comment_common/migrations/0010_discussion_muting_models.py deleted file mode 100644 index 8a9dcce226dd..000000000000 --- a/openedx/core/djangoapps/django_comment_common/migrations/0010_discussion_muting_models.py +++ /dev/null @@ -1,83 +0,0 @@ -# Generated manually - add discussion muting models - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone -import jsonfield.fields -import model_utils.fields -import opaque_keys.edx.django.models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('django_comment_common', '0009_coursediscussionsettings_reported_content_email_notifications'), - ] - - operations = [ - migrations.CreateModel( - name='DiscussionMute', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('course_id', opaque_keys.edx.django.models.CourseKeyField(help_text='Course in which mute applies', max_length=255)), - ('scope', models.CharField(choices=[('personal', 'Personal'), ('course', 'Course-wide')], default='personal', help_text='Scope of the mute (personal or course-wide)', max_length=10)), - ('reason', models.TextField(blank=True, help_text='Optional reason for muting')), - ('is_active', models.BooleanField(default=True, help_text='Whether the mute is currently active')), - ('muted_at', models.DateTimeField(auto_now_add=True)), - ('unmuted_at', models.DateTimeField(blank=True, null=True)), - ('muted_by', models.ForeignKey(help_text='User performing the mute', on_delete=django.db.models.deletion.CASCADE, related_name='muted_users', to=settings.AUTH_USER_MODEL)), - ('muted_user', models.ForeignKey(help_text='User being muted', on_delete=django.db.models.deletion.CASCADE, related_name='muted_by_users', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'indexes': [ - models.Index(fields=['muted_user', 'course_id', 'is_active'], name='django_comment_muted_user_course_active_idx'), - models.Index(fields=['muted_by', 'course_id', 'scope'], name='django_comment_muted_by_course_scope_idx'), - ], - 'unique_together': {('muted_user', 'muted_by', 'course_id', 'scope')}, - }, - ), - migrations.CreateModel( - name='DiscussionMuteException', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('course_id', opaque_keys.edx.django.models.CourseKeyField(help_text='Course where the exception applies', max_length=255)), - ('exception_user', models.ForeignKey(help_text='User who unmuted the muted_user for themselves', on_delete=django.db.models.deletion.CASCADE, related_name='mute_exceptions', to=settings.AUTH_USER_MODEL)), - ('muted_user', models.ForeignKey(help_text='User who is globally muted in this course', on_delete=django.db.models.deletion.CASCADE, related_name='mute_exceptions_for', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'indexes': [ - models.Index(fields=['muted_user', 'course_id'], name='django_comment_mute_exception_user_course_idx'), - models.Index(fields=['exception_user', 'course_id'], name='django_comment_mute_exception_exception_user_idx'), - ], - 'unique_together': {('muted_user', 'exception_user', 'course_id')}, - }, - ), - migrations.CreateModel( - name='DiscussionModerationLog', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('action_type', models.CharField(choices=[('mute', 'Mute'), ('unmute', 'Unmute'), ('mute_and_report', 'Mute and Report')], help_text='Type of moderation action performed', max_length=20)), - ('course_id', opaque_keys.edx.django.models.CourseKeyField(help_text='Course where the action was performed', max_length=255)), - ('scope', models.CharField(choices=[('personal', 'Personal'), ('course', 'Course-wide')], default='personal', help_text='Scope of the moderation action', max_length=10)), - ('reason', models.TextField(blank=True, help_text='Optional reason for moderation')), - ('metadata', jsonfield.fields.JSONField(blank=True, default=dict, help_text='Additional metadata for the action')), - ('moderator', models.ForeignKey(help_text='User performing the moderation action', on_delete=django.db.models.deletion.CASCADE, related_name='moderation_logs', to=settings.AUTH_USER_MODEL)), - ('target_user', models.ForeignKey(help_text='User on whom the action was performed', on_delete=django.db.models.deletion.CASCADE, related_name='moderation_actions', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'indexes': [ - models.Index(fields=['target_user', 'course_id', 'created'], name='django_comment_moderation_log_target_course_created_idx'), - models.Index(fields=['moderator', 'course_id', 'action_type'], name='django_comment_moderation_log_moderator_course_action_idx'), - models.Index(fields=['course_id', 'action_type', 'created'], name='django_comment_moderation_log_course_action_created_idx'), - ], - }, - ), - ] \ No newline at end of file diff --git a/openedx/core/djangoapps/django_comment_common/migrations/0011_add_timestamped_fields_to_moderationlog.py b/openedx/core/djangoapps/django_comment_common/migrations/0011_add_timestamped_fields_to_moderationlog.py deleted file mode 100644 index 492a0704c34c..000000000000 --- a/openedx/core/djangoapps/django_comment_common/migrations/0011_add_timestamped_fields_to_moderationlog.py +++ /dev/null @@ -1,34 +0,0 @@ -# Migration to add TimeStampedModel fields to existing DiscussionModerationLog table - -from django.db import migrations, models -import django.utils.timezone -import model_utils.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('django_comment_common', '0010_discussion_muting_models'), - ] - - operations = [ - # Add created and modified fields from TimeStampedModel - migrations.AddField( - model_name='discussionmoderationlog', - name='created', - field=model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name='created' - ), - ), - migrations.AddField( - model_name='discussionmoderationlog', - name='modified', - field=model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name='modified' - ), - ), - ] \ No newline at end of file diff --git a/openedx/core/djangoapps/django_comment_common/migrations/0011_update_moderation_log_related_names.py b/openedx/core/djangoapps/django_comment_common/migrations/0011_update_moderation_log_related_names.py deleted file mode 100644 index 9102448e9756..000000000000 --- a/openedx/core/djangoapps/django_comment_common/migrations/0011_update_moderation_log_related_names.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated manually to fix related_name conflicts -from django.db import migrations, models -import django.db.models.deletion -from django.conf import settings - - -class Migration(migrations.Migration): - - dependencies = [ - ('django_comment_common', '0010_discussion_muting_models'), - ] - - operations = [ - migrations.AlterField( - model_name='discussionmoderationlog', - name='moderator', - field=models.ForeignKey( - help_text='User performing the moderation action', - on_delete=django.db.models.deletion.CASCADE, - related_name='discussion_moderation_logs', - to=settings.AUTH_USER_MODEL - ), - ), - migrations.AlterField( - model_name='discussionmoderationlog', - name='target_user', - field=models.ForeignKey( - help_text='User on whom the action was performed', - on_delete=django.db.models.deletion.CASCADE, - related_name='discussion_moderation_targets', - to=settings.AUTH_USER_MODEL - ), - ), - ] \ No newline at end of file diff --git a/openedx/core/djangoapps/django_comment_common/migrations/0012_merge_20251127_0622.py b/openedx/core/djangoapps/django_comment_common/migrations/0012_merge_20251127_0622.py deleted file mode 100644 index 52248ac9b07f..000000000000 --- a/openedx/core/djangoapps/django_comment_common/migrations/0012_merge_20251127_0622.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 5.2.7 on 2025-11-27 06:22 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('django_comment_common', '0011_add_timestamped_fields_to_moderationlog'), - ('django_comment_common', '0011_update_moderation_log_related_names'), - ] - - operations = [ - ] diff --git a/openedx/core/djangoapps/django_comment_common/models.py b/openedx/core/djangoapps/django_comment_common/models.py index 798f00236649..bd7b8fe66e67 100644 --- a/openedx/core/djangoapps/django_comment_common/models.py +++ b/openedx/core/djangoapps/django_comment_common/models.py @@ -8,15 +8,12 @@ from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.db import models -from django.db.models import Q -from django.core.exceptions import ValidationError from django.db.models.signals import post_save from django.dispatch import receiver from django.utils.translation import gettext_noop from jsonfield.fields import JSONField from opaque_keys.edx.django.models import CourseKeyField -from model_utils.models import TimeStampedModel from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager from openedx.core.lib.cache_utils import request_cached @@ -339,231 +336,3 @@ def update_mapping(cls, course_key, discussions_id_map): if not created: mapping_entry.mapping = discussions_id_map mapping_entry.save() - - -class DiscussionMute(TimeStampedModel): - """ - Tracks muted users in discussions. - A mute can be personal or course-wide. - """ - - class Scope(models.TextChoices): - PERSONAL = "personal", "Personal" - COURSE = "course", "Course-wide" - - muted_user = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='muted_by_users', - help_text='User being muted', - db_index=True, - ) - muted_by = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='muted_users', - help_text='User performing the mute', - db_index=True, - ) - unmuted_by = models.ForeignKey( - User, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="mute_unactions", - help_text="User who performed the unmute action" - ) - course_id = CourseKeyField( - max_length=255, - db_index=True, - help_text='Course in which mute applies' - ) - scope = models.CharField( - max_length=10, - choices=Scope.choices, - default=Scope.PERSONAL, - help_text='Scope of the mute (personal or course-wide)', - db_index=True, - ) - reason = models.TextField( - blank=True, - help_text='Optional reason for muting' - ) - is_active = models.BooleanField( - default=True, - help_text='Whether the mute is currently active' - ) - - muted_at = models.DateTimeField(auto_now_add=True) - unmuted_at = models.DateTimeField(null=True, blank=True) - - class Meta: - db_table = 'discussion_user_mute' - constraints = [ - # Only one active personal mute per (muted_by → muted_user) in a course - models.UniqueConstraint( - fields=['muted_user', 'muted_by', 'course_id', 'scope'], - condition=Q(is_active=True, scope='personal'), - name='unique_active_personal_mute' - ), - # Only one active course-wide mute per user per course - models.UniqueConstraint( - fields=['muted_user', 'course_id'], - condition=Q(is_active=True, scope='course'), - name='unique_active_course_mute' - ), - ] - - indexes = [ - models.Index(fields=['muted_user', 'course_id', 'is_active']), - models.Index(fields=['muted_by', 'course_id', 'scope']), - models.Index(fields=['scope', 'course_id', 'is_active']), - ] - - def clean(self): - """Additional validation depending on mute scope.""" - super().clean() - - # Personal mute must have a muted_by different from muted_user - if self.scope == self.Scope.PERSONAL: - if self.muted_by == self.muted_user: - raise ValidationError("Personal mute cannot be self-applied.") - - # Course-wide mute must not be self-applied - if self.scope == self.Scope.COURSE: - if self.muted_by == self.muted_user: - raise ValidationError("Course-wide mute cannot be self-applied.") - - def __str__(self): - return f"{self.muted_by} muted {self.muted_user} in {self.course_id} ({self.scope})" - - -class DiscussionMuteException(TimeStampedModel): - """ - Per-user exception for course-wide mutes. - Allows a specific user to unmute someone while the rest of the course remains muted. - """ - - muted_user = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='mute_exceptions_for', - help_text='User who is globally muted in this course', - db_index=True, - ) - exception_user = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='mute_exceptions', - help_text='User who unmuted the muted_user for themselves', - db_index=True, - ) - course_id = CourseKeyField( - max_length=255, - help_text='Course where the exception applies', - db_index=True, - ) - - class Meta: - db_table = 'discussion_mute_exception' - unique_together = [ - ['muted_user', 'exception_user', 'course_id'] - ] - indexes = [ - models.Index(fields=['muted_user', 'course_id']), - models.Index(fields=['exception_user', 'course_id']), - ] - - def clean(self): - """Ensure exception is only created if a course-wide mute is active.""" - super().clean() - - has_coursewide_mute = DiscussionMute.objects.filter( - muted_user=self.muted_user, - course_id=self.course_id, - scope=DiscussionMute.Scope.COURSE, - is_active=True - ).exists() - - if not has_coursewide_mute: - raise ValidationError( - "Exception can only be created for an active course-wide mute." - ) - - def __str__(self): - return f"{self.exception_user} unmuted {self.muted_user} in {self.course_id}" - -class DiscussionModerationLog(TimeStampedModel): - """ - Logs moderation actions such as mute, unmute, and mute_and_report. - """ - - class ActionType(models.TextChoices): - MUTE = "mute", "Mute" - UNMUTE = "unmute", "Unmute" - MUTE_AND_REPORT = "mute_and_report", "Mute and Report" - - class Scope(models.TextChoices): - PERSONAL = "personal", "Personal" - COURSE = "course", "Course-wide" - - # Convenience constants for backward compatibility - ACTION_MUTE = ActionType.MUTE - ACTION_UNMUTE = ActionType.UNMUTE - ACTION_MUTE_AND_REPORT = ActionType.MUTE_AND_REPORT - - action_type = models.CharField( - max_length=20, - choices=ActionType.choices, - help_text='Type of moderation action performed', - db_index=True, - ) - target_user = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='discussion_moderation_targets', - help_text='User on whom the action was performed', - db_index=True, - ) - moderator = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='discussion_moderation_logs', - help_text='User performing the moderation action', - db_index=True, - ) - course_id = CourseKeyField( - max_length=255, - help_text='Course where the action was performed', - db_index=True, - ) - scope = models.CharField( - max_length=10, - choices=Scope.choices, - default=Scope.PERSONAL, - help_text='Scope of the moderation action' - ) - reason = models.TextField( - blank=True, - help_text='Optional reason for moderation' - ) - metadata = JSONField( - default=dict, - blank=True, - help_text='Additional metadata for the action' - ) - timestamp = models.DateTimeField( - auto_now_add=True, - help_text='When this action was performed' - ) - - class Meta: - db_table = 'discussion_moderation_log' - indexes = [ - models.Index(fields=['target_user', 'course_id', 'timestamp']), - models.Index(fields=['moderator', 'course_id', 'action_type']), - models.Index(fields=['course_id', 'action_type', 'timestamp']), - ] - - def __str__(self): - return f"{self.moderator} performed {self.action_type} on {self.target_user} in {self.course_id}"