Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 181 additions & 8 deletions lms/djangoapps/discussion/rest_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1157,13 +1209,19 @@ 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)

if count_flagged and not context["has_moderation_privilege"]:
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)
Expand All @@ -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,
Expand All @@ -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.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions lms/djangoapps/discussion/rest_api/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
6 changes: 5 additions & 1 deletion lms/djangoapps/discussion/rest_api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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']:
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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"<<Bulk Delete>> Deleted comment {comment_id} in {time.time() - start_time} seconds."
f" Comment Found: {comment_id is not None}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
)

Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading