From 1e5ed4e41f4d476e35161cb781d66f060be4c899 Mon Sep 17 00:00:00 2001 From: Naincy Chourasia Date: Wed, 1 Apr 2026 21:30:07 +0530 Subject: [PATCH] =?UTF-8?q?Revert=20"feat:=20implement=20discussion=20mute?= =?UTF-8?q?/unmute=20feature=20with=20user=20and=20staff-le=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4618c2933ac40d81bcd49935ab56f337f11274f3. --- .../tests/mock_cs_server/mock_cs_server.py | 1 - lms/djangoapps/discussion/rest_api/api.py | 239 +--------- lms/djangoapps/discussion/rest_api/forms.py | 3 - .../discussion/rest_api/forum_mute_views.py | 441 ------------------ .../discussion/rest_api/permissions.py | 141 +----- .../discussion/rest_api/serializers.py | 62 --- .../discussion/rest_api/tests/test_api_v2.py | 278 ----------- .../discussion/rest_api/tests/test_forms.py | 2 - .../rest_api/tests/test_permissions.py | 85 +--- .../rest_api/tests/test_serializers_v2.py | 4 +- .../rest_api/tests/test_views_v2.py | 9 - lms/djangoapps/discussion/rest_api/urls.py | 32 -- lms/djangoapps/discussion/rest_api/views.py | 14 +- 13 files changed, 22 insertions(+), 1289 deletions(-) delete mode 100644 lms/djangoapps/discussion/rest_api/forum_mute_views.py diff --git a/lms/djangoapps/discussion/django_comment_client/tests/mock_cs_server/mock_cs_server.py b/lms/djangoapps/discussion/django_comment_client/tests/mock_cs_server/mock_cs_server.py index afc34d42228f..bcc5da168d52 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/mock_cs_server/mock_cs_server.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/mock_cs_server/mock_cs_server.py @@ -97,7 +97,6 @@ class MockCommentServiceServer(HTTPServer): A mock Comment Service server that responds to POST requests to localhost. ''' - def __init__(self, port_num, response={'username': 'new', 'external_id': 1}): ''' diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 1163c0a25168..b0c1058b22c8 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -1,4 +1,3 @@ -# pylint: disable=too-many-statements """ Discussion API internal interface """ @@ -153,103 +152,6 @@ log = logging.getLogger(__name__) User = get_user_model() - -def filter_muted_content(request_user, course_key, content_list, return_muted_ids=False): - """ - 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 (or None if only getting muted IDs) - return_muted_ids: If True, return (filtered_list, muted_user_ids) tuple - - Returns: - list: Filtered list with muted users' content removed - tuple: (filtered_list, muted_user_ids) if return_muted_ids=True - """ - - if not request_user.is_authenticated: - if return_muted_ids: - return (content_list if content_list is not None else [], set()) - return content_list if content_list is not None else [] - - # Get muted user IDs directly from forum_api. - # Personal mutes are requester-specific; course-wide mutes should affect ALL users. - try: - muted_user_ids = set() - - # Always include requester's personal mutes. - personal_mutes = forum_api.get_all_muted_users_for_course( - course_id=str(course_key), - requester_id=str(request_user.id), - scope="personal", - requester_is_privileged=False, - ) - - muted_user_ids.update( - { - int(str(user["muted_user_id"])) - for user in personal_mutes.get("muted_users", []) - if ( - user.get("muted_user_id") - and str(user.get("muted_user_id")).isdigit() - and user.get("scope") == "personal" - and str(user.get("muter_id")) == str(request_user.id) - ) - } - ) - - # Always apply course-wide mutes for ALL users (learners and staff). - # Course-wide muted users should only appear in the "Muted" section (include_muted=True). - course_mutes = forum_api.get_all_muted_users_for_course( - course_id=str(course_key), - requester_id=str(request_user.id), - scope="course", - requester_is_privileged=True, - ) - muted_user_ids.update( - { - int(str(user["muted_user_id"])) - for user in course_mutes.get("muted_users", []) - if ( - user.get("muted_user_id") - and str(user.get("muted_user_id")).isdigit() - and user.get("scope") == "course" - ) - } - ) - - muted_user_ids = muted_user_ids - {request_user.id} # Exclude self-muting - except Exception: # pylint: disable=broad-except - log.exception("Error getting muted user IDs") - if return_muted_ids: - return (content_list if content_list is not None else [], set()) - return content_list if content_list is not None else [] - - if not muted_user_ids: - if return_muted_ids: - return (content_list if content_list is not None else [], set()) - return content_list if content_list is not None else [] - - # Filter content with optimized comprehension (if content_list provided) - if content_list is not None: - filtered_list = [ - item for item in content_list - if ( - not item.get("user_id") or - not str(item.get("user_id")).isdigit() or - int(str(item["user_id"])) == request_user.id or - int(str(item["user_id"])) not in muted_user_ids - ) - ] - else: - filtered_list = [] - - if return_muted_ids: - return (filtered_list, muted_user_ids) - return filtered_list - ThreadType = Literal["discussion", "question"] ViewType = Literal["unread", "unanswered"] ThreadOrderingType = Literal["last_activity_at", "comment_count", "vote_count"] @@ -329,12 +231,6 @@ def _get_thread_and_context(request, thread_id, retrieve_kwargs=None, course_id= both the user's access to the course and to the thread's cohort if applicable). Raises ThreadNotFoundError if the thread does not exist or the user cannot access it. - - Args: - request: The django request object - thread_id: The id for the thread to retrieve - retrieve_kwargs: Additional kwargs for thread retrieval - course_id: The course id """ retrieve_kwargs = retrieve_kwargs or {} try: @@ -977,7 +873,6 @@ def _get_user_profile_dict(request, usernames): else: username_list = [] user_profile_details = get_account_settings(request, username_list) - return {user["username"]: user for user in user_profile_details} @@ -1114,7 +1009,7 @@ def _serialize_discussion_entities( return results -def get_thread_list( # pylint: disable=too-many-statements +def get_thread_list( request: Request, course_key: CourseKey, page: int, @@ -1130,7 +1025,6 @@ def get_thread_list( # pylint: disable=too-many-statements order_direction: Literal["desc"] = "desc", requested_fields: Optional[List[Literal["profile_image"]]] = None, count_flagged: bool = None, - include_muted: bool = None, show_deleted: bool = False, ): """ @@ -1257,7 +1151,6 @@ def get_thread_list( # pylint: disable=too-many-statements "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, "show_deleted": show_deleted, @@ -1286,22 +1179,10 @@ def get_thread_list( # pylint: disable=too-many-statements if paginated_results.page != page: raise PageNotFoundError("Page not found (No results on this page).") - # Always filter muted content for All Posts tab (unless include_muted is explicitly True) - if include_muted: - # Only the muted section should set include_muted True - filtered_threads = paginated_results.collection - else: - # Always filter out muted content for All Posts, even after restoration - filtered_threads = filter_muted_content( - request.user, - course_key, - paginated_results.collection - ) - results = _serialize_discussion_entities( request, context, - filtered_threads, + paginated_results.collection, requested_fields, DiscussionEntity.thread, ) @@ -1416,7 +1297,6 @@ def get_learner_active_thread_list(request, course_key, query_params): user_id = query_params.get("user_id", None) count_flagged = query_params.get("count_flagged", None) show_deleted = query_params.get("show_deleted", False) - if isinstance(show_deleted, str): show_deleted = show_deleted.lower() == "true" @@ -1429,10 +1309,8 @@ def get_learner_active_thread_list(request, course_key, query_params): raise PermissionDenied( "count_flagged can only be set by users with moderation roles." ) - if "flagged" in query_params.keys() and not context["has_moderation_privilege"]: raise PermissionDenied("Flagged filter is only available for moderators") - if show_deleted and not context["has_moderation_privilege"]: raise PermissionDenied( "show_deleted can only be set by users with moderation roles." @@ -1445,24 +1323,13 @@ def get_learner_active_thread_list(request, course_key, query_params): id=user_id, course_id=course_key, group_id=group_id ) - 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) - if include_muted: - filtered_threads = threads - else: - filtered_threads = filter_muted_content( - request.user, - course_key, - threads - ) - # This portion below is temporary until we migrate to forum v2 - filtered_threads_with_deletion_status = [] - for thread in filtered_threads: + filtered_threads = [] + for thread in threads: try: forum_thread = forum_api.get_thread( thread.get("id"), course_id=str(course_key) @@ -1473,33 +1340,31 @@ def get_learner_active_thread_list(request, course_key, query_params): thread["is_deleted"] = True thread["deleted_at"] = forum_thread.get("deleted_at") thread["deleted_by"] = forum_thread.get("deleted_by") - filtered_threads_with_deletion_status.append(thread) + filtered_threads.append(thread) elif not show_deleted and not is_deleted: - filtered_threads_with_deletion_status.append(thread) + filtered_threads.append(thread) except Exception as e: # pylint: disable=broad-exception-caught log.warning( "Failed to check thread %s deletion status: %s", thread.get("id"), e ) if not show_deleted: # Fail safe: include thread for regular users - filtered_threads_with_deletion_status.append(thread) + filtered_threads.append(thread) results = _serialize_discussion_entities( request, context, - filtered_threads_with_deletion_status, + filtered_threads, {"profile_image"}, DiscussionEntity.thread, ) - paginator = DiscussionAPIPagination( - request, page, num_pages, len(filtered_threads_with_deletion_status) + request, page, num_pages, len(filtered_threads) ) return paginator.get_paginated_response( { "results": results, } ) - except CommentClient500Error: return DiscussionAPIPagination( request, @@ -1521,7 +1386,6 @@ def get_comment_list( flagged=False, requested_fields=None, merge_question_type_responses=False, - include_muted=False, show_deleted=False, ): """ @@ -1617,22 +1481,11 @@ def get_comment_list( "`show_deleted` can only be set by users with moderation roles." ) - # Always filter muted content for All Posts tab - if include_muted: - filtered_responses = responses - else: - # Always filter out muted content for All Posts, even after restoration - filtered_responses = filter_muted_content( - request.user, - context["course"].id, - responses - ) - results = _serialize_discussion_entities( - request, context, filtered_responses, requested_fields, DiscussionEntity.comment + request, context, responses, requested_fields, DiscussionEntity.comment ) - paginator = DiscussionAPIPagination(request, page, num_pages, len(filtered_responses)) + paginator = DiscussionAPIPagination(request, page, num_pages, len(responses)) track_thread_viewed_event(request, context["course"], cc_thread, from_mfe_sidebar) return paginator.get_paginated_response(results) @@ -2146,7 +1999,7 @@ def get_thread(request, thread_id, requested_fields=None, course_id=None): )[0] -def get_response_comments(request, comment_id, page, page_size, requested_fields=None, include_muted=False): +def get_response_comments(request, comment_id, page, page_size, requested_fields=None): """ Return the list of comments for the given thread response. @@ -2215,15 +2068,6 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields raise PermissionDenied( "`show_deleted` can only be set by users with moderation roles." ) - - # Apply muting filter if not including muted content - if not include_muted: - paged_response_comments = filter_muted_content( - request.user, - context["course"].id, - paged_response_comments - ) - results = _serialize_discussion_entities( request, context, @@ -2409,7 +2253,6 @@ def get_course_discussion_user_stats( "page": page, "per_page": page_size, } - comma_separated_usernames = matched_users_count = matched_users_pages = None if username_search_string: comma_separated_usernames, matched_users_count, matched_users_pages = ( @@ -2438,27 +2281,6 @@ 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: - # Reuse filter_muted_content logic to get muted user IDs - _, muted_user_ids = filter_muted_content( - request.user, course_key, None, return_muted_ids=True - ) - - 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"]) - # Exclude banned users from the learners list # Get all active bans for this course using forum API get_banned_usernames = getattr(forum_api, 'get_banned_usernames', None) @@ -2493,43 +2315,6 @@ def get_course_discussion_user_stats( ) course_stats_response["user_stats"] = updated_course_stats - # Course-wide muted users should only be visible to staff and privileged users - if not is_privileged: - try: - # Non-privileged users need to see which users are course-wide muted to filter them out - # Pass requester_is_privileged=False since the requester is not privileged - course_mutes = forum_api.get_all_muted_users_for_course( - course_id=str(course_key), - requester_id=None, - scope="course", - requester_is_privileged=False, - ) - - # Get course-wide muted user IDs and convert to usernames in one operation - course_wide_muted_user_ids = { - int(user.get('muted_user_id')) - for user in course_mutes.get('muted_users', []) - if user.get('muted_user_id') is not None - } - - if course_wide_muted_user_ids: - # Get usernames for muted users and filter user stats - course_wide_muted_usernames = set( - User.objects.filter(id__in=course_wide_muted_user_ids) - .values_list('username', flat=True) - ) - - # Filter out course-wide muted users from stats, but allow muted users to see themselves - requester_username = request.user.username - course_stats_response["user_stats"] = [ - user_stat for user_stat in course_stats_response["user_stats"] - if (user_stat.get("username") not in course_wide_muted_usernames or - user_stat.get("username") == requester_username) - ] - - except Exception as e: # pylint: disable=broad-except - log.warning(f"Failed to filter course-wide muted users: {e}") - serializer = UserStatsSerializer( course_stats_response["user_stats"], context={"is_privileged": is_privileged}, diff --git a/lms/djangoapps/discussion/rest_api/forms.py b/lms/djangoapps/discussion/rest_api/forms.py index 1cff9cd35bb8..f37543723792 100644 --- a/lms/djangoapps/discussion/rest_api/forms.py +++ b/lms/djangoapps/discussion/rest_api/forms.py @@ -62,7 +62,6 @@ class ThreadListGetForm(_PaginationForm): ) count_flagged = ExtendedNullBooleanField(required=False) flagged = ExtendedNullBooleanField(required=False) - include_muted = ExtendedNullBooleanField(required=False) show_deleted = ExtendedNullBooleanField(required=False) view = ChoiceField( choices=[ @@ -145,7 +144,6 @@ class CommentListGetForm(_PaginationForm): endorsed = ExtendedNullBooleanField(required=False) requested_fields = MultiValueField(required=False) merge_question_type_responses = BooleanField(required=False) - include_muted = BooleanField(required=False) show_deleted = ExtendedNullBooleanField(required=False) @@ -183,7 +181,6 @@ class CommentGetForm(_PaginationForm): """ requested_fields = MultiValueField(required=False) - include_muted = BooleanField(required=False) class CourseDiscussionSettingsForm(Form): diff --git a/lms/djangoapps/discussion/rest_api/forum_mute_views.py b/lms/djangoapps/discussion/rest_api/forum_mute_views.py deleted file mode 100644 index f520750b9aa7..000000000000 --- a/lms/djangoapps/discussion/rest_api/forum_mute_views.py +++ /dev/null @@ -1,441 +0,0 @@ -""" -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 django.core.exceptions import ValidationError -from django.http import Http404 -from django.shortcuts import get_object_or_404 -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 rest_framework.exceptions import PermissionDenied -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 -) -from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole, GlobalStaff -from lms.djangoapps.discussion.rest_api.serializers import ( - MuteRequestSerializer, - UnmuteRequestSerializer, - MuteAndReportRequestSerializer -) -from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges -from forum import api as forum_api -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin -from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread -from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment -from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError - -log = logging.getLogger(__name__) -User = get_user_model() - - -def _get_target_user_and_data(request_data, course_id): - """ - Extract target user and normalized mute data from request payload. - - Returns: - (User, dict) on success - (None, None) on failure - """ - try: - if 'username' in request_data: - target_user = User.objects.get( - username=request_data['username'] - ) - muted_user_id = target_user.id - scope = ( - 'course' - if request_data.get('is_course_wide') - else 'personal' - ) - course_id_value = course_id - else: - muted_user_id = request_data.get('muted_user_id') - target_user = User.objects.get(id=muted_user_id) - scope = request_data.get('scope', 'personal') - course_id_value = request_data.get('course_id', course_id) - - data = { - 'muted_user_id': muted_user_id, - 'course_id': course_id_value, - 'scope': scope, - 'reason': request_data.get('reason', ''), - 'muter_id': request_data.get('muter_id'), - } - - return target_user, data - - except (User.DoesNotExist, ValueError, TypeError): - return None, None - - -def _is_privileged_user(user, course_key): - """Check if user has privileged permissions.""" - return ( - has_discussion_privileges(user, course_key) or - GlobalStaff().has_user(user) or - CourseStaffRole(course_key).has_user(user) or - CourseInstructorRole(course_key).has_user(user) - ) - - -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""" - course_id = unquote(course_id) - target_user, data = _get_target_user_and_data(request.data, course_id) - - if not target_user: - raise Http404("Target user not found") - - # Validate data - serializer = MuteRequestSerializer(data=data) - if not serializer.is_valid(): - raise ValidationError(serializer.errors) - - course_key = CourseKey.from_string(course_id) - - # Check self-mute and permissions - if request.user.id == target_user.id: - raise ValidationError("Users cannot mute themselves") - - # For course-wide actions, user must have permissions to mute at course level - if not CanMuteUsers.can_mute(request.user, target_user, course_key, data.get('scope', 'personal')): - raise PermissionDenied("Permission denied") - - # Call forum API - try: - result = forum_api.mute_user( - muted_user_id=str(target_user.id), - muter_id=str(request.user.id), - course_id=str(course_key), - scope=data.get('scope', 'personal'), - reason=data.get('reason', ''), - requester_is_privileged=_is_privileged_user(request.user, course_key) - ) - return Response(result, status=status.HTTP_201_CREATED) - except Exception as e: # pylint: disable=broad-exception-caught - if "already muted" in str(e).lower(): - raise ValidationError("User is already muted") from e - log.exception(f"Error muting user {target_user.id} in course {course_key}") - raise ValidationError("Unable to mute user") from e - - -class ForumUnmuteUserView(DeveloperErrorViewMixin, APIView): - """ - API endpoint to unmute a user in discussions using forum service. - - **POST /api/discussion/v1/moderation/forum-unmute/{course_id}/** - - 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""" - course_id = unquote(course_id) - target_user, data = _get_target_user_and_data(request.data, course_id) - - if not target_user: - raise Http404("Target user not found") - - # Validate data - serializer = UnmuteRequestSerializer(data=data) - if not serializer.is_valid(): - raise ValidationError(serializer.errors) - - course_key = CourseKey.from_string(course_id) - scope = data.get('scope', 'personal') - - # Check permissions - if not CanMuteUsers.can_unmute(request.user, target_user, course_key, scope): - raise PermissionDenied("Permission denied") - - # Handle muter_id for personal unmutes - muter_id = None - if scope == 'personal': - muter_id = request.user.id - - # Call forum API - try: - 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, - muter_id=str(muter_id) if muter_id else None - ) - return Response(result) - except Exception as e: # pylint: disable=broad-exception-caught - if "no active mute found" in str(e).lower(): - raise Http404("No active mute found") from e - log.exception(f"Error unmuting user {target_user.id} in course {course_key}") - raise ValidationError("Unable to unmute user") from e - - -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/{course_id}/** - """ - authentication_classes = [ - JwtAuthentication, - SessionAuthenticationAllowInactiveUser, - ] - permission_classes = [CanMuteUsers] - - def post(self, request, course_id): - """Mute a user and report their content using forum service""" - course_id = unquote(course_id) - course_key = CourseKey.from_string(course_id) - raw_data = request.data.copy() - - # Handle frontend format - if 'username' in raw_data: - try: - target_user = User.objects.get(username=raw_data.get('username')) - except User.DoesNotExist as exc: - raise Http404("Target user not found") from exc - - # Handle post_id (thread or comment) - thread_id = comment_id = '' - post_id = raw_data.get('post_id', '') - if post_id: - try: - Thread.find(post_id).retrieve() - thread_id = post_id - except (CommentClientRequestError, Exception): # pylint: disable=broad-exception-caught - try: - Comment.find(post_id).retrieve() - comment_id = post_id - except (CommentClientRequestError, Exception): # pylint: disable=broad-exception-caught - log.warning(f"Post ID {post_id} not found as thread or comment") - - data = { - 'muted_user_id': target_user.id, - 'course_id': course_id, - 'scope': 'course' if raw_data.get('is_course_wide') else 'personal', - 'reason': raw_data.get('reason', ''), - 'thread_id': thread_id, - 'comment_id': comment_id, - } - else: - data = { - 'muted_user_id': raw_data.get('muted_user_id'), - 'course_id': raw_data.get('course_id', course_id), - 'scope': raw_data.get('scope', 'personal'), - 'reason': raw_data.get('reason', ''), - 'thread_id': raw_data.get('thread_id', ''), - 'comment_id': raw_data.get('comment_id', '') - } - try: - target_user = get_object_or_404(User, id=data['muted_user_id']) - except (User.DoesNotExist, ValueError, TypeError) as exc: - raise Http404("Target user not found") from exc - - # Validate data - serializer = MuteAndReportRequestSerializer(data=data) - if not serializer.is_valid(): - raise ValidationError(serializer.errors) - - # Check self-mute and permissions - if request.user.id == target_user.id: - raise ValidationError("Users cannot mute themselves") - - # For course-wide actions, user must have permissions to mute at course level - if not CanMuteUsers.can_mute(request.user, target_user, course_key, data.get('scope', 'personal')): - raise PermissionDenied("Permission denied") - - # Call forum API - try: - result = forum_api.mute_and_report_user( - muted_user_id=str(target_user.id), - muter_id=str(request.user.id), - course_id=str(course_key), - scope=data.get('scope', 'personal'), - reason=data.get('reason', ''), - thread_id=data.get('thread_id', ''), - comment_id=data.get('comment_id', ''), - request=request, - requester_is_privileged=_is_privileged_user(request.user, course_key) - ) - return Response(result, status=status.HTTP_201_CREATED) - except Exception as e: # pylint: disable=broad-exception-caught - if "already muted" in str(e).lower(): - raise ValidationError("User is already muted") from e - log.exception(f"Error muting and reporting user {target_user.id} in course {course_key}") - raise ValidationError("Unable to mute and report user") from e - - -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 - * Privacy restrictions: Non-staff users can only view their own mutes (muted_by is restricted to self) - * Staff users can view any user's mutes by providing their user ID - * For 'course' scope: muted_by is ignored as it returns all course-wide mutes regardless of who muted them - - 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""" - course_id = unquote(course_id) - course_key = CourseKey.from_string(course_id) - - # Get 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' - - # Check staff permissions - is_staff = _is_privileged_user(request.user, course_key) - - # Enforce restrictions for non-staff users - if not is_staff: - scope = 'personal' - # Non-staff can only view their own mutes - if muted_by and str(muted_by) != str(request.user.id): - raise PermissionDenied("Non-staff users can only view their own mutes") - else: - # Staff can view other users' mutes, validate the muted_by user exists - if muted_by and str(muted_by) != str(request.user.id): - try: - User.objects.get(id=muted_by) - except (User.DoesNotExist, ValueError, TypeError) as exc: - raise ValidationError({"muted_by": ["Invalid muted_by user ID"]}) from exc - - # Determine requester_id based on scope and muted_by parameter - if scope == 'personal': - requester_id = str(muted_by) if muted_by else str(request.user.id) - elif scope == 'course': - # muted_by is ignored for course scope as it gets all course mutes - requester_id = None - else: # scope == 'all' - requester_id = str(muted_by) if muted_by else str(request.user.id) - - # Call forum API - try: - result = forum_api.get_all_muted_users_for_course( - course_id=str(course_key), - requester_id=requester_id, - scope=scope, - requester_is_privileged=is_staff - ) - - # Process results if usernames needed - muted_users = result.get('muted_users', []) - if include_usernames and muted_users: - user_ids = {int(user['muted_user_id']) for user in muted_users if user.get('muted_user_id')} | \ - {int(user['muter_id']) for user in muted_users if user.get('muter_id')} - users_bulk = User.objects.filter(id__in=user_ids).in_bulk() - - for user_data in muted_users: - # Add usernames - if user_data.get('muted_user_id'): - user_obj = users_bulk.get(int(user_data['muted_user_id'])) - user_data['username'] = user_obj.username if user_obj else 'Unknown' - if user_data.get('muter_id'): - muter_obj = users_bulk.get(int(user_data['muter_id'])) - user_data['muted_by_username'] = muter_obj.username if muter_obj else 'Unknown' - - # Separate by scope for frontend - # Personal muted users should only include mutes made BY the current user - personal_muted = [ - u for u in muted_users - if u.get('scope') == 'personal' and str(u.get('muter_id')) == str(request.user.id) - ] - course_wide_muted = [u for u in muted_users if u.get('scope') == 'course'] - - # Filter main muted_users list to exclude other users' personal mutes - filtered_muted_users = [ - u for u in muted_users - if u.get('scope') != 'personal' or str(u.get('muter_id')) == str(request.user.id) - ] - - return Response({ - 'status': 'success', - 'muted_users': filtered_muted_users, - 'personal_muted_users': personal_muted, - 'course_wide_muted_users': course_wide_muted, - 'total_count': len(filtered_muted_users), - 'personal_count': len(personal_muted), - 'course_wide_count': len(course_wide_muted), - 'requester_id': requester_id, - 'course_id': str(course_key), - 'scope_filter': scope, - }, status=status.HTTP_200_OK) - except Exception as exc: # pylint: disable=broad-exception-caught - log.exception(f"Error getting muted users for course {course_id}") - raise ValidationError("Unable to retrieve muted users") from exc - - -class ForumMuteStatusView(DeveloperErrorViewMixin, APIView): - """ - API endpoint to get mute status for a user using forum service. - - **GET /api/discussion/v1/moderation/forum-mute-status/{course_id}/{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""" - course_id = unquote(course_id) - - # Validate user_id - try: - user_id = int(user_id) - except (ValueError, TypeError) as exc: - raise ValidationError({"user_id": ["Invalid user ID"]}) from exc - - # Call forum API - try: - result = forum_api.get_user_mute_status( - user_id=str(user_id), - course_id=str(CourseKey.from_string(course_id)), - viewer_id=str(request.user.id) - ) - return Response(result) - except Exception as exc: # pylint: disable=broad-exception-caught - log.exception(f"Error getting mute status for user {user_id}") - raise ValidationError("Unable to retrieve mute status") from exc diff --git a/lms/djangoapps/discussion/rest_api/permissions.py b/lms/djangoapps/discussion/rest_api/permissions.py index e997e5898b8a..e72f4a36b9ab 100644 --- a/lms/djangoapps/discussion/rest_api/permissions.py +++ b/lms/djangoapps/discussion/rest_api/permissions.py @@ -111,7 +111,6 @@ 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: @@ -228,8 +227,10 @@ def can_take_action_on_spam(user, course_id): course_id=course_id, ).values_list('name', flat=True) ) - if bool(user_roles & {FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}): + + if user_roles & {FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}: return True + return False @@ -276,142 +277,6 @@ def has_permission(self, request, view): return can_take_action_on_spam(request.user, course_id) -class CanMuteUsers(permissions.BasePermission): - """ - Permission class for all mute/unmute operations. - Handles muting, unmuting, and basic course access permissions. - """ - - @staticmethod - def _is_privileged_user(user, course_id): - """ - Check if user has discussion privileges. - """ - return ( - has_discussion_privileges(user, course_id) or - GlobalStaff().has_user(user) or - CourseStaffRole(course_id).has_user(user) or - CourseInstructorRole(course_id).has_user(user) - ) - - def has_permission(self, request, view): - """Check basic mute permissions - same logic as IsStaffOrCourseTeamOrEnrolled""" - if not request.user.is_authenticated: - return False - - # Get course_id from URL kwargs first (where it's actually passed) - course_id = view.kwargs.get("course_id") - if not course_id: - return False - - # Convert course_id to CourseKey if it's a string - if isinstance(course_id, str): - try: - course_id = CourseKey.from_string(course_id) - except InvalidKeyError: - return False - - # Use same permission logic as IsStaffOrCourseTeamOrEnrolled - return ( - GlobalStaff().has_user(request.user) or - CourseStaffRole(course_id).has_user(request.user) or - CourseInstructorRole(course_id).has_user(request.user) or - CourseEnrollment.is_enrolled(request.user, course_id) or - has_discussion_privileges(request.user, course_id) - ) - - @staticmethod - def can_mute(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 has discussion privileges - target_is_privileged = CanMuteUsers._is_privileged_user(target_user, course_id) - - # Check if requesting user has discussion privileges - requesting_is_privileged = CanMuteUsers._is_privileged_user(requesting_user, course_id) - - # Learners cannot mute discussion-privileged users - if target_is_privileged and not requesting_is_privileged: - return False - - # For course-wide muting, user must have discussion privileges - if scope == 'course' and not requesting_is_privileged: - return False - - # Non-privileged users must be enrolled in the course - if not requesting_is_privileged: - try: - CourseEnrollment.objects.get( - user=requesting_user, - course_id=course_id, - is_active=True - ) - except CourseEnrollment.DoesNotExist: - return False - - return True - - @staticmethod - def can_unmute(requesting_user, target_user, course_id, scope='personal'): - """ - Determine whether the requesting user can unmute the target user. - - Rules: - - Users cannot unmute themselves - - Staff (instructors, TAs, global staff) can unmute anyone at any scope - - Course-wide unmute is restricted to staff - - Personal unmute requires enrollment - - Args: - requesting_user: User attempting to unmute - target_user: User to be unmuted - course_id: Course context - scope: 'personal' or 'course' - - Returns: - bool: True if the basic permission requirements are met. - """ - # Users cannot unmute themselves as the target - if requesting_user.id == target_user.id: - return False - - # Check if requesting user is staff or has discussion privileges (includes CTAs) - requesting_is_privileged = CanMuteUsers._is_privileged_user(requesting_user, course_id) - - # Privileged users (staff, instructors, CTAs, moderators) can unmute anyone - if requesting_is_privileged: - return True - - # For course-wide unmuting, only privileged users are allowed - if scope == 'course' and not requesting_is_privileged: - return False - - # For personal unmuting, verify the user is enrolled in the course - try: - CourseEnrollment.objects.get( - user=requesting_user, - course_id=course_id, - is_active=True - ) - return True - except CourseEnrollment.DoesNotExist: - return False - - class IsAllowedToRestore(permissions.BasePermission): """ Permission that checks if the user has privileges to restore individual deleted content. diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index ec39a112d588..1f7f2264cd19 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -1407,65 +1407,3 @@ def validate(self, data): # Just record the username for the view to resolve data['lookup_username'] = data['username'] return data - -# Muting-related serializers - - -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" - ) - post_id = serializers.CharField( - required=False, - allow_blank=True, - help_text="Generic post ID (could be thread or comment) - used for retry logic" - ) - - -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)" - ) 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 2862882bf613..2ac795fa7052 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -20,7 +20,6 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.test.client import RequestFactory -from django.contrib.auth.models import AnonymousUser from opaque_keys.edx.locator import CourseLocator from pytz import UTC from rest_framework.exceptions import PermissionDenied @@ -61,7 +60,6 @@ ThreadNotFoundError, ) from lms.djangoapps.discussion.rest_api.serializers import TopicOrdering -from lms.djangoapps.discussion.rest_api.api import filter_muted_content from lms.djangoapps.discussion.rest_api.tests.utils import ( ForumMockUtilsMixin, make_paginated_api_response, @@ -96,7 +94,6 @@ from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory from xmodule.partitions.partitions import Group, UserPartition - User = get_user_model() @@ -378,7 +375,6 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit): "closed", "copy_link", "following", - "muted", "pinned", "raw_body", "read", @@ -2906,7 +2902,6 @@ def setUp(self): self.course.cohort_config = {"cohorted": False} modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) self.cohort = CohortFactory.create(course_id=self.course.id) - self.set_mock_return_value("get_all_muted_users_for_course", {"muted_users": []}) def get_thread_list( self, @@ -3518,279 +3513,6 @@ def test_invalid_order_direction(self): ).data assert "order_direction" in assertion.value.message_dict - def test_muted_content_filtering_default(self): - """ - Test that threads from muted users are omitted by default (include_muted=False) - """ - # Create muted and non-muted users - muted_user = UserFactory.create() - non_muted_user = UserFactory.create() - - # Mock the mute service to return the muted user's ID in proper format - self.set_mock_return_value("get_all_muted_users_for_course", { - 'muted_users': [ - {'muted_user_id': str(muted_user.id), 'scope': 'course', 'muter_id': str(self.user.id)} - ] - }) - - # Create threads from both users - muted_thread = make_minimal_cs_thread({ - "id": "muted_thread_id", - "user_id": str(muted_user.id), - "username": muted_user.username, - "title": "Thread from muted user", - "body": "This should be filtered out" - }) - - non_muted_thread = make_minimal_cs_thread({ - "id": "visible_thread_id", - "user_id": str(non_muted_user.id), - "username": non_muted_user.username, - "title": "Thread from non-muted user", - "body": "This should be visible" - }) - - threads = [muted_thread, non_muted_thread] - - # Register the threads response and call get_thread_list directly with include_muted=False - self.register_get_threads_response(threads, page=1, num_pages=1) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - include_muted=False # Explicitly set to False - ) - - # Verify that threads are returned (filtering behavior may vary in test environment) - returned_threads = result.data["results"] - - assert len(returned_threads) >= 1 # At least the visible thread should be there - - # Verify that the visible thread is present - thread_ids = [thread["id"] for thread in returned_threads] - assert "visible_thread_id" in thread_ids - - # Find and verify the visible thread details - visible_thread = next(t for t in returned_threads if t["id"] == "visible_thread_id") - assert visible_thread["author"] == non_muted_user.username - - def test_muted_content_filtering_include_muted_true(self): - """ - Test that threads from muted users are included when include_muted=True - """ - # Create muted and non-muted users - muted_user = UserFactory.create() - non_muted_user = UserFactory.create() - - # Mock the mute service to return the muted user's ID in proper format - self.set_mock_return_value("get_all_muted_users_for_course", { - 'muted_users': [ - {'muted_user_id': str(muted_user.id), 'scope': 'course', 'muter_id': str(self.user.id)} - ] - }) - - # Create threads from both users - muted_thread = make_minimal_cs_thread({ - "id": "muted_thread_id", - "user_id": str(muted_user.id), - "username": muted_user.username, - "title": "Thread from muted user", - "body": "This should be included" - }) - - non_muted_thread = make_minimal_cs_thread({ - "id": "visible_thread_id", - "user_id": str(non_muted_user.id), - "username": non_muted_user.username, - "title": "Thread from non-muted user", - "body": "This should also be visible" - }) - - threads = [muted_thread, non_muted_thread] - self.register_get_threads_response(threads, page=1, num_pages=1) - - # Call get_thread_list with include_muted=True - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - include_muted=True - ) - - # Verify that both threads are returned - returned_threads = result.data["results"] - assert len(returned_threads) == 2 - thread_ids = [thread["id"] for thread in returned_threads] - assert "muted_thread_id" in thread_ids - assert "visible_thread_id" in thread_ids - - def test_muted_content_filtering_no_muted_users(self): - """ - Test that all threads are returned when no users are muted - """ - # Mock the mute service to return empty result in proper format - self.set_mock_return_value("get_all_muted_users_for_course", {'muted_users': []}) - - user1 = UserFactory.create() - user2 = UserFactory.create() - - # Create threads from both users - thread1 = make_minimal_cs_thread({ - "id": "thread_1", - "user_id": str(user1.id), - "username": user1.username, - "title": "Thread 1", - "body": "First thread" - }) - - thread2 = make_minimal_cs_thread({ - "id": "thread_2", - "user_id": str(user2.id), - "username": user2.username, - "title": "Thread 2", - "body": "Second thread" - }) - - threads = [thread1, thread2] - - # Register the threads response and call get_thread_list directly - self.register_get_threads_response(threads, page=1, num_pages=1) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - include_muted=False # Explicitly set to trigger mute check - ) - - # Verify that mute service was called - self.check_mock_called("get_all_muted_users_for_course") - - # Verify that both threads are returned - returned_threads = result.data["results"] - assert len(returned_threads) == 2 - thread_ids = [thread["id"] for thread in returned_threads] - assert "thread_1" in thread_ids - assert "thread_2" in thread_ids - - def test_muted_content_filtering_service_returns_empty(self): - """ - Test that when mute service returns empty set, all threads are returned (no filtering) - """ - # Mock the mute service to return empty result in proper format - self.set_mock_return_value("get_all_muted_users_for_course", {'muted_users': []}) - - user = UserFactory.create() - thread = make_minimal_cs_thread({ - "id": "thread_id", - "user_id": str(user.id), - "username": user.username, - "title": "Test thread", - "body": "Should be visible when no muted users" - }) - - # Register the threads response and call get_thread_list directly - self.register_get_threads_response([thread], page=1, num_pages=1) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - include_muted=False # Explicitly set to trigger mute check - ) - - # Verify that mute service was called - self.check_mock_called("get_all_muted_users_for_course") - - # Verify that thread is returned (no filtering due to empty muted users) - returned_threads = result.data["results"] - assert len(returned_threads) == 1 - assert returned_threads[0]["id"] == "thread_id" - - def test_muted_content_filtering_unauthenticated_user(self): - """ - Test that muted content filtering is skipped for unauthenticated users - """ - user = UserFactory.create() - thread = make_minimal_cs_thread({ - "id": "thread_id", - "user_id": str(user.id), - "username": user.username, - "title": "Test thread", - "body": "Should be visible for unauthenticated user" - }) - - # Test filter_muted_content directly with unauthenticated user - unauthenticated_request = RequestFactory().get("/test_path") - unauthenticated_request.user = AnonymousUser() - - result = filter_muted_content( - unauthenticated_request.user, - self.course.id, - [thread] - ) - - # Verify that thread is returned unfiltered - assert len(result) == 1 - assert result[0]["id"] == "thread_id" - - def test_muted_content_filtering_multiple_muted_users(self): - """ - Test filtering when multiple users are muted - """ - # Create muted and non-muted users - muted_user1 = UserFactory.create() - muted_user2 = UserFactory.create() - non_muted_user = UserFactory.create() - - # Mock the mute service to return multiple muted user IDs in proper format - self.set_mock_return_value("get_all_muted_users_for_course", { - 'muted_users': [ - {'muted_user_id': str(muted_user1.id), 'scope': 'course', 'muter_id': str(self.user.id)}, - {'muted_user_id': str(muted_user2.id), 'scope': 'course', 'muter_id': str(self.user.id)} - ] - }) - - # Create threads from all users - threads = [ - make_minimal_cs_thread({ - "id": "muted_thread_1", - "user_id": str(muted_user1.id), - "username": muted_user1.username - }), - make_minimal_cs_thread({ - "id": "visible_thread", - "user_id": str(non_muted_user.id), - "username": non_muted_user.username - }), - make_minimal_cs_thread({ - "id": "muted_thread_2", - "user_id": str(muted_user2.id), - "username": muted_user2.username - }) - ] - - # Register the threads response and call get_thread_list directly - self.register_get_threads_response(threads, page=1, num_pages=1) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - include_muted=False # Explicitly set to False to trigger filtering - ) - - # Verify that only the non-muted thread is returned - returned_threads = result.data["results"] - - assert len(returned_threads) >= 1 # At least the visible thread should be there - # Find the visible thread among the results - visible_threads = [t for t in returned_threads if t["id"] == "visible_thread"] - assert len(visible_threads) == 1 - assert visible_threads[0]["author"] == non_muted_user.username - @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_forms.py b/lms/djangoapps/discussion/rest_api/tests/test_forms.py index 6bb568b7059e..33359337933b 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_forms.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_forms.py @@ -81,7 +81,6 @@ def test_basic(self): "order_by": "last_activity_at", "order_direction": "desc", "requested_fields": set(), - 'include_muted': None, } def test_topic_id(self): @@ -226,7 +225,6 @@ def test_basic(self): "requested_fields": set(), "merge_question_type_responses": False, "show_deleted": None, - '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 4aede24838c7..e0a325a3fa3d 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_permissions.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_permissions.py @@ -17,13 +17,11 @@ can_delete, get_editable_fields, get_initializable_comment_fields, - get_initializable_thread_fields, - CanMuteUsers + get_initializable_thread_fields ) 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.django_comment_common.comment_client.user import User -from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, @@ -81,7 +79,7 @@ def test_thread( "read", "title", "topic_id", "type" } if is_privileged: - expected |= {"closed", "pinned", "close_reason_code", "voted", "muted"} + expected |= {"closed", "pinned", "close_reason_code", "voted"} if is_privileged and is_cohorted: expected |= {"group_id"} if allow_anonymous: @@ -137,7 +135,7 @@ def test_thread( if has_moderation_privilege: expected |= {"closed", "close_reason_code"} if has_moderation_privilege or is_staff_or_admin: - expected |= {"pinned", "muted"} + expected |= {"pinned"} if has_moderation_privilege or not is_author or is_staff_or_admin: expected |= {"voted"} if has_moderation_privilege and not is_author: @@ -313,80 +311,3 @@ def test_regular_user_denied(self): view = self._create_mock_view() assert not self.permission.has_permission(request, view) - - -class ModerationPermissionsTest(ModuleStoreTestCase): - """Tests for discussion moderation permissions""" - - def setUp(self): - super().setUp() - self.course = CourseFactory.create() - - def test_can_mute_self_mute_prevention(self): - """Test that users cannot mute themselves""" - - user = UserFactory.create() - - # Self-mute should always return False - result = CanMuteUsers.can_mute(user, user, self.course.id, 'personal') - assert result is False - - result = CanMuteUsers.can_mute(user, user, self.course.id, 'course') - assert result is False - - def test_can_mute_basic_logic(self): - """Test basic mute permission logic""" - - 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 = CanMuteUsers.can_mute(user1, user2, self.course.id, 'personal') - assert result is True - - # Course-wide mute should fail for non-staff - result = CanMuteUsers.can_mute(user1, user2, self.course.id, 'course') - assert result is False - - def test_can_mute_staff_permissions(self): - """Test staff mute permissions""" - - 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 = CanMuteUsers.can_mute(staff_user, learner, self.course.id, 'course') - assert result is True - - # Staff should also be able to do personal mutes - result = CanMuteUsers.can_mute(staff_user, learner, self.course.id, 'personal') - assert result is True - - def test_can_unmute_user_basic_logic(self): - """Test basic unmute permission logic""" - - user1 = UserFactory.create() - user2 = UserFactory.create() - - # Create enrollments for unmute operations - 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) - - # Personal unmute should work - result = CanMuteUsers.can_unmute(user1, user2, self.course.id, 'personal') - assert result is True - - # Course unmute should fail for non-staff - result = CanMuteUsers.can_unmute(user1, user2, self.course.id, 'course') - assert result is False diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py index c5833036f95b..67c031a07dfd 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py @@ -994,7 +994,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', 'muted', 'pinned', + editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', 'raw_body', 'title', 'topic_id', 'type']) expected = self.expected_thread_data({ "author": author.username, @@ -1053,7 +1053,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', 'muted', 'pinned', + editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', 'raw_body', 'title', 'topic_id', 'type']) expected = self.expected_thread_data({ 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 686aac3ad7f4..c5d8eae03ac2 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -510,14 +510,6 @@ class ThreadViewSetListTest( def setUp(self): super().setUp() - # Patch forum_api in api module to use the same mock as the mixin - self.api_patcher = mock.patch( - 'lms.djangoapps.discussion.rest_api.api.forum_api', - self.mock_forum_api - ) - self.api_patcher.start() - self.addCleanup(self.api_patcher.stop) - self.author = UserFactory.create() self.url = reverse("thread-list") @@ -565,7 +557,6 @@ def test_404(self): ) def test_basic(self): - self.set_mock_return_value("get_all_muted_users_for_course", {"muted_users": []}) 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 071adb7d8d28..7a7cbc4b15af 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -28,13 +28,6 @@ ThreadViewSet, UploadFileView, ) -from lms.djangoapps.discussion.rest_api.forum_mute_views import ( - ForumMuteUserView, - ForumUnmuteUserView, - ForumMuteAndReportView, - ForumMutedUsersListView, - ForumMuteStatusView, -) ROUTER = SimpleRouter() ROUTER.register("threads", ThreadViewSet, basename="thread") @@ -145,30 +138,5 @@ DeletedContentView.as_view(), name="deleted_content", ), - re_path( - fr"^v1/moderation/forum-mute/{settings.COURSE_ID_PATTERN}/$", - ForumMuteUserView.as_view(), - name="forum_mute_user" - ), - re_path( - fr"^v1/moderation/forum-unmute/{settings.COURSE_ID_PATTERN}/$", - ForumUnmuteUserView.as_view(), - name="forum_unmute_user" - ), - re_path( - fr"^v1/moderation/forum-mute-and-report/{settings.COURSE_ID_PATTERN}/$", - ForumMuteAndReportView.as_view(), - name="forum_mute_and_report" - ), - re_path( - fr"^v1/moderation/forum-muted-users/{settings.COURSE_ID_PATTERN}/$", - ForumMutedUsersListView.as_view(), - name="forum_muted_users_list" - ), - re_path( - 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 cdb837e53962..1f21c81e6ead 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -696,7 +696,6 @@ class docstring. form.cleaned_data["order_direction"], form.cleaned_data["requested_fields"], form.cleaned_data["count_flagged"], - form.cleaned_data["include_muted"], form.cleaned_data["show_deleted"], ) @@ -811,20 +810,13 @@ def get(self, request, course_id=None): threads_per_page = request.GET.get("page_size", 10) count_flagged = request.GET.get("count_flagged", False) thread_type = request.GET.get("thread_type") - # Parse include_muted parameter (boolean) - include_muted = request.GET.get('include_muted', False) - if isinstance(include_muted, str): - include_muted = include_muted.lower() == 'true' - - # Parse and map order_by parameter - order_by = request.GET.get('order_by') + order_by = request.GET.get("order_by") order_by_mapping = { "last_activity_at": "activity", "comment_count": "comments", "vote_count": "votes", } order_by = order_by_mapping.get(order_by, "activity") - post_status = request.GET.get("status", None) show_deleted = request.GET.get("show_deleted", "false").lower() == "true" discussion_id = None @@ -847,7 +839,6 @@ def get(self, request, course_id=None): "count_flagged": count_flagged, "thread_type": thread_type, "sort_key": order_by, - "include_muted": include_muted, "show_deleted": show_deleted, } if post_status: @@ -1074,7 +1065,6 @@ def list_by_thread(self, request): form.cleaned_data["flagged"], form.cleaned_data["requested_fields"], form.cleaned_data["merge_question_type_responses"], - form.cleaned_data["include_muted"], form.cleaned_data["show_deleted"], ) @@ -1109,7 +1099,6 @@ def retrieve(self, request, comment_id=None): form.cleaned_data["page"], form.cleaned_data["page_size"], form.cleaned_data["requested_fields"], - form.cleaned_data["include_muted"], ) def create(self, request): @@ -1843,6 +1832,7 @@ def post(self, request, course_id): course_or_org, course_id, ) + comment_count = Comment.get_user_deleted_comment_count(user.id, course_ids) thread_count = Thread.get_user_deleted_threads_count(user.id, course_ids) log.info(