Skip to content

Commit af5fe7c

Browse files
committed
feat: soft delete
1 parent 527d1db commit af5fe7c

9 files changed

Lines changed: 658 additions & 25 deletions

File tree

lms/djangoapps/discussion/rest_api/api.py

Lines changed: 182 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@
132132
is_posting_allowed,
133133
can_user_notify_all_learners, is_captcha_enabled, get_captcha_site_key_by_platform
134134
)
135-
135+
from forum import api as forum_api
136+
import logging
137+
log = logging.getLogger(__name__)
136138
User = get_user_model()
137139

138140
ThreadType = Literal["discussion", "question"]
@@ -916,6 +918,7 @@ def get_thread_list(
916918
order_direction: Literal["desc"] = "desc",
917919
requested_fields: Optional[List[Literal["profile_image"]]] = None,
918920
count_flagged: bool = None,
921+
show_deleted: bool = False,
919922
):
920923
"""
921924
Return the list of all discussion threads pertaining to the given course
@@ -991,6 +994,8 @@ def get_thread_list(
991994

992995
if count_flagged and not context["has_moderation_privilege"]:
993996
raise PermissionDenied("`count_flagged` can only be set by users with moderator access or higher.")
997+
if show_deleted and not context["has_moderation_privilege"]:
998+
raise PermissionDenied("`show_deleted` can only be set by users with moderator access or higher.")
994999

9951000
group_id = None
9961001
allowed_roles = [
@@ -1023,6 +1028,7 @@ def get_thread_list(
10231028
"flagged": flagged,
10241029
"thread_type": thread_type,
10251030
"count_flagged": count_flagged,
1031+
"show_deleted": show_deleted,
10261032
}
10271033

10281034
if view:
@@ -1157,13 +1163,19 @@ def get_learner_active_thread_list(request, course_key, query_params):
11571163
group_id = query_params.get('group_id', None)
11581164
user_id = query_params.get('user_id', None)
11591165
count_flagged = query_params.get('count_flagged', None)
1166+
show_deleted = query_params.get('show_deleted', False)
1167+
if isinstance(show_deleted, str):
1168+
show_deleted = show_deleted.lower() == 'true'
1169+
11601170
if user_id is None:
11611171
return Response({'detail': 'Invalid user id'}, status=status.HTTP_400_BAD_REQUEST)
11621172

11631173
if count_flagged and not context["has_moderation_privilege"]:
11641174
raise PermissionDenied("count_flagged can only be set by users with moderation roles.")
11651175
if "flagged" in query_params.keys() and not context["has_moderation_privilege"]:
11661176
raise PermissionDenied("Flagged filter is only available for moderators")
1177+
if show_deleted and not context["has_moderation_privilege"]:
1178+
raise PermissionDenied("show_deleted can only be set by users with moderation roles.")
11671179

11681180
if group_id is None:
11691181
comment_client_user = comment_client.User(id=user_id, course_id=course_key)
@@ -1173,14 +1185,34 @@ def get_learner_active_thread_list(request, course_key, query_params):
11731185
try:
11741186
threads, page, num_pages = comment_client_user.active_threads(query_params)
11751187
threads = set_attribute(threads, "pinned", False)
1188+
1189+
# This portion below is temporary until we migrate to forum v2
1190+
filtered_threads = []
1191+
for thread in threads:
1192+
try:
1193+
forum_thread = forum_api.get_thread(thread.get('id'), course_id=str(course_key))
1194+
is_deleted = forum_thread.get('is_deleted', False)
1195+
1196+
if show_deleted and is_deleted:
1197+
thread['is_deleted'] = True
1198+
thread['deleted_at'] = forum_thread.get('deleted_at')
1199+
thread['deleted_by'] = forum_thread.get('deleted_by')
1200+
filtered_threads.append(thread)
1201+
elif not show_deleted and not is_deleted:
1202+
filtered_threads.append(thread)
1203+
except Exception as e:
1204+
log.warning("Failed to check thread %s deletion status: %s", thread.get('id'), e)
1205+
if not show_deleted: # Fail safe: include thread for regular users
1206+
filtered_threads.append(thread)
1207+
11761208
results = _serialize_discussion_entities(
1177-
request, context, threads, {'profile_image'}, DiscussionEntity.thread
1209+
request, context, filtered_threads, {'profile_image'}, DiscussionEntity.thread
11781210
)
11791211
paginator = DiscussionAPIPagination(
11801212
request,
11811213
page,
11821214
num_pages,
1183-
len(threads)
1215+
len(filtered_threads)
11841216
)
11851217
return paginator.get_paginated_response({
11861218
"results": results,
@@ -1196,7 +1228,7 @@ def get_learner_active_thread_list(request, course_key, query_params):
11961228

11971229

11981230
def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=False, requested_fields=None,
1199-
merge_question_type_responses=False):
1231+
merge_question_type_responses=False, show_deleted=False):
12001232
"""
12011233
Return the list of comments in the given thread.
12021234
@@ -1272,9 +1304,15 @@ def get_comment_list(request, thread_id, endorsed, page, page_size, flagged=Fals
12721304
raise PageNotFoundError("Page not found (No results on this page).")
12731305
num_pages = (resp_total + page_size - 1) // page_size if resp_total else 1
12741306

1307+
if not show_deleted:
1308+
responses = [response for response in responses if not response.get('is_deleted', False)]
1309+
else:
1310+
if not context["has_moderation_privilege"]:
1311+
raise PermissionDenied("`show_deleted` can only be set by users with moderation roles.")
1312+
12751313
results = _serialize_discussion_entities(request, context, responses, requested_fields, DiscussionEntity.comment)
12761314

1277-
paginator = DiscussionAPIPagination(request, page, num_pages, resp_total)
1315+
paginator = DiscussionAPIPagination(request, page, num_pages, len(responses))
12781316
track_thread_viewed_event(request, context["course"], cc_thread, from_mfe_sidebar)
12791317
return paginator.get_paginated_response(results)
12801318

@@ -1700,6 +1738,7 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields
17001738
try:
17011739
cc_comment = Comment(id=comment_id).retrieve()
17021740
reverse_order = request.GET.get('reverse_order', False)
1741+
show_deleted = request.GET.get('show_deleted', False)
17031742
cc_thread, context = _get_thread_and_context(
17041743
request,
17051744
cc_comment["thread_id"],
@@ -1724,6 +1763,11 @@ def get_response_comments(request, comment_id, page, page_size, requested_fields
17241763
if not paged_response_comments and page != 1:
17251764
raise PageNotFoundError("Page not found (No results on this page).")
17261765

1766+
if not show_deleted:
1767+
paged_response_comments = [response for response in paged_response_comments if not response.get('is_deleted', False)]
1768+
else:
1769+
if not context["has_moderation_privilege"]:
1770+
raise PermissionDenied("`show_deleted` can only be set by users with moderation roles.")
17271771
results = _serialize_discussion_entities(
17281772
request, context, paged_response_comments, requested_fields, DiscussionEntity.comment
17291773
)
@@ -1822,7 +1866,7 @@ def delete_thread(request, thread_id):
18221866
"""
18231867
cc_thread, context = _get_thread_and_context(request, thread_id)
18241868
if can_delete(cc_thread, context):
1825-
cc_thread.delete()
1869+
cc_thread.delete(deleted_by=str(request.user.id))
18261870
thread_deleted.send(sender=None, user=request.user, post=cc_thread)
18271871
track_thread_deleted_event(request, context["course"], cc_thread)
18281872
else:
@@ -1847,7 +1891,7 @@ def delete_comment(request, comment_id):
18471891
"""
18481892
cc_comment, context = _get_comment_and_context(request, comment_id)
18491893
if can_delete(cc_comment, context):
1850-
cc_comment.delete()
1894+
cc_comment.delete(deleted_by=str(request.user.id))
18511895
comment_deleted.send(sender=None, user=request.user, post=cc_comment)
18521896
track_comment_deleted_event(request, context["course"], cc_comment)
18531897
else:
@@ -1999,3 +2043,134 @@ def add_stats_for_users_with_null_values(course_stats, users_in_course):
19992043
})
20002044
updated_course_stats = sorted(updated_course_stats, key=lambda d: len(d['username']))
20012045
return updated_course_stats
2046+
2047+
2048+
def get_deleted_content_for_course(course_id, content_type=None, page=1, per_page=20, author_id=None):
2049+
"""
2050+
Retrieve all deleted content (threads, comments) for a course.
2051+
2052+
Args:
2053+
course_id (str): Course identifier
2054+
content_type (str, optional): Filter by 'thread' or 'comment'. If None, returns all types.
2055+
page (int): Page number for pagination (1-based)
2056+
per_page (int): Number of items per page
2057+
author_id (str, optional): Filter by author ID
2058+
2059+
Returns:
2060+
dict: Paginated results with deleted content
2061+
"""
2062+
2063+
import math
2064+
2065+
try:
2066+
# Build query parameters for forum API
2067+
query_params = {
2068+
'course_id': course_id,
2069+
'is_deleted': True, # Only get deleted content
2070+
'page': page,
2071+
'per_page': per_page,
2072+
}
2073+
2074+
if author_id:
2075+
query_params['author_id'] = author_id
2076+
2077+
deleted_content = []
2078+
total_count = 0
2079+
2080+
# Get deleted threads
2081+
if content_type is None or content_type == 'thread':
2082+
try:
2083+
deleted_threads = forum_api.get_deleted_threads_for_course(
2084+
course_id=course_id,
2085+
page=page if content_type == 'thread' else 1,
2086+
per_page=per_page if content_type == 'thread' else 1000,
2087+
author_id=author_id
2088+
)
2089+
for thread_data in deleted_threads.get('threads', []):
2090+
deleted_content.append({
2091+
'id': str(thread_data.get('_id', thread_data.get('id'))),
2092+
'type': 'thread',
2093+
'title': thread_data.get('title', ''),
2094+
'body': thread_data.get('body', ''),
2095+
'course_id': course_id,
2096+
'author_id': thread_data.get('author_id', ''),
2097+
'author_username': thread_data.get('author_username', ''),
2098+
'commentable_id': thread_data.get('commentable_id', ''),
2099+
'created_at': thread_data.get('created_at'),
2100+
'updated_at': thread_data.get('updated_at'),
2101+
'deleted_at': thread_data.get('deleted_at'),
2102+
'deleted_by': thread_data.get('deleted_by'),
2103+
'thread_type': thread_data.get('thread_type', 'discussion'),
2104+
'anonymous': thread_data.get('anonymous', False),
2105+
'anonymous_to_peers': thread_data.get('anonymous_to_peers', False),
2106+
})
2107+
2108+
if content_type == 'thread':
2109+
total_count = deleted_threads.get('total_count', len(deleted_content))
2110+
except Exception as e:
2111+
log.warning("Failed to get deleted threads for course %s: %s", course_id, e)
2112+
2113+
# Get deleted comments
2114+
if content_type is None or content_type == 'comment':
2115+
try:
2116+
deleted_comments = forum_api.get_deleted_comments_for_course(
2117+
course_id=course_id,
2118+
page=page if content_type == 'comment' else 1,
2119+
per_page=per_page if content_type == 'comment' else 1000,
2120+
author_id=author_id
2121+
)
2122+
2123+
for comment_data in deleted_comments.get('comments', []):
2124+
deleted_content.append({
2125+
'id': str(comment_data.get('_id', comment_data.get('id'))),
2126+
'type': 'comment',
2127+
'body': comment_data.get('body', ''),
2128+
'course_id': course_id,
2129+
'author_id': comment_data.get('author_id', ''),
2130+
'author_username': comment_data.get('author_username', ''),
2131+
'comment_thread_id': str(comment_data.get('comment_thread_id', '')),
2132+
'parent_id': str(comment_data.get('parent_id', '')) if comment_data.get('parent_id') else None,
2133+
'created_at': comment_data.get('created_at'),
2134+
'updated_at': comment_data.get('updated_at'),
2135+
'deleted_at': comment_data.get('deleted_at'),
2136+
'deleted_by': comment_data.get('deleted_by'),
2137+
'depth': comment_data.get('depth', 0),
2138+
'anonymous': comment_data.get('anonymous', False),
2139+
'anonymous_to_peers': comment_data.get('anonymous_to_peers', False),
2140+
'endorsed': comment_data.get('endorsed', False),
2141+
})
2142+
2143+
if content_type == 'comment':
2144+
total_count = deleted_comments.get('total_count', len(deleted_content))
2145+
except Exception as e:
2146+
log.warning("Failed to get deleted comments for course %s: %s", course_id, e)
2147+
2148+
# If getting all content types, handle pagination differently
2149+
if content_type is None:
2150+
total_count = len(deleted_content)
2151+
# Sort by deletion date (most recent first)
2152+
deleted_content.sort(key=lambda x: x.get('deleted_at', ''), reverse=True)
2153+
2154+
# Apply pagination to combined results
2155+
start_idx = (page - 1) * per_page
2156+
end_idx = start_idx + per_page
2157+
deleted_content = deleted_content[start_idx:end_idx]
2158+
2159+
# Calculate pagination info
2160+
num_pages = math.ceil(total_count / per_page) if total_count > 0 else 1
2161+
2162+
return {
2163+
'results': deleted_content,
2164+
'pagination': {
2165+
'page': page,
2166+
'per_page': per_page,
2167+
'total_count': total_count,
2168+
'num_pages': num_pages,
2169+
'has_next': page < num_pages,
2170+
'has_previous': page > 1,
2171+
}
2172+
}
2173+
2174+
except Exception as e:
2175+
log.exception("Error getting deleted content for course %s: %s", course_id, e)
2176+
raise

lms/djangoapps/discussion/rest_api/forms.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class ThreadListGetForm(_PaginationForm):
5858
)
5959
count_flagged = ExtendedNullBooleanField(required=False)
6060
flagged = ExtendedNullBooleanField(required=False)
61+
show_deleted = ExtendedNullBooleanField(required=False)
6162
view = ChoiceField(
6263
choices=[(choice, choice) for choice in ["unread", "unanswered", "unresponded"]],
6364
required=False,
@@ -131,6 +132,7 @@ class CommentListGetForm(_PaginationForm):
131132
endorsed = ExtendedNullBooleanField(required=False)
132133
requested_fields = MultiValueField(required=False)
133134
merge_question_type_responses = BooleanField(required=False)
135+
show_deleted = ExtendedNullBooleanField(required=False)
134136

135137

136138
class UserCommentListGetForm(_PaginationForm):

lms/djangoapps/discussion/rest_api/serializers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ class _ContentSerializer(serializers.Serializer):
198198
last_edit = serializers.SerializerMethodField(required=False)
199199
edit_reason_code = serializers.CharField(required=False, validators=[validate_edit_reason_code])
200200
edit_by_label = serializers.SerializerMethodField(required=False)
201+
is_deleted = serializers.SerializerMethodField(read_only=True)
202+
deleted_at = serializers.SerializerMethodField(read_only=True)
203+
deleted_by = serializers.SerializerMethodField(read_only=True)
201204

202205
non_updatable_fields = set()
203206

@@ -372,6 +375,37 @@ def get_edit_by_label(self, obj):
372375
last_edit = edit_history[-1]
373376
return self._get_user_label_from_username(last_edit.get('editor_username'))
374377

378+
def get_is_deleted(self, obj):
379+
"""
380+
Returns the is_deleted status for privileged users only.
381+
"""
382+
if not _validate_privileged_access(self.context):
383+
return None
384+
return obj.get('is_deleted', False)
385+
386+
def get_deleted_at(self, obj):
387+
"""
388+
Returns the deletion timestamp for privileged users only.
389+
"""
390+
if not _validate_privileged_access(self.context):
391+
return None
392+
return obj.get('deleted_at')
393+
394+
def get_deleted_by(self, obj):
395+
"""
396+
Returns the username of the user who deleted this content for privileged users only.
397+
"""
398+
if not _validate_privileged_access(self.context):
399+
return None
400+
deleted_by_id = obj.get('deleted_by')
401+
if deleted_by_id:
402+
try:
403+
user = User.objects.get(id=int(deleted_by_id))
404+
return user.username
405+
except (User.DoesNotExist, ValueError):
406+
return None
407+
return None
408+
375409

376410
class ThreadSerializer(_ContentSerializer):
377411
"""

lms/djangoapps/discussion/rest_api/tasks.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,10 @@ def delete_course_post_for_user(user_id, username, course_ids, event_data=None):
100100
"""
101101
event_data = event_data or {}
102102
log.info(f"<<Bulk Delete>> Deleting all posts for {username} in course {course_ids}")
103-
threads_deleted = Thread.delete_user_threads(user_id, course_ids)
104-
comments_deleted = Comment.delete_user_comments(user_id, course_ids)
103+
# Get triggered_by user_id from event_data for audit trail
104+
deleted_by_user_id = event_data.get('triggered_by_user_id') if event_data else None
105+
threads_deleted = Thread.delete_user_threads(user_id, course_ids, deleted_by=deleted_by_user_id)
106+
comments_deleted = Comment.delete_user_comments(user_id, course_ids, deleted_by=deleted_by_user_id)
105107
log.info(f"<<Bulk Delete>> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} "
106108
f"in course {course_ids}")
107109
event_data.update({
@@ -111,3 +113,26 @@ def delete_course_post_for_user(user_id, username, course_ids, event_data=None):
111113
event_name = 'edx.discussion.bulk_delete_user_posts'
112114
tracker.emit(event_name, event_data)
113115
segment.track('None', event_name, event_data)
116+
117+
118+
@shared_task
119+
@set_code_owner_attribute
120+
def restore_course_post_for_user(user_id, username, course_ids, event_data=None):
121+
"""
122+
Restores all soft-deleted posts for user in a course by setting is_deleted=False.
123+
"""
124+
event_data = event_data or {}
125+
log.info("<<Bulk Restore>> Restoring all posts for %s in course %s", username, course_ids)
126+
# Get triggered_by user_id from event_data for audit trail
127+
restored_by_user_id = event_data.get('triggered_by_user_id') if event_data else None
128+
threads_restored = Thread.restore_user_deleted_threads(user_id, course_ids, restored_by=restored_by_user_id)
129+
comments_restored = Comment.restore_user_deleted_comments(user_id, course_ids, restored_by=restored_by_user_id)
130+
log.info("<<Bulk Restore>> Restored %s posts and %s comments for %s in course %s",
131+
threads_restored, comments_restored, username, course_ids)
132+
event_data.update({
133+
"number_of_posts_restored": threads_restored,
134+
"number_of_comments_restored": comments_restored,
135+
})
136+
event_name = 'edx.discussion.bulk_restore_user_posts'
137+
tracker.emit(event_name, event_data)
138+
segment.track('None', event_name, event_data)

0 commit comments

Comments
 (0)