feat: Adds additional data and filters to discussions API

This change adds three new filters to the threads API. They are:
* Filtering only threads that are flagged for abuse
* Filtering by the thread type (discussion or question)
* Filtering by the thread author

In addition it also adds a new ``abuse_flagged_count`` field for threads. It
returns a count of the number of comments in a thread that are flagged for abuse.
This is only visible to users that have moderator privileges or higher.

Finally it also adds a ``abuse_flagged_any_user`` field that is set if any user
has flagged a thread. This field too, is only visible to moderators or above.

Co-authored-by: Kshitij Sobti <kshitij@opencraft.com>
This commit is contained in:
Aayush Agrawal
2020-09-11 17:02:48 +05:30
committed by Awais Jibran
parent 235f8244f8
commit ffe3ee3869
10 changed files with 366 additions and 114 deletions

View File

@@ -30,6 +30,7 @@ from xblock.fields import Scope
from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.xblock_config.models import CourseEditLTIFieldsEnabledFlag
from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
from common.djangoapps.edxmako.shortcuts import render_to_string
from common.djangoapps.static_replace import replace_static_urls

View File

@@ -2,52 +2,23 @@
Discussion API internal interface
"""
import itertools
from collections import defaultdict
from enum import Enum
from typing import List, Literal, Optional
from urllib.parse import urlencode, urlunparse
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.http import Http404
from django.urls import reverse
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import CourseKey
from rest_framework.exceptions import PermissionDenied
from six.moves.urllib.parse import urlencode, urlunparse
from rest_framework.request import Request
from lms.djangoapps.courseware.courses import get_course_with_access
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
from lms.djangoapps.discussion.django_comment_client.base.views import (
track_comment_created_event,
track_thread_created_event,
track_voted_event
)
from lms.djangoapps.discussion.django_comment_client.utils import (
get_accessible_discussion_xblocks,
get_group_id_for_user,
is_commentable_divided,
)
from lms.djangoapps.discussion.rest_api.exceptions import (
CommentNotFoundError,
ThreadNotFoundError,
DiscussionDisabledError,
DiscussionBlackOutException
)
from lms.djangoapps.discussion.rest_api.forms import CommentActionsForm, ThreadActionsForm
from lms.djangoapps.discussion.rest_api.pagination import DiscussionAPIPagination
from lms.djangoapps.discussion.rest_api.permissions import (
can_delete,
get_editable_fields,
get_initializable_comment_fields,
get_initializable_thread_fields
)
from lms.djangoapps.discussion.rest_api.serializers import (
CommentSerializer,
DiscussionTopicSerializer,
ThreadSerializer,
get_context
)
from lms.djangoapps.discussion.rest_api.utils import discussion_open_for_user
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.comment_client.utils import CommentClientRequestError
@@ -60,10 +31,48 @@ from openedx.core.djangoapps.django_comment_common.signals import (
thread_created,
thread_deleted,
thread_edited,
thread_voted
thread_voted,
)
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
from openedx.core.lib.exceptions import CourseNotFoundError, DiscussionNotFoundError, PageNotFoundError
from .exceptions import (
CommentNotFoundError,
DiscussionBlackOutException,
DiscussionDisabledError,
ThreadNotFoundError,
)
from .forms import CommentActionsForm, ThreadActionsForm
from .pagination import DiscussionAPIPagination
from .permissions import (
can_delete,
get_editable_fields,
get_initializable_comment_fields,
get_initializable_thread_fields,
)
from .serializers import (
CommentSerializer,
DiscussionTopicSerializer,
ThreadSerializer,
get_context,
)
from .utils import discussion_open_for_user
from ..django_comment_client.base.views import (
track_comment_created_event,
track_thread_created_event,
track_voted_event,
)
from ..django_comment_client.utils import (
get_accessible_discussion_xblocks,
get_group_id_for_user,
is_commentable_divided,
)
User = get_user_model()
ThreadType = Literal["discussion", "question"]
ViewType = Literal["unread", "unanswered"]
ThreadOrderingType = Literal["last_activity_at", "comment_count", "vote_count"]
class DiscussionTopic:
@@ -75,7 +84,7 @@ class DiscussionTopic:
self.id = topic_id # pylint: disable=invalid-name
self.name = name
self.thread_list_url = thread_list_url
self.children = children or [] # children are of same type i.e. DiscussionTopic
self.children: List[DiscussionTopic] = children or [] # children are of same type i.e. DiscussionTopic
class DiscussionEntity(Enum):
@@ -517,17 +526,20 @@ def _serialize_discussion_entities(request, context, discussion_entities, reques
def get_thread_list(
request,
course_key,
page,
page_size,
topic_id_list=None,
text_search=None,
following=False,
view=None,
order_by="last_activity_at",
order_direction="desc",
requested_fields=None,
request: Request,
course_key: CourseKey,
page: int,
page_size: int,
topic_id_list: List[str] = None,
text_search: Optional[str] = None,
following: Optional[bool] = False,
author: Optional[str] = None,
thread_type: Optional[ThreadType] = None,
flagged: Optional[bool] = None,
view: Optional[ViewType] = None,
order_by: ThreadOrderingType = "last_activity_at",
order_direction: Literal["desc"] = "desc",
requested_fields: Optional[List[Literal["profile_image"]]] = None,
):
"""
Return the list of all discussion threads pertaining to the given course
@@ -541,6 +553,9 @@ def get_thread_list(
topic_id_list: The list of topic_ids to get the discussion threads for
text_search A text search query string to match
following: If true, retrieve only threads the requester is following
author: If provided, retrieve only threads by this author
thread_type: filter for "discussion" or "question threads
flagged: filter for only threads that are flagged
view: filters for either "unread" or "unanswered" threads
order_by: The key in which to sort the threads by. The only values are
"last_activity_at", "comment_count", and "vote_count". The default is
@@ -584,6 +599,18 @@ def get_thread_list(
course = _get_course(course_key, request.user)
context = get_context(course, request)
author_id = None
if author:
try:
author_id = User.objects.get(username=author).id
except User.DoesNotExist:
# Raising an error for a missing user leaks the presence of a username,
# so just return an empty response.
return DiscussionAPIPagination(request, 0, 1).get_paginated_response({
"results": [],
"text_search_rewrite": None,
})
query_params = {
"user_id": str(request.user.id),
"group_id": (
@@ -594,6 +621,10 @@ def get_thread_list(
"per_page": page_size,
"text": text_search,
"sort_key": cc_map.get(order_by),
"author_id": author_id,
"flagged": flagged,
"thread_type": thread_type,
"count_flagged": context["is_requester_privileged"] or None,
}
if view:

View File

@@ -42,6 +42,12 @@ class ThreadListGetForm(_PaginationForm):
topic_id = MultiValueField(required=False)
text_search = CharField(required=False)
following = ExtendedNullBooleanField(required=False)
author = CharField(required=False)
thread_type = ChoiceField(
choices=[(choice, choice) for choice in ["discussion", "question"]],
required=False,
)
flagged = ExtendedNullBooleanField(required=False)
view = ChoiceField(
choices=[(choice, choice) for choice in ["unread", "unanswered"]],
required=False,

View File

@@ -2,12 +2,12 @@
Discussion API serializers
"""
from urllib.parse import urlencode, urlunparse
from django.contrib.auth.models import User as DjangoUser # lint-amnesty, pylint: disable=imported-auth-user
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.urls import reverse
from rest_framework import serializers
from six.moves.urllib.parse import urlencode, urlunparse
from common.djangoapps.student.models import get_user_by_username_or_email
from lms.djangoapps.discussion.django_comment_client.utils import (
@@ -16,12 +16,12 @@ from lms.djangoapps.discussion.django_comment_client.utils import (
get_group_id_for_user,
get_group_name,
get_group_names_by_id,
is_comment_too_deep
is_comment_too_deep,
)
from lms.djangoapps.discussion.rest_api.permissions import (
NON_UPDATABLE_COMMENT_FIELDS,
NON_UPDATABLE_THREAD_FIELDS,
get_editable_fields
get_editable_fields,
)
from lms.djangoapps.discussion.rest_api.render import render_body
from lms.djangoapps.discussion.views import get_divided_discussions
@@ -34,9 +34,11 @@ from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_MODERATOR,
Role
Role,
)
User = get_user_model()
def get_context(course, request, thread=None):
"""
@@ -208,6 +210,7 @@ class ThreadSerializer(_ContentSerializer):
source="thread_type",
choices=[(val, val) for val in ["discussion", "question"]]
)
abuse_flagged_count = serializers.SerializerMethodField(required=False)
title = serializers.CharField(validators=[validate_not_blank])
pinned = serializers.SerializerMethodField(read_only=True)
closed = serializers.BooleanField(read_only=True)
@@ -230,6 +233,15 @@ class ThreadSerializer(_ContentSerializer):
if self.instance and self.instance.get("pinned") is None:
self.instance["pinned"] = False
def get_abuse_flagged_count(self, obj):
"""
Returns the number of users that flagged content as abusive only if user has staff permissions
"""
course = self.context.get('course', None)
is_requester_privileged = self.context.get('is_requester_privileged')
if course and is_requester_privileged:
return obj.get("abuse_flagged_count")
def get_pinned(self, obj):
"""
Compensate for the fact that some threads in the comments service do
@@ -325,6 +337,7 @@ class CommentSerializer(_ContentSerializer):
endorsed_at = serializers.SerializerMethodField()
child_count = serializers.IntegerField(read_only=True)
children = serializers.SerializerMethodField(required=False)
abuse_flagged_any_user = serializers.SerializerMethodField(required=False)
non_updatable_fields = NON_UPDATABLE_COMMENT_FIELDS
@@ -351,7 +364,7 @@ class CommentSerializer(_ContentSerializer):
self._is_anonymous(self.context["thread"]) and
not self._is_user_privileged(endorser_id)
):
return DjangoUser.objects.get(id=endorser_id).username
return User.objects.get(id=endorser_id).username
return None
def get_endorsed_by_label(self, obj):
@@ -390,6 +403,17 @@ class CommentSerializer(_ContentSerializer):
return data
def get_abuse_flagged_any_user(self, obj):
"""
Returns a boolean indicating whether any user has flagged the
content as abusive.
"""
course = self.context.get('course', None)
is_requester_privileged = self.context.get('is_requester_privileged')
if course and is_requester_privileged:
return len(obj.get("abuse_flaggers", [])) > 0
def validate(self, attrs):
"""
Ensure that parent_id identifies a comment that is actually in the
@@ -566,7 +590,7 @@ class DiscussionRolesSerializer(serializers.Serializer):
try:
self.user = get_user_by_username_or_email(user_id)
return user_id
except DjangoUser.DoesNotExist:
except User.DoesNotExist:
raise ValidationError(f"'{user_id}' is not a valid student identifier") # lint-amnesty, pylint: disable=raise-missing-from
def validate(self, attrs):

View File

@@ -16,10 +16,18 @@ from django.test.client import RequestFactory
from opaque_keys.edx.locator import CourseLocator
from pytz import UTC
from rest_framework.exceptions import PermissionDenied
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, ItemFactory
from xmodule.partitions.partitions import Group, UserPartition
from common.djangoapps.student.tests.factories import BetaTesterFactory
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from common.djangoapps.student.tests.factories import StaffFactory
from common.djangoapps.student.tests.factories import (
BetaTesterFactory,
CourseEnrollmentFactory,
StaffFactory,
UserFactory,
)
from common.djangoapps.util.testing import UrlResetMixin
from common.test.utils import MockSignalHandlerMixin, disable_signal
from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
@@ -35,19 +43,19 @@ from lms.djangoapps.discussion.rest_api.api import (
get_thread,
get_thread_list,
update_comment,
update_thread
update_thread,
)
from lms.djangoapps.discussion.rest_api.exceptions import (
CommentNotFoundError,
DiscussionBlackOutException,
DiscussionDisabledError,
ThreadNotFoundError,
DiscussionBlackOutException
)
from lms.djangoapps.discussion.rest_api.tests.utils import (
CommentsServiceMockMixin,
make_minimal_cs_comment,
make_minimal_cs_thread,
make_paginated_api_response
make_paginated_api_response,
)
from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
@@ -56,14 +64,9 @@ from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_STUDENT,
Role
Role,
)
from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError
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, ItemFactory
from xmodule.partitions.partitions import Group, UserPartition
def _remove_discussion_tab(course, user_id):
@@ -730,6 +733,7 @@ class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMix
"read": True,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"abuse_flagged_count": None,
}),
self.expected_thread_data({
"id": "test_thread_id_1",
@@ -753,6 +757,7 @@ class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMix
"http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False"
),
"editable_fields": ["abuse_flagged", "following", "read", "voted"],
"abuse_flagged_count": None,
}),
]
@@ -836,6 +841,135 @@ class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMix
"text": ["test search string"],
})
def test_filter_threads_by_author(self):
thread = make_minimal_cs_thread()
self.register_get_threads_response([thread], page=1, num_pages=10)
thread_results = get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
author=self.user.username,
).data.get('results')
assert len(thread_results) == 1
expected_last_query_params = {
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["10"],
"author_id": [str(self.user.id)],
}
self.assert_last_query_params(expected_last_query_params)
def test_filter_threads_by_missing_author(self):
self.register_get_threads_response([make_minimal_cs_thread()], page=1, num_pages=10)
results = get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
author="a fake and missing username",
).data.get('results')
assert len(results) == 0
@ddt.data('question', 'discussion', None)
def test_thread_type(self, thread_type):
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
self.register_get_threads_response([], page=1, num_pages=0)
assert get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
thread_type=thread_type,
).data == expected_result
expected_last_query_params = {
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["10"],
"thread_type": [thread_type],
}
if thread_type is None:
del expected_last_query_params["thread_type"]
self.assert_last_query_params(expected_last_query_params)
@ddt.data(True, False, None)
def test_flagged(self, flagged_boolean):
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
self.register_get_threads_response([], page=1, num_pages=0)
assert get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
flagged=flagged_boolean,
).data == expected_result
expected_last_query_params = {
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["10"],
"flagged": [str(flagged_boolean)],
}
if flagged_boolean is None:
del expected_last_query_params["flagged"]
self.assert_last_query_params(expected_last_query_params)
@ddt.data(
(FORUM_ROLE_ADMINISTRATOR, True),
(FORUM_ROLE_MODERATOR, True),
(FORUM_ROLE_COMMUNITY_TA, True),
(FORUM_ROLE_STUDENT, False),
)
@ddt.unpack
def test_flagged_count(self, role, is_count_flagged_flag_set):
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
_assign_role_to_user(self.user, self.course.id, role=role)
self.register_get_threads_response([], page=1, num_pages=0)
get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
)
expected_last_query_params = {
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["10"],
}
if is_count_flagged_flag_set:
expected_last_query_params["count_flagged"] = ["True"]
self.assert_last_query_params(expected_last_query_params)
def test_following(self):
self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0)
result = get_thread_list(
@@ -1196,6 +1330,7 @@ class GetCommentListTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModu
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 4,
"editable_fields": ["abuse_flagged", "voted"],
@@ -1217,6 +1352,7 @@ class GetCommentListTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModu
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": True,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 7,
"editable_fields": ["abuse_flagged", "voted"],
@@ -1811,6 +1947,7 @@ class CreateCommentTest(
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 0,
"children": [],
@@ -1891,29 +2028,25 @@ class CreateCommentTest(
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": False,
"voted": False,
"vote_count": 0,
"children": [],
"editable_fields": ["abuse_flagged", "endorsed", "raw_body", "voted"],
"child_count": 0,
}
self.assertEqual(actual, expected)
assert actual == expected
expected_url = (
f"/api/v1/comments/{parent_id}" if parent_id else
"/api/v1/threads/test_thread/comments"
)
self.assertEqual(
urlparse(httpretty.last_request().path).path, # lint-amnesty, pylint: disable=no-member
expected_url
)
self.assertEqual(
httpretty.last_request().parsed_body, # lint-amnesty, pylint: disable=no-member
{
"course_id": [str(self.course.id)],
"body": ["Test body"],
"user_id": [str(self.user.id)]
}
)
assert urlparse(httpretty.last_request().path).path == expected_url # pylint: disable=no-member
assert httpretty.last_request().parsed_body == { # pylint: disable=no-member
"course_id": [str(self.course.id)],
"body": ["Test body"],
"user_id": [str(self.user.id)]
}
expected_event_name = (
"edx.forum.comment.created" if parent_id else
"edx.forum.response.created"
@@ -2528,6 +2661,7 @@ class UpdateCommentTest(
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 0,
"children": [],

View File

@@ -5,11 +5,11 @@ Tests for Discussion API forms
import itertools
from unittest import TestCase
from urllib.parse import urlencode
import ddt
from django.http import QueryDict
from opaque_keys.edx.locator import CourseLocator
from six.moves.urllib.parse import urlencode
from lms.djangoapps.discussion.rest_api.forms import CommentListGetForm, ThreadListGetForm
from openedx.core.djangoapps.util.test_forms import FormTestMixin
@@ -66,6 +66,9 @@ class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase):
'topic_id': set(),
'text_search': '',
'following': None,
'author': '',
'thread_type': '',
'flagged': None,
'view': '',
'order_by': 'last_activity_at',
'order_direction': 'desc',
@@ -94,6 +97,29 @@ class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase):
self.form_data.setlist("topic_id", ["", "not empty"])
self.assert_error("topic_id", "This field cannot be empty.")
@ddt.data("discussion", "question")
def test_thread_type(self, value):
self.form_data["thread_type"] = value
self.assert_field_value("thread_type", value)
def test_thread_type_invalid(self):
self.form_data["thread_type"] = "invalid-option"
self.assert_error("thread_type", "Select a valid choice. invalid-option is not one of the available choices.")
@ddt.data("True", "true", 1, True)
def test_flagged_true(self, value):
self.form_data["flagged"] = value
self.assert_field_value("flagged", True)
@ddt.data("False", "false", 0, False)
def test_flagged_false(self, value):
self.form_data["flagged"] = value
self.assert_field_value("flagged", False)
def test_invalid_flagged(self):
self.form_data["flagged"] = "invalid-boolean"
self.assert_error("flagged", "Invalid Boolean Value.")
@ddt.data("True", "true", 1, True)
def test_following_true(self, value):
self.form_data["following"] = value

View File

@@ -2,14 +2,17 @@
Tests for Discussion API serializers
"""
import itertools
from unittest import mock
from urllib.parse import urlparse
import ddt
import httpretty
from django.test.client import RequestFactory
from six.moves.urllib.parse import urlparse
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from common.djangoapps.student.tests.factories import UserFactory
from common.djangoapps.util.testing import UrlResetMixin
@@ -18,7 +21,7 @@ from lms.djangoapps.discussion.rest_api.serializers import CommentSerializer, Th
from lms.djangoapps.discussion.rest_api.tests.utils import (
CommentsServiceMockMixin,
make_minimal_cs_comment,
make_minimal_cs_thread
make_minimal_cs_thread,
)
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
@@ -28,12 +31,8 @@ from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_STUDENT,
Role
Role,
)
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@ddt.ddt
@@ -186,6 +185,7 @@ class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTe
"unread_comment_count": 3,
"pinned": True,
"editable_fields": ["abuse_flagged", "following", "read", "voted"],
"abuse_flagged_count": None,
})
assert self.serialize(thread) == expected
@@ -306,12 +306,14 @@ class CommentSerializerTest(SerializerTestMixin, SharedModuleStoreTestCase):
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 4,
"children": [],
"editable_fields": ["abuse_flagged", "voted"],
"child_count": 0,
}
assert self.serialize(comment) == expected
@ddt.data(

View File

@@ -6,6 +6,7 @@ Tests for Discussion API views
import json
from datetime import datetime
from unittest import mock
from urllib.parse import urlparse
import ddt
import httpretty
@@ -14,7 +15,10 @@ from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework.parsers import JSONParser
from rest_framework.test import APIClient, APITestCase
from six.moves.urllib.parse import urlparse
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
@@ -25,7 +29,7 @@ from common.test.utils import disable_signal
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
ForumsEnableMixin,
config_course_discussions,
topic_name_to_id
topic_name_to_id,
)
from lms.djangoapps.discussion.rest_api import api
from lms.djangoapps.discussion.rest_api.tests.utils import (
@@ -33,7 +37,7 @@ from lms.djangoapps.discussion.rest_api.tests.utils import (
ProfileImageTestMixin,
make_minimal_cs_comment,
make_minimal_cs_thread,
make_paginated_api_response
make_paginated_api_response,
)
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role
@@ -42,10 +46,6 @@ 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
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
class DiscussionAPIViewTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin):
@@ -549,6 +549,7 @@ class ThreadViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, Pro
"voted": True,
"author": self.author.username,
"editable_fields": ["abuse_flagged", "following", "read", "voted"],
"abuse_flagged_count": None,
})]
self.register_get_threads_response(source_threads, page=1, num_pages=2)
response = self.client.get(self.url, {"course_id": str(self.course.id), "following": ""})
@@ -1112,6 +1113,7 @@ class CommentViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, Pr
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 0,
"children": [],
@@ -1499,6 +1501,7 @@ class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 0,
"children": [],
@@ -1583,6 +1586,7 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 0,
"children": [],
@@ -1642,6 +1646,7 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_response_data({
'abuse_flagged': value,
"abuse_flagged_any_user": None,
'editable_fields': ['abuse_flagged']
})
@@ -1768,6 +1773,7 @@ class CommentViewSetRetrieveTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase
"voted": False,
"vote_count": 0,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"editable_fields": ["abuse_flagged", "raw_body", "voted"],
"child_count": 0,
}

View File

@@ -358,7 +358,7 @@ class CommentsServiceMockMixin:
"""
actual_params = dict(httpretty_request.querystring)
actual_params.pop("request_id") # request_id is random
assert actual_params == expected_params
assert actual_params == expected_params, f"""[\n\t{actual_params} \n\t{expected_params}\n]"""
def assert_last_query_params(self, expected_params):
"""
@@ -388,6 +388,7 @@ class CommentsServiceMockMixin:
"raw_body": "Test body",
"rendered_body": "<p>Test body</p>",
"abuse_flagged": False,
"abuse_flagged_count": None,
"voted": False,
"vote_count": 0,
"editable_fields": ["abuse_flagged", "following", "raw_body", "read", "title", "topic_id", "type", "voted"],
@@ -438,6 +439,7 @@ def make_minimal_cs_thread(overrides=None):
"pinned": False,
"closed": False,
"abuse_flaggers": [],
"abuse_flagged_count": None,
"votes": {"up_count": 0},
"comments_count": 0,
"unread_comments_count": 0,

View File

@@ -4,7 +4,8 @@ Discussion API views
import logging
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
@@ -15,9 +16,17 @@ from rest_framework.parsers import JSONParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet
from xmodule.modulestore.django import modulestore
from lms.djangoapps.discussion.django_comment_client.utils import available_division_schemes # lint-amnesty, pylint: disable=unused-import
from lms.djangoapps.discussion.rest_api.api import (
from lms.djangoapps.instructor.access import update_forum_role
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.user_api.accounts.permissions import CanReplaceUsername, CanRetireUser
from openedx.core.djangoapps.user_api.models import UserRetirementStatus
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.parsers import MergePatchParser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from ..rest_api.api import (
create_comment,
create_thread,
delete_comment,
@@ -29,34 +38,25 @@ from lms.djangoapps.discussion.rest_api.api import (
get_thread,
get_thread_list,
update_comment,
update_thread
update_thread,
)
from lms.djangoapps.discussion.rest_api.forms import (
from ..rest_api.forms import (
CommentGetForm,
CommentListGetForm,
CourseDiscussionRolesForm,
CourseDiscussionSettingsForm,
ThreadListGetForm
ThreadListGetForm,
)
from lms.djangoapps.discussion.rest_api.serializers import (
from ..rest_api.serializers import (
DiscussionRolesListSerializer,
DiscussionRolesSerializer,
DiscussionSettingsSerializer
DiscussionSettingsSerializer,
)
from lms.djangoapps.discussion.views import get_divided_discussions # lint-amnesty, pylint: disable=unused-import
from lms.djangoapps.instructor.access import update_forum_role
from openedx.core.djangoapps.django_comment_common import comment_client
from openedx.core.djangoapps.django_comment_common.models import Role
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings
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 BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.parsers import MergePatchParser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from xmodule.modulestore.django import modulestore
log = logging.getLogger(__name__)
User = get_user_model()
@view_auth_classes()
class CourseView(DeveloperErrorViewMixin, APIView):
@@ -83,6 +83,8 @@ class CourseView(DeveloperErrorViewMixin, APIView):
* thread_list_url: The URL of the list of all threads in the course.
* following_thread_list_url: thread_list_url with parameter following=True
* topics_url: The URL of the topic listing for the course.
"""
def get(self, request, course_id):
@@ -174,6 +176,14 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet):
multiple topic_id queries to retrieve threads from multiple topics
at once.
* author: The username of an author. If provided, only threads by this
author will be returned.
* thread_type: Can be 'discussion' or 'question', only return threads of
the selected thread type.
* flagged: If True, only return threads that have been flagged (reported)
* text_search: A search string to match. Any thread whose content
(including the bodies of comments in the thread) matches the search
string will be returned.
@@ -290,6 +300,9 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet):
* response_count: The number of direct responses for a thread
* abuse_flagged_count: The number of flags(reports) on and within the
thread. Returns null if requesting user is not a moderator
**DELETE response values:
No content is returned for a DELETE request
@@ -314,6 +327,9 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet):
form.cleaned_data["topic_id"],
form.cleaned_data["text_search"],
form.cleaned_data["following"],
form.cleaned_data["author"],
form.cleaned_data["thread_type"],
form.cleaned_data["flagged"],
form.cleaned_data["view"],
form.cleaned_data["order_by"],
form.cleaned_data["order_direction"],
@@ -468,6 +484,10 @@ class CommentViewSet(DeveloperErrorViewMixin, ViewSet):
* abuse_flagged: Boolean indicating whether the requesting user has
flagged the comment for abuse
* abuse_flagged_any_user: Boolean indicating whether any user has
flagged the comment for abuse. Returns null if requesting user
is not a moderator.
* voted: Boolean indicating whether the requesting user has voted
for the comment