diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index b87852c16cfa..0e4a01d3e5a8 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -916,6 +916,7 @@ def get_thread_list( order_direction: Literal["desc"] = "desc", requested_fields: Optional[List[Literal["profile_image"]]] = None, count_flagged: bool = None, + show_deleted: bool = False, ): """ Return the list of all discussion threads pertaining to the given course @@ -991,6 +992,8 @@ def get_thread_list( if count_flagged and not context["has_moderation_privilege"]: raise PermissionDenied("`count_flagged` can only be set by users with moderator access or higher.") + if show_deleted and not context["has_moderation_privilege"]: + raise PermissionDenied("`show_deleted` can only be set by users with moderator access or higher.") group_id = None allowed_roles = [ @@ -1046,15 +1049,64 @@ def get_thread_list( if paginated_results.page != page: raise PageNotFoundError("Page not found (No results on this page).") + # Handle deleted thread filtering based on show_deleted parameter + if show_deleted: + # Show only deleted threads for privileged users + from forum import api as forum_api + import logging + log = logging.getLogger(__name__) + + # First get threads that already have is_deleted field and are deleted + filtered_collection = [thread for thread in paginated_results.collection if thread.get('is_deleted', False)] + + # For threads missing is_deleted field, check via Forum API and include only deleted ones + threads_to_check = [thread for thread in paginated_results.collection if 'is_deleted' not in thread] + + for thread in threads_to_check: + try: + forum_thread = forum_api.get_thread(thread.get('id'), course_id=str(course_key)) + thread['is_deleted'] = forum_thread.get('is_deleted', False) + if thread['is_deleted']: + thread['deleted_at'] = forum_thread.get('deleted_at') + thread['deleted_by'] = forum_thread.get('deleted_by') + filtered_collection.append(thread) # Only add if deleted + except Exception as e: + log.warning("Failed to get deletion status for thread %s: %s", thread.get('id'), e) + # Don't include threads we can't verify as deleted + else: + # Standard filtering - exclude deleted threads for regular users + filtered_collection = [] + from forum import api as forum_api + import logging + log = logging.getLogger(__name__) + + for thread in paginated_results.collection: + if 'is_deleted' in thread: + # Thread already has is_deleted field + if not thread.get('is_deleted', False): + filtered_collection.append(thread) + else: + # Thread missing is_deleted field, check via Forum API + try: + forum_thread = forum_api.get_thread(thread.get('id'), course_id=str(course_key)) + if not forum_thread.get('is_deleted', False): + filtered_collection.append(thread) + else: + log.info("Filtered out deleted thread %s via Forum API check", thread.get('id')) + except Exception as e: + # If Forum API check fails, include the thread (fail safe) + log.warning("Failed to check thread %s deletion status: %s", thread.get('id'), e) + filtered_collection.append(thread) + results = _serialize_discussion_entities( - request, context, paginated_results.collection, requested_fields, DiscussionEntity.thread + request, context, filtered_collection, requested_fields, DiscussionEntity.thread ) paginator = DiscussionAPIPagination( request, paginated_results.page, paginated_results.num_pages, - paginated_results.thread_count + len(filtered_collection) ) return paginator.get_paginated_response({ "results": results, @@ -1157,6 +1209,10 @@ def get_learner_active_thread_list(request, course_key, query_params): group_id = query_params.get('group_id', None) 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' + if user_id is None: return Response({'detail': 'Invalid user id'}, status=status.HTTP_400_BAD_REQUEST) @@ -1164,6 +1220,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.") if group_id is None: comment_client_user = comment_client.User(id=user_id, course_id=course_key) @@ -1173,14 +1231,75 @@ def get_learner_active_thread_list(request, course_key, query_params): try: threads, page, num_pages = comment_client_user.active_threads(query_params) threads = set_attribute(threads, "pinned", False) + + # Enhanced filtering for deleted threads + if show_deleted: + # If show_deleted is True and user has privilege, show only deleted threads + from forum import api as forum_api + import logging + log = logging.getLogger(__name__) + + # First get threads that already have is_deleted field and are deleted + filtered_threads = [thread for thread in threads if thread.get('is_deleted', False)] + + # For threads missing is_deleted field, check via Forum API and include only deleted ones + threads_to_check = [thread for thread in threads if 'is_deleted' not in thread] + + for thread in threads_to_check: + try: + forum_thread = forum_api.get_thread(thread.get('id'), course_id=str(course_key)) + thread['is_deleted'] = forum_thread.get('is_deleted', False) + if thread['is_deleted']: + thread['deleted_at'] = forum_thread.get('deleted_at') + thread['deleted_by'] = forum_thread.get('deleted_by') + filtered_threads.append(thread) # Only add if deleted + except Exception as e: + log.warning("Failed to get deletion status for thread %s: %s", thread.get('id'), e) + # Don't include threads we can't verify as deleted + else: + # Normal filtering - exclude deleted threads + # First filter threads that already have is_deleted field + filtered_threads = [thread for thread in threads if not thread.get('is_deleted', False)] + + # For threads missing is_deleted field (from cs_comments_service), check via Forum API + threads_to_double_check = [thread for thread in filtered_threads if 'is_deleted' not in thread] + + if threads_to_double_check: + from forum import api as forum_api + import logging + log = logging.getLogger(__name__) + + final_threads = [] + for thread in filtered_threads: + if 'is_deleted' in thread: + # Thread already has is_deleted field, trust it + final_threads.append(thread) + else: + # Thread missing is_deleted field, check via Forum API + try: + forum_thread = forum_api.get_thread(thread.get('id'), course_id=str(course_key)) + if not forum_thread.get('is_deleted', False): + final_threads.append(thread) + else: + log.info("Filtered out deleted thread %s via Forum API check", thread.get('id')) + except Exception as e: + # If Forum API check fails, include the thread (fail safe) + log.warning("Failed to check thread %s deletion status: %s", thread.get('id'), e) + final_threads.append(thread) + + filtered_threads = final_threads + # else: filtered_threads is already set correctly above + results = _serialize_discussion_entities( - request, context, threads, {'profile_image'}, DiscussionEntity.thread + request, context, filtered_threads, {'profile_image'}, DiscussionEntity.thread ) + # Update pagination count since we may have filtered out deleted threads + filtered_count = len(filtered_threads) paginator = DiscussionAPIPagination( request, page, num_pages, - len(threads) + filtered_count ) return paginator.get_paginated_response({ "results": results, @@ -1196,7 +1315,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, show_deleted=False): """ Return the list of comments in the given thread. @@ -1228,6 +1347,14 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals response_skip = page_size * (page - 1) reverse_order = request.GET.get('reverse_order', False) from_mfe_sidebar = request.GET.get("enable_in_context_sidebar", False) + + # Check permissions for show_deleted parameter + if show_deleted: + # Get context to check permissions + temp_thread, temp_context = _get_thread_and_context(request, thread_id) + if not temp_context["has_moderation_privilege"]: + raise PermissionDenied("`show_deleted` can only be set by users with moderation roles.") + cc_thread, context = _get_thread_and_context( request, thread_id, @@ -1272,9 +1399,55 @@ 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) - - paginator = DiscussionAPIPagination(request, page, num_pages, resp_total) + # Handle deleted comment filtering based on show_deleted parameter + if show_deleted: + # Include all comments (both deleted and non-deleted) for privileged users + # Ensure deletion status is populated for all comments + from forum import api as forum_api + import logging + log = logging.getLogger(__name__) + + for response in responses: + if 'is_deleted' not in response: + try: + forum_comment = forum_api.get_comment(response.get('id')) + response['is_deleted'] = forum_comment.get('is_deleted', False) + if response['is_deleted']: + response['deleted_at'] = forum_comment.get('deleted_at') + response['deleted_by'] = forum_comment.get('deleted_by') + except Exception as e: + log.warning("Failed to get deletion status for comment %s: %s", response.get('id'), e) + response['is_deleted'] = False + + filtered_responses = responses + else: + # Standard filtering - exclude deleted comments for regular users + filtered_responses = [] + from forum import api as forum_api + import logging + log = logging.getLogger(__name__) + + for response in responses: + if 'is_deleted' in response: + # Comment already has is_deleted field + if not response.get('is_deleted', False): + filtered_responses.append(response) + else: + # Comment missing is_deleted field, check via Forum API + try: + forum_comment = forum_api.get_comment(response.get('id')) + if not forum_comment.get('is_deleted', False): + filtered_responses.append(response) + else: + log.info("Filtered out deleted comment %s via Forum API check", response.get('id')) + except Exception as e: + # If Forum API check fails, include the comment (fail safe) + log.warning("Failed to check comment %s deletion status: %s", response.get('id'), e) + filtered_responses.append(response) + + results = _serialize_discussion_entities(request, context, filtered_responses, requested_fields, DiscussionEntity.comment) + + paginator = DiscussionAPIPagination(request, page, num_pages, len(filtered_responses)) track_thread_viewed_event(request, context["course"], cc_thread, from_mfe_sidebar) return paginator.get_paginated_response(results) diff --git a/lms/djangoapps/discussion/rest_api/forms.py b/lms/djangoapps/discussion/rest_api/forms.py index 8cc7127645b2..ef30bfe4284c 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) + show_deleted = 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) + show_deleted = ExtendedNullBooleanField(required=False) class UserCommentListGetForm(_PaginationForm): diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index ba9818124e08..fb08e8386675 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -660,6 +660,7 @@ class docstring. form.cleaned_data["order_direction"], form.cleaned_data["requested_fields"], form.cleaned_data["count_flagged"], + form.cleaned_data["show_deleted"], ) def retrieve(self, request, thread_id=None): @@ -774,6 +775,7 @@ def get(self, request, course_id=None): } 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 username = request.GET.get('username', None) user = get_object_or_404(User, username=username) @@ -792,6 +794,7 @@ def get(self, request, course_id=None): "count_flagged": count_flagged, "thread_type": thread_type, "sort_key": order_by, + "show_deleted": show_deleted, } if post_status: if post_status not in ['flagged', 'unanswered', 'unread', 'unresponded']: @@ -1010,7 +1013,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["show_deleted"] ) def list_by_user(self, request): diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py index 8905679a45db..822d18bf3ee0 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -23,7 +23,7 @@ class Comment(models.Model): 'closed', 'created_at', 'updated_at', 'depth', 'at_position_list', 'type', 'commentable_id', 'abuse_flaggers', 'endorsement', 'child_count', 'edit_history', - 'is_spam', 'ai_moderation_reason', 'abuse_flagged', + 'is_spam', 'ai_moderation_reason', 'abuse_flagged', 'is_deleted' ] updatable_fields = [ @@ -137,7 +137,8 @@ def delete_user_comments(cls, user_id, course_ids): comment_id = comment.get("_id") course_id = comment.get("course_id") if comment_id: - forum_api.delete_comment(comment_id, course_id=course_id) + # forum_api.delete_comment(comment_id, course_id=course_id) + forum_api.soft_delete_comment(comment_id, course_id=course_id) comments_deleted += 1 log.info(f"<> Deleted comment {comment_id} in {time.time() - start_time} seconds." f" Comment Found: {comment_id is not None}") diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index 34ccd7bf2ce6..0a28b67e8cdf 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -33,7 +33,7 @@ class Thread(models.Model): 'abuse_flaggers', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type', 'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total', 'context', 'last_activity_at', 'closed_by', 'close_reason_code', 'edit_history', - 'is_spam', 'ai_moderation_reason', 'abuse_flagged', + 'is_spam', 'ai_moderation_reason', 'abuse_flagged', 'is_deleted' ] # updateable_fields are sent in PUT requests @@ -120,11 +120,18 @@ def search(cls, query_params): total_results=total_results ) ) + # Filter out soft deleted threads + collection = response.get('collection', []) + + # For now, just filter based on is_deleted field if present + # (Forum v2 will have this field, old service won't) + filtered_collection = [thread for thread in collection if not thread.get('is_deleted', False)] + return utils.CommentClientPaginatedResult( - collection=response.get('collection', []), + collection=filtered_collection, page=response.get('page', 1), num_pages=response.get('num_pages', 1), - thread_count=response.get('thread_count', 0), + thread_count=len(filtered_collection), corrected_text=response.get('corrected_text', None) ) @@ -269,11 +276,13 @@ def _delete_thread(cls, thread_id, course_id=None): raise ForumV2RequestError("Failed to prepare thread API response") from error start_time = time.perf_counter() - backend.delete_subscriptions_of_a_thread(thread_id) + # backend.delete_subscriptions_of_a_thread(thread_id) + backend.soft_delete_subscriptions_of_a_thread(thread_id) log.info(f"{prefix} Delete subscriptions {time.perf_counter() - start_time} sec") start_time = time.perf_counter() - result = backend.delete_thread(thread_id) + # result = backend.delete_thread(thread_id) + result = backend.soft_delete_thread(thread_id) log.info(f"{prefix} Delete thread {time.perf_counter() - start_time} sec") if result and not (thread["anonymous"] or thread["anonymous_to_peers"]): start_time = time.perf_counter() diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/user.py b/openedx/core/djangoapps/django_comment_common/comment_client/user.py index 187593e70717..24edd432dfc5 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -135,6 +135,24 @@ def active_threads(self, query_params=None): metric_tags=self._metric_tags, paged_results=True, ) + + # Filter out deleted threads from the response + threads = response.get('collection', []) + + # Debug: Log what fields are actually in the threads + import logging + log = logging.getLogger(__name__) + if threads: + sample_thread = threads[0] + log.info("DEBUG: Sample thread fields: %s", list(sample_thread.keys())) + log.info("DEBUG: is_deleted field value: %s", sample_thread.get('is_deleted', 'FIELD_NOT_PRESENT')) + + filtered_threads = utils.filter_deleted_content(threads) + log.info("DEBUG: Original count: %d, Filtered count: %d", len(threads), len(filtered_threads)) + + # Update the response with filtered results + response['collection'] = filtered_threads + return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) def subscribed_threads(self, query_params=None): diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py index 26625ed3a732..5b33bc5e6530 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py @@ -201,3 +201,22 @@ def get_course_key(course_id: CourseKey | str | None) -> CourseKey | None: if course_id and isinstance(course_id, str): course_id = CourseKey.from_string(course_id) return course_id + + +def filter_deleted_content(content_list): + """ + Filter out soft-deleted content from a list of threads/comments. + + This is used for client-side filtering when using Forum v1 (cs_comments_service) + which doesn't know about soft delete fields. + + Args: + content_list (list): List of threads or comments from API response + + Returns: + list: Filtered list with deleted content removed + """ + if not content_list: + return content_list + + return [content for content in content_list if not content.get('is_deleted', False)]