diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 067a5cf9fe..906d26ef39 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -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, diff --git a/lms/djangoapps/discussion/rest_api/forms.py b/lms/djangoapps/discussion/rest_api/forms.py index 4683f241b4..b7a9286c28 100644 --- a/lms/djangoapps/discussion/rest_api/forms.py +++ b/lms/djangoapps/discussion/rest_api/forms.py @@ -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) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 72dcc4318b..53870dc7ae 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -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 diff --git a/lms/djangoapps/discussion/rest_api/utils.py b/lms/djangoapps/discussion/rest_api/utils.py index 654b4d2a5f..c8ccaf11a7 100644 --- a/lms/djangoapps/discussion/rest_api/utils.py +++ b/lms/djangoapps/discussion/rest_api/utils.py @@ -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 diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index 3cf24b4dde..3c3e4c545d 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -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