EDUCATOR-3622 | Add a username_contains query param to gradebook endpoint

This commit is contained in:
Alex Dusenbery
2018-10-26 11:47:44 -04:00
committed by Alex Dusenbery
parent c02b195ff7
commit b528b8e5d7
3 changed files with 105 additions and 24 deletions

View File

@@ -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 = [
{

View File

@@ -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'
)

View File

@@ -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