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
This commit is contained in:
Muhammad Adeel Tajamul
2025-07-21 09:43:55 +05:00
committed by GitHub
parent e3d3eedd8b
commit 989ecfe5a0
7 changed files with 292 additions and 73 deletions

View File

@@ -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)

View File

@@ -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"<<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)
log.info(f"<<Bulk Delete>> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} "
f"in course {course_ids}")

View File

@@ -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

View File

@@ -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)),
]

View File

@@ -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
)

View File

@@ -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"

View File

@@ -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"