feat: add partial username search for course discussion user stats
This commit is contained in:
@@ -94,7 +94,13 @@ from .serializers import (
|
||||
UserStatsSerializer,
|
||||
get_context,
|
||||
)
|
||||
from .utils import discussion_open_for_user, set_attribute
|
||||
|
||||
from .utils import (
|
||||
discussion_open_for_user,
|
||||
get_usernames_from_search_string,
|
||||
add_stats_for_users_with_no_discussion_content,
|
||||
set_attribute,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -1602,6 +1608,7 @@ def get_course_discussion_user_stats(
|
||||
page: int,
|
||||
page_size: int,
|
||||
order_by: UserOrdering = None,
|
||||
username_search_string: str = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
Get paginated course discussion stats for users in the course.
|
||||
@@ -1612,6 +1619,7 @@ def get_course_discussion_user_stats(
|
||||
page (int): Page number to fetch
|
||||
page_size (int): Number of items in each page
|
||||
order_by (UserOrdering): The ordering to use for the user stats
|
||||
username_search_string (str): Partial string to match user names
|
||||
|
||||
Returns:
|
||||
Paginated data of a user's discussion stats sorted based on the specified ordering.
|
||||
@@ -1625,11 +1633,31 @@ def get_course_discussion_user_stats(
|
||||
order_by = order_by or UserOrdering.BY_ACTIVITY
|
||||
if order_by != UserOrdering.BY_ACTIVITY:
|
||||
raise ValidationError({"order_by": "Invalid value"})
|
||||
course_stats_response = get_course_user_stats(course_key, {
|
||||
params = {
|
||||
'sort_key': str(order_by),
|
||||
'page': page,
|
||||
'per_page': page_size,
|
||||
})
|
||||
}
|
||||
comma_separated_usernames = matched_users_count = matched_users_pages = None
|
||||
if username_search_string:
|
||||
comma_separated_usernames, matched_users_count, matched_users_pages = get_usernames_from_search_string(
|
||||
course_key, username_search_string, page, page_size
|
||||
)
|
||||
if not comma_separated_usernames:
|
||||
return DiscussionAPIPagination(request, 0, 1).get_paginated_response({
|
||||
"results": [],
|
||||
})
|
||||
params['usernames'] = comma_separated_usernames
|
||||
|
||||
course_stats_response = get_course_user_stats(course_key, params)
|
||||
|
||||
if comma_separated_usernames:
|
||||
updated_course_stats = add_stats_for_users_with_no_discussion_content(
|
||||
course_stats_response["user_stats"],
|
||||
comma_separated_usernames,
|
||||
)
|
||||
course_stats_response["user_stats"] = updated_course_stats
|
||||
|
||||
serializer = UserStatsSerializer(
|
||||
course_stats_response["user_stats"],
|
||||
context={"is_privileged": is_privileged},
|
||||
@@ -1639,8 +1667,8 @@ def get_course_discussion_user_stats(
|
||||
paginator = DiscussionAPIPagination(
|
||||
request,
|
||||
course_stats_response["page"],
|
||||
course_stats_response["num_pages"],
|
||||
course_stats_response["count"],
|
||||
matched_users_pages if username_search_string else course_stats_response["num_pages"],
|
||||
matched_users_count if username_search_string else course_stats_response["count"],
|
||||
)
|
||||
return paginator.get_paginated_response({
|
||||
"results": serializer.data,
|
||||
|
||||
@@ -229,3 +229,4 @@ class TopicListGetForm(Form):
|
||||
class CourseActivityStatsForm(_PaginationForm):
|
||||
"""Form for validating course activity stats API query parameters"""
|
||||
order_by = ChoiceField(choices=UserOrdering.choices, required=False)
|
||||
username = CharField(required=False)
|
||||
|
||||
@@ -21,12 +21,12 @@ from rest_framework.parsers import JSONParser
|
||||
from rest_framework.test import APIClient, APITestCase
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
|
||||
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
|
||||
from common.djangoapps.student.models import get_retired_username_by_username
|
||||
from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment
|
||||
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, SuperuserFactory, UserFactory
|
||||
from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin
|
||||
@@ -2890,7 +2890,8 @@ class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTe
|
||||
|
||||
@ddt.ddt
|
||||
@httpretty.activate
|
||||
class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceMockMixin, APITestCase):
|
||||
class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceMockMixin, APITestCase,
|
||||
SharedModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the course stats endpoint
|
||||
"""
|
||||
@@ -2898,11 +2899,12 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.course_key = 'course-v1:test+test+test'
|
||||
seed_permissions_roles(self.course_key)
|
||||
self.course = CourseFactory.create()
|
||||
self.course_key = str(self.course.id)
|
||||
seed_permissions_roles(self.course.id)
|
||||
self.user = UserFactory(username='user')
|
||||
self.moderator = UserFactory(username='moderator')
|
||||
moderator_role = Role.objects.get(name="Moderator", course_id=self.course_key)
|
||||
moderator_role = Role.objects.get(name="Moderator", course_id=self.course.id)
|
||||
moderator_role.users.add(self.moderator)
|
||||
self.stats = [
|
||||
{
|
||||
@@ -2915,6 +2917,16 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM
|
||||
}
|
||||
for idx in range(10)
|
||||
]
|
||||
|
||||
for stat in self.stats:
|
||||
user = UserFactory.create(
|
||||
username=stat['username'],
|
||||
email=f"{stat['username']}@example.com",
|
||||
password='12345'
|
||||
)
|
||||
CourseEnrollment.enroll(user, self.course.id, mode='audit')
|
||||
|
||||
CourseEnrollment.enroll(self.moderator, self.course.id, mode='audit')
|
||||
self.stats_without_flags = [{**stat, "active_flags": None, "inactive_flags": None} for stat in self.stats]
|
||||
self.register_course_stats_response(self.course_key, self.stats, 1, 3)
|
||||
self.url = reverse("discussion_course_activity_stats", kwargs={"course_key_string": self.course_key})
|
||||
@@ -2973,3 +2985,35 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM
|
||||
self.client.login(username=self.user.username, password='test')
|
||||
response = self.client.get(self.url, {"order_by": order_by})
|
||||
assert "order_by" in response.json()["field_errors"]
|
||||
|
||||
@ddt.data(
|
||||
('user', 'user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9'),
|
||||
('moderator', 'moderator'),
|
||||
)
|
||||
@ddt.unpack
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
|
||||
def test_with_username_param(self, username_search_string, comma_separated_usernames):
|
||||
"""
|
||||
Test for endpoint with username param.
|
||||
"""
|
||||
params = {'username': username_search_string}
|
||||
self.client.login(username=self.moderator.username, password='test')
|
||||
self.client.get(self.url, params)
|
||||
assert urlparse(
|
||||
httpretty.last_request().path # lint-amnesty, pylint: disable=no-member
|
||||
).path == f'/api/v1/users/{self.course_key}/stats'
|
||||
assert parse_qs(
|
||||
urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member
|
||||
).get('usernames', [None]) == [comma_separated_usernames]
|
||||
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
|
||||
def test_with_username_param_with_no_matches(self):
|
||||
"""
|
||||
Test for endpoint with username param with no matches.
|
||||
"""
|
||||
params = {'username': 'unknown'}
|
||||
self.client.login(username=self.moderator.username, password='test')
|
||||
response = self.client.get(self.url, params)
|
||||
data = response.json()
|
||||
self.assertFalse(data['results'])
|
||||
assert data['pagination']['count'] == 0
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
Utils for discussion API.
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models.functions import Length
|
||||
|
||||
from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges
|
||||
|
||||
|
||||
@@ -28,3 +32,50 @@ def set_attribute(threads, attribute, value):
|
||||
for thread in threads:
|
||||
thread[attribute] = value
|
||||
return threads
|
||||
|
||||
|
||||
def get_usernames_from_search_string(course_id, search_string, page_number, page_size):
|
||||
"""
|
||||
Gets usernames for all users in course that match string.
|
||||
|
||||
Args:
|
||||
course_id (CourseKey): Course to check discussions for
|
||||
search_string (str): String to search matching
|
||||
page_number (int): Page number to fetch
|
||||
page_size (int): Number of items in each page
|
||||
|
||||
Returns:
|
||||
page_matched_users (str): comma seperated usernames for the page
|
||||
matched_users_count (int): count of matched users in course
|
||||
matched_users_pages (int): pages of matched users in course
|
||||
"""
|
||||
matched_users_in_course = User.objects.filter(
|
||||
courseenrollment__course_id=course_id,
|
||||
username__contains=search_string).order_by(Length('username').asc()).values_list('username', flat=True)
|
||||
if not matched_users_in_course:
|
||||
return '', 0, 0
|
||||
matched_users_count = len(matched_users_in_course)
|
||||
paginator = Paginator(matched_users_in_course, page_size)
|
||||
page_matched_users = paginator.page(page_number)
|
||||
matched_users_pages = int(matched_users_count / page_size)
|
||||
return ','.join(page_matched_users), matched_users_count, matched_users_pages
|
||||
|
||||
|
||||
def add_stats_for_users_with_no_discussion_content(course_stats, users_in_course):
|
||||
"""
|
||||
Update users stats for users with no discussion stats available in course
|
||||
"""
|
||||
users_returned_from_api = [user['username'] for user in course_stats]
|
||||
user_list = users_in_course.split(',')
|
||||
users_with_no_discussion_content = set(user_list) ^ set(users_returned_from_api)
|
||||
updated_course_stats = course_stats
|
||||
for user in users_with_no_discussion_content:
|
||||
updated_course_stats.append({
|
||||
'username': user,
|
||||
'threads': 0,
|
||||
'replies': 0,
|
||||
'responses': 0,
|
||||
'active_flags': 0,
|
||||
'inactive_flags': 0,
|
||||
})
|
||||
return updated_course_stats
|
||||
|
||||
@@ -173,12 +173,14 @@ class CourseActivityStatsView(DeveloperErrorViewMixin, APIView):
|
||||
raise ValidationError(form_query_string.errors)
|
||||
order_by = form_query_string.cleaned_data.get('order_by', None)
|
||||
order_by = UserOrdering(order_by) if order_by else None
|
||||
username_search_string = form_query_string.cleaned_data.get('username', None)
|
||||
data = get_course_discussion_user_stats(
|
||||
request,
|
||||
course_key_string,
|
||||
form_query_string.cleaned_data['page'],
|
||||
form_query_string.cleaned_data['page_size'],
|
||||
order_by,
|
||||
username_search_string,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
Reference in New Issue
Block a user