Skip to content
315 changes: 307 additions & 8 deletions lms/djangoapps/discussion/rest_api/api.py

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions lms/djangoapps/discussion/rest_api/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class UserOrdering(TextChoices):
BY_ACTIVITY = 'activity'
BY_FLAGS = 'flagged'
BY_RECENT_ACTIVITY = 'recency'
BY_DELETED = 'deleted'


class _PaginationForm(Form):
Expand Down Expand Up @@ -58,6 +59,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 +133,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
52 changes: 52 additions & 0 deletions lms/djangoapps/discussion/rest_api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ class _ContentSerializer(serializers.Serializer):
last_edit = serializers.SerializerMethodField(required=False)
edit_reason_code = serializers.CharField(required=False, validators=[validate_edit_reason_code])
edit_by_label = serializers.SerializerMethodField(required=False)
is_deleted = serializers.SerializerMethodField(read_only=True)
deleted_at = serializers.SerializerMethodField(read_only=True)
deleted_by = serializers.SerializerMethodField(read_only=True)
deleted_by_label = serializers.SerializerMethodField(read_only=True)

non_updatable_fields = set()

Expand Down Expand Up @@ -372,6 +376,51 @@ def get_edit_by_label(self, obj):
last_edit = edit_history[-1]
return self._get_user_label_from_username(last_edit.get('editor_username'))

def get_is_deleted(self, obj):
"""
Returns the is_deleted status for privileged users only.
"""
if not _validate_privileged_access(self.context):
return None
return obj.get('is_deleted', False)

def get_deleted_at(self, obj):
"""
Returns the deletion timestamp for privileged users only.
"""
if not _validate_privileged_access(self.context):
return None
return obj.get('deleted_at')

def get_deleted_by(self, obj):
"""
Returns the username of the user who deleted this content for privileged users only.
"""
if not _validate_privileged_access(self.context):
return None
deleted_by_id = obj.get('deleted_by')
if deleted_by_id:
try:
user = User.objects.get(id=int(deleted_by_id))
return user.username
except (User.DoesNotExist, ValueError):
return None
return None

def get_deleted_by_label(self, obj):
"""
Returns the role label for the user who deleted this content for privileged users only.
"""
if not _validate_privileged_access(self.context):
return None
deleted_by_id = obj.get('deleted_by')
if deleted_by_id:
try:
return self._get_user_label(int(deleted_by_id))
except (ValueError, TypeError):
return None
return None


class ThreadSerializer(_ContentSerializer):
"""
Expand Down Expand Up @@ -890,6 +939,9 @@ class UserStatsSerializer(serializers.Serializer):
threads = serializers.IntegerField()
replies = serializers.IntegerField()
responses = serializers.IntegerField()
deleted_threads = serializers.IntegerField(required=False, default=0)
deleted_replies = serializers.IntegerField(required=False, default=0)
deleted_responses = serializers.IntegerField(required=False, default=0)
active_flags = serializers.IntegerField()
inactive_flags = serializers.IntegerField()
username = serializers.CharField()
Expand Down
29 changes: 27 additions & 2 deletions lms/djangoapps/discussion/rest_api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@ def delete_course_post_for_user(user_id, username, course_ids, event_data=None):
"""
event_data = event_data or {}
log.info(f"<<Bulk Delete>> Deleting all posts for {username} in course {course_ids}")
threads_deleted = Thread.delete_user_threads(user_id, course_ids)
comments_deleted = Comment.delete_user_comments(user_id, course_ids)
# Get triggered_by user_id from event_data for audit trail
deleted_by_user_id = event_data.get('triggered_by_user_id') if event_data else None
threads_deleted = Thread.delete_user_threads(user_id, course_ids, deleted_by=deleted_by_user_id)
comments_deleted = Comment.delete_user_comments(user_id, course_ids, deleted_by=deleted_by_user_id)
log.info(f"<<Bulk Delete>> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} "
f"in course {course_ids}")
event_data.update({
Expand All @@ -111,3 +113,26 @@ def delete_course_post_for_user(user_id, username, course_ids, event_data=None):
event_name = 'edx.discussion.bulk_delete_user_posts'
tracker.emit(event_name, event_data)
segment.track('None', event_name, event_data)


@shared_task
@set_code_owner_attribute
def restore_course_post_for_user(user_id, username, course_ids, event_data=None):
"""
Restores all soft-deleted posts for user in a course by setting is_deleted=False.
"""
event_data = event_data or {}
log.info("<<Bulk Restore>> Restoring all posts for %s in course %s", username, course_ids)
# Get triggered_by user_id from event_data for audit trail
restored_by_user_id = event_data.get('triggered_by_user_id') if event_data else None
threads_restored = Thread.restore_user_deleted_threads(user_id, course_ids, restored_by=restored_by_user_id)
comments_restored = Comment.restore_user_deleted_comments(user_id, course_ids, restored_by=restored_by_user_id)
log.info("<<Bulk Restore>> Restored %s posts and %s comments for %s in course %s",
threads_restored, comments_restored, username, course_ids)
event_data.update({
"number_of_posts_restored": threads_restored,
"number_of_comments_restored": comments_restored,
})
event_name = 'edx.discussion.bulk_restore_user_posts'
tracker.emit(event_name, event_data)
segment.track('None', event_name, event_data)
18 changes: 18 additions & 0 deletions lms/djangoapps/discussion/rest_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from lms.djangoapps.discussion.rest_api.views import (
BulkDeleteUserPosts,
BulkRestoreUserPosts,
CommentViewSet,
CourseActivityStatsView,
CourseDiscussionRolesAPIView,
Expand All @@ -20,9 +21,11 @@
CourseViewV2,
LearnerThreadView,
ReplaceUsernamesView,
RestoreContent,
RetireUserView,
ThreadViewSet,
UploadFileView,
DeletedContentView,
)

ROUTER = SimpleRouter()
Expand Down Expand Up @@ -93,5 +96,20 @@
BulkDeleteUserPosts.as_view(),
name="bulk_delete_user_posts"
),
re_path(
fr"^v1/bulk_restore_user_posts/{settings.COURSE_ID_PATTERN}",
BulkRestoreUserPosts.as_view(),
name="bulk_restore_user_posts"
),
path(
"v1/restore_content",
RestoreContent.as_view(),
name="restore_content"
),
re_path(
fr"^v1/deleted_content/{settings.COURSE_ID_PATTERN}",
DeletedContentView.as_view(),
name="deleted_content"
),
path('v1/', include(ROUTER.urls)),
]
Loading
Loading