Skip to content
Merged
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
47 changes: 45 additions & 2 deletions lms/djangoapps/discussion/rest_api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
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,
Expand All @@ -19,7 +19,7 @@
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
)


Expand Down Expand Up @@ -185,3 +185,46 @@ def has_permission(self, request, view):
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]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we filter org directly in the CourseEnrollment query if possible. This approach will query on each course

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will not query database for org. course_id is CourseKeyField. It is basically a string field that gets processed after query and contains attributes

Copy link
Contributor

@hassan-raza-1 hassan-raza-1 Jul 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, It may not add query. But filtering may save extra lines of code
CourseEnrollment.objects.filter(user=user, course__org=org_id).values_list('course_id', flat=True)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will not work... In database it is just a string, not a table

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion, this should work because CourseEnrollment has a foreign key to CourseOverview, and CourseOverview includes the org field.

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)
17 changes: 17 additions & 0 deletions lms/djangoapps/discussion/rest_api/tasks.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,7 +17,9 @@
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
Expand Down Expand Up @@ -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}")
166 changes: 95 additions & 71 deletions lms/djangoapps/discussion/rest_api/tests/test_views_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,55 +10,23 @@


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 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 rest_framework.test import APIClient

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
Expand All @@ -67,48 +35,18 @@
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):
Expand Down Expand Up @@ -923,3 +861,89 @@ def test_profile_image_requested_field_anonymous_user(self):
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
6 changes: 6 additions & 0 deletions lms/djangoapps/discussion/rest_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from rest_framework.routers import SimpleRouter

from lms.djangoapps.discussion.rest_api.views import (
BulkDeleteUserPosts,
CommentViewSet,
CourseActivityStatsView,
CourseDiscussionRolesAPIView,
Expand Down Expand Up @@ -87,5 +88,10 @@
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)),
]
Loading
Loading