From b528b8e5d720dc6b377cfdbbc9acea3d6d8544b4 Mon Sep 17 00:00:00 2001
From: Alex Dusenbery <adusenbery@edx.org>
Date: Fri, 26 Oct 2018 11:47:44 -0400
Subject: [PATCH] EDUCATOR-3622 | Add a username_contains query param to
 gradebook endpoint

---
 .../grades/api/v1/tests/test_views.py         | 82 +++++++++++++++++--
 lms/djangoapps/grades/api/v1/views.py         | 41 ++++++----
 .../docs/decisions/0001-gradebook-api.rst     |  6 +-
 3 files changed, 105 insertions(+), 24 deletions(-)

diff --git a/lms/djangoapps/grades/api/v1/tests/test_views.py b/lms/djangoapps/grades/api/v1/tests/test_views.py
index 46993c5f409..1f26c206434 100644
--- a/lms/djangoapps/grades/api/v1/tests/test_views.py
+++ b/lms/djangoapps/grades/api/v1/tests/test_views.py
@@ -96,8 +96,8 @@ class GradeViewTestMixin(SharedModuleStoreTestCase):
         super(GradeViewTestMixin, self).setUp()
         self.password = 'test'
         self.global_staff = GlobalStaffFactory.create()
-        self.student = UserFactory(password=self.password)
-        self.other_student = UserFactory(password=self.password)
+        self.student = UserFactory(password=self.password, username='student')
+        self.other_student = UserFactory(password=self.password, username='other_student')
         self._create_user_enrollments(self.student, self.other_student)
 
     @classmethod
@@ -459,13 +459,15 @@ class GradebookViewTest(GradebookViewTestBase):
     """
     Tests for the gradebook view.
     """
-    def get_url(self, course_key=None, username=None):  # pylint: disable=arguments-differ
+    def get_url(self, course_key=None, username=None, username_contains=None):  # pylint: disable=arguments-differ
         """
         Helper function to create the course gradebook API read url.
         """
         base_url = super(GradebookViewTest, self).get_url(course_key)
         if username:
             return "{0}?username={1}".format(base_url, username)
+        if username_contains:
+            return "{0}?username_contains={1}".format(base_url, username_contains)
         return base_url
 
     def mock_subsection_grade(self, subsection, **kwargs):
@@ -786,6 +788,70 @@ class GradebookViewTest(GradebookViewTestBase):
                 actual_data = dict(resp.data)
                 self.assertEqual(expected_results, actual_data)
 
