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:
committed by
GitHub
parent
e3d3eedd8b
commit
989ecfe5a0
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user