From 989ecfe5a0866f7b373d04ce325b9bc357108e1b Mon Sep 17 00:00:00 2001 From: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com> Date: Mon, 21 Jul 2025 09:43:55 +0500 Subject: [PATCH] feat: added endpoint for priviledged roles to delete threads of a user (#37030) * feat: added endpoint for priviledged roles to delete threads of a user * chore: moved forum calls to django comment common app * fix: fixed nits --- .../discussion/rest_api/permissions.py | 47 ++++- lms/djangoapps/discussion/rest_api/tasks.py | 17 ++ .../rest_api/tests/test_views_v2.py | 166 ++++++++++-------- lms/djangoapps/discussion/rest_api/urls.py | 6 + lms/djangoapps/discussion/rest_api/views.py | 64 +++++++ .../comment_client/comment.py | 32 ++++ .../comment_client/thread.py | 33 ++++ 7 files changed, 292 insertions(+), 73 deletions(-) diff --git a/lms/djangoapps/discussion/rest_api/permissions.py b/lms/djangoapps/discussion/rest_api/permissions.py index cb6ff4ea96..cfcea5b328 100644 --- a/lms/djangoapps/discussion/rest_api/permissions.py +++ b/lms/djangoapps/discussion/rest_api/permissions.py @@ -6,7 +6,7 @@ from typing import Dict, Set, Union from opaque_keys.edx.keys import CourseKey from rest_framework import permissions -from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment from common.djangoapps.student.roles import ( CourseInstructorRole, CourseStaffRole, @@ -19,7 +19,7 @@ from lms.djangoapps.discussion.django_comment_client.utils import ( 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.django_comment_common.models import ( - FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR + Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR ) @@ -185,3 +185,46 @@ class IsStaffOrAdmin(permissions.BasePermission): request.user.is_staff or is_user_staff and request.method == "GET" ) + + +def can_take_action_on_spam(user, course_id): + """ + Returns if the user has access to take action against forum spam posts + Parameters: + user: User object + course_id: CourseKey or string of course_id + """ + if GlobalStaff().has_user(user): + return True + + if isinstance(course_id, str): + course_id = CourseKey.from_string(course_id) + org_id = course_id.org + course_ids = CourseEnrollment.objects.filter(user=user).values_list('course_id', flat=True) + course_ids = [c_id for c_id in course_ids if c_id.org == org_id] + user_roles = set( + Role.objects.filter( + users=user, + course_id__in=course_ids, + ).values_list('name', flat=True).distinct() + ) + if bool(user_roles & {FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}): + return True + + if CourseAccessRole.objects.filter(user=user, course_id__in=course_ids, role__in=["instructor", "staff"]).exists(): + return True + return False + + +class IsAllowedToBulkDelete(permissions.BasePermission): + """ + Permission that checks if the user is staff or an admin. + """ + + def has_permission(self, request, view): + """Returns true if the user can bulk delete posts""" + if not request.user.is_authenticated: + return False + + course_id = view.kwargs.get("course_id") + return can_take_action_on_spam(request.user, course_id) diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py index e87804b1ca..54517cf6ce 100644 --- a/lms/djangoapps/discussion/rest_api/tasks.py +++ b/lms/djangoapps/discussion/rest_api/tasks.py @@ -1,6 +1,8 @@ """ Contain celery tasks """ +import logging + from celery import shared_task from django.contrib.auth import get_user_model from edx_django_utils.monitoring import set_code_owner_attribute @@ -15,7 +17,9 @@ from openedx.core.djangoapps.django_comment_common.comment_client import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS + User = get_user_model() +log = logging.getLogger(__name__) @shared_task @@ -84,3 +88,16 @@ def send_response_endorsed_notifications(thread_id, response_id, course_key_str, if int(response.user_id) != endorser.id: notification_sender.creator = User.objects.get(id=response.user_id) notification_sender.send_response_endorsed_notification() + + +@shared_task +@set_code_owner_attribute +def delete_course_post_for_user(user_id, username, course_ids): + """ + Deletes all posts for user in a course. + """ + log.info(f"<> 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) + log.info(f"<> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} " + f"in course {course_ids}") 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 9c41c11b24..2351d92ee6 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -10,55 +10,23 @@ various user roles, input data, and edge cases, and that they return appropriate import json -import random from datetime import datetime from unittest import mock -from urllib.parse import parse_qs, urlencode, urlparse import ddt +from forum.backends.mongodb.comments import Comment +from forum.backends.mongodb.threads import CommentThread import httpretty -from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import override_settings from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_flag -from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( - MockForumApiMixin, -) -from opaque_keys.edx.keys import CourseKey from pytz import UTC from rest_framework import status from rest_framework.parsers import JSONParser -from rest_framework.test import APIClient, APITestCase +from rest_framework.test import APIClient -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE -from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ( - ModuleStoreTestCase, - SharedModuleStoreTestCase, -) -from xmodule.modulestore.tests.factories import ( - CourseFactory, - BlockFactory, - check_mongo_calls, -) - -from common.djangoapps.course_modes.models import CourseMode -from common.djangoapps.course_modes.tests.factories import CourseModeFactory -from common.djangoapps.student.models import ( - get_retired_username_by_username, - CourseEnrollment, -) -from common.djangoapps.student.roles import ( - CourseInstructorRole, - CourseStaffRole, - GlobalStaff, -) +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory from common.djangoapps.student.tests.factories import ( - AdminFactory, CourseEnrollmentFactory, - SuperuserFactory, UserFactory, ) from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin @@ -67,48 +35,18 @@ from lms.djangoapps.discussion.tests.utils import ( make_minimal_cs_comment, make_minimal_cs_thread, ) -from lms.djangoapps.discussion.django_comment_client.tests.utils import ( - ForumsEnableMixin, - config_course_discussions, - topic_name_to_id, -) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin from lms.djangoapps.discussion.rest_api import api from lms.djangoapps.discussion.rest_api.tests.utils import ( - CommentsServiceMockMixin, ForumMockUtilsMixin, ProfileImageTestMixin, make_paginated_api_response, - parsed_body, -) -from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts -from openedx.core.djangoapps.discussions.config.waffle import ( - ENABLE_NEW_STRUCTURE_DISCUSSIONS, -) -from openedx.core.djangoapps.discussions.models import ( - DiscussionsConfiguration, - DiscussionTopicLink, - Provider, -) -from openedx.core.djangoapps.discussions.tasks import ( - update_discussions_settings_from_course_task, ) from openedx.core.djangoapps.django_comment_common.models import ( - CourseDiscussionSettings, - Role, -) -from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles -from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user -from openedx.core.djangoapps.oauth_dispatch.tests.factories import ( - AccessTokenFactory, - ApplicationFactory, -) -from openedx.core.djangoapps.user_api.accounts.image_helpers import ( - get_profile_image_storage, -) -from openedx.core.djangoapps.user_api.models import ( - RetirementState, - UserRetirementStatus, + FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, + assign_role ) +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin): @@ -923,3 +861,89 @@ class ThreadViewSetListTest( response_thread = json.loads(response.content.decode("utf-8"))["results"][0] assert response_thread["author"] is None assert {} == response_thread["users"] + + +@ddt.ddt +class BulkDeleteUserPostsTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """ + Tests for the BulkDeleteUserPostsViewSet + """ + + def setUp(self): + super().setUp() + self.url = reverse("bulk_delete_user_posts", kwargs={"course_id": str(self.course.id)}) + self.user2 = UserFactory.create(password=self.password) + CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id) + + def test_basic(self): + """ + Intentionally left empty because this test case is inherited from parent + """ + + def mock_comment_and_thread_count(self, comment_count=1, thread_count=1): + """ + Patches count_documents() for Comment and CommentThread._collection. + """ + thread_collection = mock.MagicMock() + thread_collection.count_documents.return_value = thread_count + patch_thread = mock.patch.object( + CommentThread, "_collection", new_callable=mock.PropertyMock, return_value=thread_collection + ) + + comment_collection = mock.MagicMock() + comment_collection.count_documents.return_value = comment_count + patch_comment = mock.patch.object( + Comment, "_collection", new_callable=mock.PropertyMock, return_value=comment_collection + ) + + thread_mock = patch_thread.start() + comment_mock = patch_comment.start() + + self.addCleanup(patch_comment.stop) + self.addCleanup(patch_thread.stop) + return thread_mock, comment_mock + + @ddt.data(FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT) + def test_bulk_delete_denied_for_discussion_roles(self, role): + """ + Test bulk delete user posts denied with discussion roles. + """ + thread_mock, comment_mock = self.mock_comment_and_thread_count(comment_count=1, thread_count=1) + assign_role(self.course.id, self.user, role) + response = self.client.post( + f"{self.url}?username={self.user2.username}", + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + thread_mock.count_documents.assert_not_called() + comment_mock.count_documents.assert_not_called() + + @ddt.data(FORUM_ROLE_MODERATOR, FORUM_ROLE_ADMINISTRATOR) + def test_bulk_delete_allowed_for_discussion_roles(self, role): + """ + Test bulk delete user posts passed with discussion roles. + """ + self.mock_comment_and_thread_count(comment_count=1, thread_count=1) + assign_role(self.course.id, self.user, role) + response = self.client.post( + f"{self.url}?username={self.user2.username}", + format="json", + ) + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.json() == {"comment_count": 1, "thread_count": 1} + + @mock.patch('lms.djangoapps.discussion.rest_api.views.delete_course_post_for_user.apply_async') + @ddt.data(True, False) + def test_task_only_runs_if_execute_param_is_true(self, execute, task_mock): + """ + Test bulk delete user posts task runs only if execute parameter is set to true. + """ + assign_role(self.course.id, self.user, FORUM_ROLE_MODERATOR) + self.mock_comment_and_thread_count(comment_count=1, thread_count=1) + response = self.client.post( + f"{self.url}?username={self.user2.username}&execute={str(execute).lower()}", + format="json", + ) + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.json() == {"comment_count": 1, "thread_count": 1} + assert task_mock.called is execute diff --git a/lms/djangoapps/discussion/rest_api/urls.py b/lms/djangoapps/discussion/rest_api/urls.py index f8c5bb3255..f102dc41f2 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -8,6 +8,7 @@ from django.urls import include, path, re_path from rest_framework.routers import SimpleRouter from lms.djangoapps.discussion.rest_api.views import ( + BulkDeleteUserPosts, CommentViewSet, CourseActivityStatsView, CourseDiscussionRolesAPIView, @@ -87,5 +88,10 @@ urlpatterns = [ CourseTopicsViewV3.as_view(), name="course_topics_v3" ), + re_path( + fr"^v1/bulk_delete_user_posts/{settings.COURSE_ID_PATTERN}", + BulkDeleteUserPosts.as_view(), + name="bulk_delete_user_posts" + ), path('v1/', include(ROUTER.urls)), ] diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index e85c12cda1..3c5e920418 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -23,9 +23,12 @@ from rest_framework.viewsets import ViewSet from xmodule.modulestore.django import modulestore +from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.util.file import store_uploaded_file from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.course_goals.models import UserActivity +from lms.djangoapps.discussion.rest_api.permissions import IsAllowedToBulkDelete +from lms.djangoapps.discussion.rest_api.tasks import delete_course_post_for_user from lms.djangoapps.discussion.django_comment_client import settings as cc_settings from lms.djangoapps.discussion.django_comment_client.utils import get_group_id_for_comments_service from lms.djangoapps.instructor.access import update_forum_role @@ -34,6 +37,8 @@ from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, 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.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 from openedx.core.djangoapps.user_api.models import UserRetirementStatus from openedx.core.lib.api.authentication import BearerAuthentication, BearerAuthenticationAllowInactiveUser @@ -1515,3 +1520,62 @@ class CourseDiscussionRolesAPIView(DeveloperErrorViewMixin, APIView): context = {'course_discussion_settings': CourseDiscussionSettings.get(course_id)} serializer = DiscussionRolesListSerializer(data, context=context) return Response(serializer.data) + + +class BulkDeleteUserPosts(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + A privileged user that can delete all posts and comments made by a user. + It returns expected number of comments and threads that will be deleted + + **Example Requests**: + POST /api/discussion/v1/bulk_delete_user_posts/{course_id} + Query Parameters: + username: The username of the user whose posts are to be deleted + course_id: Course id for which posts are to be removed + execute: If True, runs deletion task + course_or_org: If 'course', deletes posts in the course, if 'org', deletes posts in all courses of the org + + **Example Response**: + Empty string + """ + + authentication_classes = ( + JwtAuthentication, BearerAuthentication, SessionAuthentication, + ) + permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) + + def post(self, request, course_id): + """ + Implements the delete user posts endpoint. + TODO: Add support for MySQLBackend as well + """ + username = request.GET.get("username", None) + execute_task = request.GET.get("execute", "false").lower() == "true" + if (not username) or (not course_id): + raise BadRequest("username and course_id are required.") + course_or_org = request.GET.get("course_or_org", "course") + if course_or_org not in ["course", "org"]: + raise BadRequest("course_or_org must be either 'course' or 'org'.") + + user = get_object_or_404(User, username=username) + course_ids = [course_id] + if course_or_org == "org": + org_id = CourseKey.from_string(course_id).org + course_ids = [ + str(c_id) + for c_id in CourseEnrollment.objects.filter(user=request.user).values_list('course_id', flat=True) + if c_id.org == org_id + ] + + comment_count = Comment.get_user_comment_count(user.id, course_ids) + thread_count = Thread.get_user_threads_count(user.id, course_ids) + + if execute_task: + delete_course_post_for_user.apply_async( + args=(user.id, username, course_ids), + ) + return Response( + {"comment_count": comment_count, "thread_count": thread_count}, + status=status.HTTP_202_ACCEPTED + ) 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 99c7ae1b8a..bfffa1c122 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -6,6 +6,7 @@ from openedx.core.djangoapps.django_comment_common.comment_client import models, from .thread import Thread from .utils import CommentClientRequestError, get_course_key from forum import api as forum_api +from forum.backends.mongodb.comments import Comment as ForumComment class Comment(models.Model): @@ -97,6 +98,37 @@ class Comment(models.Model): soup = BeautifulSoup(self.body, 'html.parser') return soup.get_text() + @classmethod + def get_user_comment_count(cls, user_id, course_ids): + """ + Returns comments and responses count of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": "Comment" + } + return ForumComment()._collection.count_documents(query_params) # pylint: disable=protected-access + + @classmethod + def delete_user_comments(cls, user_id, course_ids): + """ + Deletes comments and responses of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + } + comments_deleted = 0 + comments = ForumComment().get_list(**query_params) + for comment in comments: + comment_id = comment.get("_id") + if comment_id: + comments_deleted += ForumComment().delete(comment_id) + return comments_deleted + def _url_for_thread_comments(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/comments" 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 de994bb13a..7c8986cbf3 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -9,6 +9,8 @@ from eventtracking import tracker from . import models, settings, utils from forum import api as forum_api from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally +from forum.backends.mongodb.threads import CommentThread + log = logging.getLogger(__name__) @@ -229,6 +231,37 @@ class Thread(models.Model): ) self._update_from_response(response) + @classmethod + def get_user_threads_count(cls, user_id, course_ids): + """ + Returns threads and responses count of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": "CommentThread" + } + return CommentThread()._collection.count_documents(query_params) # pylint: disable=protected-access + + @classmethod + def delete_user_threads(cls, user_id, course_ids): + """ + Deletes threads of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + } + threads_deleted = 0 + threads = CommentThread().get_list(**query_params) + for thread in threads: + thread_id = thread.get("_id") + if thread_id: + threads_deleted += CommentThread().delete(thread_id) + return threads_deleted + def _url_for_flag_abuse_thread(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/abuse_flag"