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 b87852c16cfa..0a7ccc82c193 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 @@ -35,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 @@ -55,6 +57,7 @@ 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, @@ -133,8 +136,81 @@ 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: + 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 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(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(item.get_user_id): + # Object with get_user_id method + user_id = item.get_user_id() + + # Ensure user_id is an integer + try: + if user_id is not None: + user_id = int(user_id) + except (ValueError, TypeError): + 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"] ViewType = Literal["unread", "unanswered"] ThreadOrderingType = Literal["last_activity_at", "comment_count", "vote_count"] @@ -772,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): @@ -916,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 @@ -1021,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, } @@ -1046,8 +1133,18 @@ def get_thread_list( if paginated_results.page != page: raise PageNotFoundError("Page not found (No results on this page).") + 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, paginated_results.collection, requested_fields, DiscussionEntity.thread + request, context, filtered_threads, requested_fields, DiscussionEntity.thread ) paginator = DiscussionAPIPagination( @@ -1170,17 +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 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, threads, {'profile_image'}, DiscussionEntity.thread + 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, @@ -1196,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. @@ -1272,7 +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 - results = _serialize_discussion_entities(request, context, responses, requested_fields, DiscussionEntity.comment) + 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 + ) paginator = DiscussionAPIPagination(request, page, num_pages, resp_total) track_thread_viewed_event(request, context["course"], cc_thread, from_mfe_sidebar) @@ -1915,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 cfcea5b32834..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), @@ -228,3 +230,152 @@ 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 Exception: # pylint: disable=broad-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_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 405726e2125b..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: @@ -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_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_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 f102dc41f249..3dfa60d3747c 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -19,11 +19,24 @@ CourseView, CourseViewV2, LearnerThreadView, + MuteAndReportView, + MutedUsersListView, + MuteStatusView, + MuteUserView, + UnmuteUserView, ReplaceUsernamesView, RetireUserView, 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") @@ -93,5 +106,31 @@ BulkDeleteUserPosts.as_view(), name="bulk_delete_user_posts" ), + # New forum-based mute endpoints + 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 ba9818124e08..bef623b774ff 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 +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.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 @@ -24,11 +27,14 @@ 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 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 -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 +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 +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 @@ -77,13 +89,22 @@ 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, + UnmuteRequestSerializer, + MuteAndReportRequestSerializer, ) from .utils import ( create_blocks_params, @@ -94,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() @@ -660,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): @@ -765,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", @@ -774,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) @@ -792,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']: @@ -1010,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): @@ -1615,3 +1792,605 @@ 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] + + 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=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 = 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 + 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 _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 _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', '') + ) + 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': 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 = [ + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + 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=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 = 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 + 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 _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 _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 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) + ) + + # 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 + ) + + # Use forum API to unmute user + try: + muted_by_filter = ( + str(request.user.id) if scope == 'personal' and not requesting_is_staff else None + ) + 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, + 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', + '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 = [ + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ] + permission_classes = [CanMuteUsers] + + 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 + 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 _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=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 = 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 _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 _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', '') + ) + + 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: + # 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, 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: # 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, voteable=thread) + report_record = { + 'id': f"thread_{thread_id}_{request.user.id}", + 'content_type': 'thread', + 'content_id': thread_id, + 'created': timezone.now(), + } + + 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, 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: # 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, 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', + } + + 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""" + + # URL decode the course_id parameter to handle browser encoding + course_id = unquote(course_id) + + # Parse course key + 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 _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) + ) + + # 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: + # 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' + ) + + # 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 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 + 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': total_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""" + + # 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 + course_key, error_response = _parse_course_key(course_id) + if error_response: + return error_response + + # Get target user + try: + 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) + ) + + 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': '', + 'mute_details': {} + })