+    def test_gradebook_data_filter_username_contains(self):
+        with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
+            mock_grade.return_value = self.mock_course_grade(
+                self.other_student, passed=True, letter_grade='A', percent=0.85
+            )
+
+            with override_waffle_flag(self.waffle_flag, active=True):
+                self.login_staff()
+                resp = self.client.get(
+                    self.get_url(course_key=self.course.id, username_contains='other')
+                )
+                expected_results = [
+                    OrderedDict([
+                        ('course_id', text_type(self.course.id)),
+                        ('email', self.other_student.email),
+                        ('user_id', self.other_student.id),
+                        ('username', self.other_student.username),
+                        ('full_name', self.other_student.get_full_name()),
+                        ('passed', True),
+                        ('percent', 0.85),
+                        ('letter_grade', 'A'),
+                        ('progress_page_url', reverse(
+                            'student_progress',
+                            kwargs=dict(course_id=text_type(self.course.id), student_id=self.other_student.id)
+                        )),
+                        ('section_breakdown', self.expected_subsection_grades(letter_grade='A')),
+                        ('aggregates', {
+                            'Lab': {
+                                'score_earned': 2.0,
+                                'score_possible': 4.0,
+                            },
+                            'Homework': {
+                                'score_earned': 2.0,
+                                'score_possible': 4.0,
+                            },
+                        }),
+                    ]),
+                ]
+
+                self.assertEqual(status.HTTP_200_OK, resp.status_code)
+                actual_data = dict(resp.data)
+                self.assertIsNone(actual_data['next'])
+                self.assertIsNone(actual_data['previous'])
+                self.assertEqual(expected_results, actual_data['results'])
+
+    def test_gradebook_data_filter_username_contains_no_match(self):
+        with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
+            mock_grade.return_value = self.mock_course_grade(
+                self.other_student, passed=True, letter_grade='A', percent=0.85
+            )
+
+            with override_waffle_flag(self.waffle_flag, active=True):
+                self.login_staff()
+                resp = self.client.get(
+                    self.get_url(course_key=self.course.id, username_contains='fooooooooooooooooo')
+                )
+
+                expected_results = []
+                self.assertEqual(status.HTTP_200_OK, resp.status_code)
+                actual_data = dict(resp.data)
+                self.assertIsNone(actual_data['next'])
+                self.assertIsNone(actual_data['previous'])
+                self.assertEqual(expected_results, actual_data['results'])
+
 
 class GradebookBulkUpdateViewTest(GradebookViewTestBase):
     """
@@ -850,7 +916,7 @@ class GradebookBulkUpdateViewTest(GradebookViewTestBase):
                 },
             ]
             self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code)
-            self.assertEqual(expected_data, json.loads(resp.data))
+            self.assertEqual(expected_data, resp.data)
 
     def test_user_does_not_exist(self):
         with override_waffle_flag(self.waffle_flag, active=True):
@@ -878,7 +944,7 @@ class GradebookBulkUpdateViewTest(GradebookViewTestBase):
                 },
             ]
             self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code)
-            self.assertEqual(expected_data, json.loads(resp.data))
+            self.assertEqual(expected_data, resp.data)
 
     def test_invalid_usage_key(self):
         with override_waffle_flag(self.waffle_flag, active=True):
@@ -906,7 +972,7 @@ class GradebookBulkUpdateViewTest(GradebookViewTestBase):
                 },
             ]
             self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code)
-            self.assertEqual(expected_data, json.loads(resp.data))
+            self.assertEqual(expected_data, resp.data)
 
     def test_subsection_does_not_exist(self):
         """
@@ -939,7 +1005,7 @@ class GradebookBulkUpdateViewTest(GradebookViewTestBase):
                 },
             ]
             self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code)
-            self.assertEqual(expected_data, json.loads(resp.data))
+            self.assertEqual(expected_data, resp.data)
 
     def test_override_is_created(self):
         """
@@ -992,7 +1058,7 @@ class GradebookBulkUpdateViewTest(GradebookViewTestBase):
                 },
             ]
             self.assertEqual(status.HTTP_202_ACCEPTED, resp.status_code)
-            self.assertEqual(expected_data, json.loads(resp.data))
+            self.assertEqual(expected_data, resp.data)
 
             second_post_data = [
                 {
diff --git a/lms/djangoapps/grades/api/v1/views.py b/lms/djangoapps/grades/api/v1/views.py
index 4744223287a..98f0b4b7584 100644
--- a/lms/djangoapps/grades/api/v1/views.py
+++ b/lms/djangoapps/grades/api/v1/views.py
@@ -1,5 +1,4 @@
 """ API v0 views. """
-import json
 import logging
 from collections import defaultdict, namedtuple
 from contextlib import contextmanager
@@ -128,12 +127,13 @@ class GradeViewMixin(DeveloperErrorViewMixin):
             USER_MODEL.DoesNotExist if no such user exists.
             CourseEnrollment.DoesNotExist if the user is not enrolled in the given course.
         """
+        # May raise USER_MODEL.DoesNotExist if no user matching the given query exists.
         if user_id:
-            # May raise USER_MODEL.DoesNotExist if no user with this id exists
             grade_user = USER_MODEL.objects.get(id=user_id)
+        elif 'username' in request.GET:
+            grade_user = USER_MODEL.objects.get(username=request.GET.get('username'))
         else:
-            username = request.GET.get('username') or request.user.username
-            grade_user = USER_MODEL.objects.get(username=username)
+            grade_user = request.user
 
         # May raise CourseEnrollment.DoesNotExist if no enrollment exists for this user/course.
         _ = CourseEnrollment.objects.get(user=grade_user, course_id=course_key)
@@ -181,18 +181,22 @@ class GradeViewMixin(DeveloperErrorViewMixin):
         course_grade = CourseGradeFactory().read(grade_user, course_key=course_key)
         return Response([self._serialize_user_grade(grade_user, course_key, course_grade)])
 
-    def _iter_user_grades(self, course_key):
+    def _iter_user_grades(self, course_key, course_enrollment_filter=None):
         """
         Args:
             course_key (CourseLocator): The course to retrieve grades for.
+            course_enrollment_filter: Optional dictionary of keyword arguments to pass
+            to `CourseEnrollment.filter()`.
 
         Returns:
             An iterator of CourseGrade objects for users enrolled in the given course.
         """
-        enrollments_in_course = CourseEnrollment.objects.filter(
-            course_id=course_key,
-            is_active=True
-        )
+        filter_kwargs = {
+            'course_id': course_key,
+            'is_active': True,
+        }
+        filter_kwargs.update(course_enrollment_filter or {})
+        enrollments_in_course = CourseEnrollment.objects.filter(**filter_kwargs)
 
         paged_enrollments = self.paginate_queryset(enrollments_in_course)
         users = (enrollment.user for enrollment in paged_enrollments)
@@ -372,9 +376,12 @@ class GradebookView(GradeViewMixin, GenericAPIView):
     **Example Request**
         GET /api/grades/v1/gradebook/{course_id}/                       - Get gradebook entries for all users in course
         GET /api/grades/v1/gradebook/{course_id}/?username={username}   - Get grades for specific user in course
+        GET /api/grades/v1/gradebook/{course_id}/?username_contains={username_contains}
     **GET Parameters**
         A GET request may include the following query parameters.
         * username:  (optional) A string representation of a user's username.
+        * username_contains: (optional) A substring against which a case-insensitive substring filter will be performed
+          on the USER_MODEL.username field.
     **GET Response Values**
         If the request for gradebook data is successful,
         an HTTP 200 "OK" response is returned.
@@ -561,11 +568,10 @@ class GradebookView(GradeViewMixin, GenericAPIView):
             request: A Django request object.
             course_id: A string representation of a CourseKey object.
         """
-        username = request.GET.get('username')
         course_key = get_course_key(request, course_id)
         course = get_course_with_access(request.user, 'staff', course_key, depth=None)
 
-        if username:
+        if request.GET.get('username'):
             with self._get_user_or_raise(request, course_key) as grade_user:
                 course_grade = CourseGradeFactory().read(grade_user, course)
 
@@ -573,9 +579,16 @@ class GradebookView(GradeViewMixin, GenericAPIView):
             serializer = StudentGradebookEntrySerializer(entry)
             return Response(serializer.data)
         else:
-            # list gradebook data for all course enrollees
+            if request.GET.get('username_contains'):
+                users = USER_MODEL.objects.filter(username__icontains=request.GET.get('username_contains'))
+                filter_kwargs = {'user__in': users}
+                user_grades = self._iter_user_grades(course_key, filter_kwargs)
+            else:
+                # list gradebook data for all course enrollees
+                user_grades = self._iter_user_grades(course_key)
+
             entries = []
-            for user, course_grade, exc in self._iter_user_grades(course_key):
+            for user, course_grade, exc in user_grades:
                 if not exc:
                     entries.append(self._gradebook_entry(user, course, course_grade))
             serializer = StudentGradebookEntrySerializer(entries, many=True)
@@ -735,7 +748,7 @@ class GradebookBulkUpdateView(GradeViewMixin, GenericAPIView):
             status_code = status.HTTP_202_ACCEPTED
 
         return Response(
-            json.dumps([item._asdict() for item in result]),
+            [item._asdict() for item in result],
             status=status_code,
             content_type='application/json'
         )
diff --git a/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst b/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
index 7f7f7a5fe55..9b930c48653 100644
--- a/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
+++ b/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
@@ -28,8 +28,10 @@ Decisions
 
 #. **The read (GET) API**
 
-   a. The read API supports either fetching subsection scores for a single user, by `username`, or fetching
-      a paginated result of subsection grade data for all enrollees in the requested course.
+   a. The read API supports either fetching subsection scores for a single user via ``?username=my-user-name``,
+      where we look up a user by their exact ``username`` value; via ``?username_contains=name-substring`` where
+      we do a case-insensitive substring query for a user, or fetching a paginated result of
+      subsection grade data for all enrollees in the requested course.
 
    b. We will use the data schema required by the EE's front-end implementation.  This will allow us to port
       over much of EE's front-end code with only minor modifications.  Note that there are some fields specified
-- 
GitLab