From c0f353467ea658a3b6d23c36e52597b27c4c8811 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Fri, 16 Feb 2018 14:02:42 -0800 Subject: [PATCH] V1 Grades API with added support for bulk read of all user grades in a course. URL patterns changed to follow edx API conventions --- common/djangoapps/enrollment/data.py | 16 + .../djangoapps/enrollment/tests/test_data.py | 46 +++ lms/djangoapps/grades/api/tests/test_views.py | 30 +- lms/djangoapps/grades/api/urls.py | 3 +- lms/djangoapps/grades/api/v1/__init__.py | 0 .../grades/api/v1/tests/__init__.py | 0 .../grades/api/v1/tests/test_views.py | 385 ++++++++++++++++++ lms/djangoapps/grades/api/v1/urls.py | 25 ++ lms/djangoapps/grades/api/v1/views.py | 202 +++++++++ .../content/course_overviews/models.py | 23 ++ .../tests/test_course_overviews.py | 12 + openedx/core/lib/api/permissions.py | 5 +- 12 files changed, 730 insertions(+), 17 deletions(-) create mode 100644 lms/djangoapps/grades/api/v1/__init__.py create mode 100644 lms/djangoapps/grades/api/v1/tests/__init__.py create mode 100644 lms/djangoapps/grades/api/v1/tests/test_views.py create mode 100644 lms/djangoapps/grades/api/v1/urls.py create mode 100644 lms/djangoapps/grades/api/v1/views.py diff --git a/common/djangoapps/enrollment/data.py b/common/djangoapps/enrollment/data.py index 6a9f44b346..faf45a69be 100644 --- a/common/djangoapps/enrollment/data.py +++ b/common/djangoapps/enrollment/data.py @@ -93,6 +93,22 @@ def get_course_enrollment(username, course_id): return None +def get_user_enrollments(course_key): + """Based on the course id, return all user enrollments in the course + Args: + course_key (CourseKey): Identifier of the course + from which to retrieve enrollments. + Returns: + A course's user enrollments as a queryset + Raises: + CourseEnrollment.DoesNotExist + """ + return CourseEnrollment.objects.filter( + course_id=course_key, + is_active=True + ).order_by('created') + + def create_course_enrollment(username, course_id, mode, is_active): """Create a new course enrollment for the given user. diff --git a/common/djangoapps/enrollment/tests/test_data.py b/common/djangoapps/enrollment/tests/test_data.py index e0ab60f643..a99cd88346 100644 --- a/common/djangoapps/enrollment/tests/test_data.py +++ b/common/djangoapps/enrollment/tests/test_data.py @@ -20,6 +20,7 @@ from enrollment.errors import ( CourseEnrollmentFullError, UserNotFoundError ) +from enrollment.serializers import CourseEnrollmentSerializer from openedx.core.lib.exceptions import CourseNotFoundError from student.models import AlreadyEnrolledError, CourseEnrollment, CourseFullError, EnrollmentClosedError from student.tests.factories import UserFactory @@ -179,6 +180,51 @@ class EnrollmentDataTest(ModuleStoreTestCase): self.assertEqual(self.user.username, result['user']) self.assertEqual(enrollment, result) + @ddt.data( + # Default (no course modes in the database) + # Expect that users are automatically enrolled as "honor". + ([], 'honor'), + + # Audit / Verified / Honor + # We should always go to the "choose your course" page. + # We should also be enrolled as "honor" by default. + (['honor', 'verified', 'audit'], 'verified'), + ) + @ddt.unpack + def test_get_user_enrollments(self, course_modes, enrollment_mode): + self._create_course_modes(course_modes) + + # Try to get enrollments before they exist. + result = data.get_user_enrollments(self.course.id) + self.assertFalse(result.exists()) + + # Create 10 test users to enroll in the course + users = [] + for i in xrange(10): + users.append(UserFactory.create( + username=self.USERNAME + str(i), + email=self.EMAIL + str(i), + password=self.PASSWORD + str(i) + )) + + # Create the original enrollments. + created_enrollments = [] + for user in users: + created_enrollments.append(data.create_course_enrollment( + user.username, + unicode(self.course.id), + enrollment_mode, + True + )) + + # Compare the created enrollments with the results + # from the get user enrollments request. + results = data.get_user_enrollments( + self.course.id + ) + self.assertTrue(result.exists()) + self.assertEqual(CourseEnrollmentSerializer(results, many=True).data, created_enrollments) + @ddt.data( # Default (no course modes in the database) # Expect that users are automatically enrolled as "honor". diff --git a/lms/djangoapps/grades/api/tests/test_views.py b/lms/djangoapps/grades/api/tests/test_views.py index 597b5ba064..caaef711a0 100644 --- a/lms/djangoapps/grades/api/tests/test_views.py +++ b/lms/djangoapps/grades/api/tests/test_views.py @@ -170,8 +170,8 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): """ resp = self.client.get(self.get_url('IDoNotExist')) self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - self.assertIn('error_code', resp.data) # pylint: disable=no-member - self.assertEqual(resp.data['error_code'], 'user_mismatch') # pylint: disable=no-member + self.assertIn('error_code', resp.data) + self.assertEqual(resp.data['error_code'], 'user_mismatch') def test_other_get_grade(self): """ @@ -181,8 +181,8 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): self.client.login(username=self.other_student.username, password=self.password) resp = self.client.get(self.get_url(self.student.username)) self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - self.assertIn('error_code', resp.data) # pylint: disable=no-member - self.assertEqual(resp.data['error_code'], 'user_mismatch') # pylint: disable=no-member + self.assertIn('error_code', resp.data) + self.assertEqual(resp.data['error_code'], 'user_mismatch') def test_self_get_grade_not_enrolled(self): """ @@ -194,9 +194,9 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): self.client.login(username=self.other_user.username, password=self.password) resp = self.client.get(self.get_url(self.other_user.username)) self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) - self.assertIn('error_code', resp.data) # pylint: disable=no-member + self.assertIn('error_code', resp.data) self.assertEqual( - resp.data['error_code'], # pylint: disable=no-member + resp.data['error_code'], 'user_or_course_does_not_exist' ) @@ -212,9 +212,9 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): resp = self.client.get(self.get_url(self.student.username)) self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) - self.assertIn('error_code', resp.data) # pylint: disable=no-member + self.assertIn('error_code', resp.data) self.assertEqual( - resp.data['error_code'], # pylint: disable=no-member + resp.data['error_code'], 'invalid_course_key' ) @@ -231,9 +231,9 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): url = "{0}?username={1}".format(base_url, self.student.username) resp = self.client.get(url) self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) - self.assertIn('error_code', resp.data) # pylint: disable=no-member + self.assertIn('error_code', resp.data) self.assertEqual( - resp.data['error_code'], # pylint: disable=no-member + resp.data['error_code'], 'user_or_course_does_not_exist' ) @@ -255,7 +255,7 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): 'course_key': str(self.course_key), 'passed': False }] - self.assertEqual(resp.data, expected_data) # pylint: disable=no-member + self.assertEqual(resp.data, expected_data) @ddt.data( 'staff', 'global_staff' @@ -268,8 +268,8 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): self.client.login(username=getattr(self, staff_user).username, password=self.password) resp = self.client.get(self.get_url('IDoNotExist')) self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) - self.assertIn('error_code', resp.data) # pylint: disable=no-member - self.assertEqual(resp.data['error_code'], 'user_does_not_exist') # pylint: disable=no-member + self.assertIn('error_code', resp.data) + self.assertEqual(resp.data['error_code'], 'user_does_not_exist') def test_no_grade(self): """ @@ -284,7 +284,7 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): 'course_key': str(self.course_key), 'passed': False }] - self.assertEqual(resp.data, expected_data) # pylint: disable=no-member + self.assertEqual(resp.data, expected_data) @ddt.data( ({'letter_grade': None, 'percent': 0.4, 'passed': False}), @@ -302,7 +302,7 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): 'course_key': str(self.course_key), } expected_data.update(grade) - self.assertEqual(resp.data, [expected_data]) # pylint: disable=no-member + self.assertEqual(resp.data, [expected_data]) @ddt.ddt diff --git a/lms/djangoapps/grades/api/urls.py b/lms/djangoapps/grades/api/urls.py index d0f3fc8a94..37d00378cd 100644 --- a/lms/djangoapps/grades/api/urls.py +++ b/lms/djangoapps/grades/api/urls.py @@ -3,7 +3,7 @@ Grades API URLs. """ from django.conf import settings -from django.conf.urls import url +from django.conf.urls import include, url from lms.djangoapps.grades.api import views @@ -20,4 +20,5 @@ urlpatterns = [ ), views.CourseGradingPolicy.as_view(), name='course_grading_policy' ), + url(r'^v1/', include('grades.api.v1.urls', namespace='v1')) ] diff --git a/lms/djangoapps/grades/api/v1/__init__.py b/lms/djangoapps/grades/api/v1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/grades/api/v1/tests/__init__.py b/lms/djangoapps/grades/api/v1/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/grades/api/v1/tests/test_views.py b/lms/djangoapps/grades/api/v1/tests/test_views.py new file mode 100644 index 0000000000..e73361bf9e --- /dev/null +++ b/lms/djangoapps/grades/api/v1/tests/test_views.py @@ -0,0 +1,385 @@ +""" +Tests for v1 views +""" +from datetime import datetime +import ddt + +from django.core.urlresolvers import reverse +from mock import MagicMock, patch +from opaque_keys import InvalidKeyError +from pytz import UTC +from rest_framework import status +from rest_framework.test import APITestCase + +from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, StaffFactory +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from student.tests.factories import CourseEnrollmentFactory, UserFactory +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE + + +class GradeViewTestMixin(SharedModuleStoreTestCase): + """ + Mixin class for grades related view tests + The following tests assume that the grading policy is the edX default one: + { + "GRADER": [ + { + "drop_count": 2, + "min_count": 12, + "short_label": "HW", + "type": "Homework", + "weight": 0.15 + }, + { + "drop_count": 2, + "min_count": 12, + "type": "Lab", + "weight": 0.15 + }, + { + "drop_count": 0, + "min_count": 1, + "short_label": "Midterm", + "type": "Midterm Exam", + "weight": 0.3 + }, + { + "drop_count": 0, + "min_count": 1, + "short_label": "Final", + "type": "Final Exam", + "weight": 0.4 + } + ], + "GRADE_CUTOFFS": { + "Pass": 0.5 + } + } + """ + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + + @classmethod + def setUpClass(cls): + super(GradeViewTestMixin, cls).setUpClass() + + cls.course = cls._create_test_course_with_default_grading_policy( + display_name='test course', run="Testing_course" + ) + cls.empty_course = cls._create_test_course_with_default_grading_policy( + display_name='empty test course', run="Empty_testing_course" + ) + + cls.course_key = cls.course.id + + cls.password = 'test' + cls.student = UserFactory(username='dummy', password=cls.password) + cls.other_student = UserFactory(username='foo', password=cls.password) + cls.other_user = UserFactory(username='bar', password=cls.password) + cls.staff = StaffFactory(course_key=cls.course_key, password=cls.password) + cls.global_staff = GlobalStaffFactory.create() + date = datetime(2013, 1, 22, tzinfo=UTC) + for user in (cls.student, cls.other_student,): + CourseEnrollmentFactory( + course_id=cls.course.id, + user=user, + created=date, + ) + + def setUp(self): + super(GradeViewTestMixin, self).setUp() + self.client.login(username=self.student.username, password=self.password) + + @classmethod + def _create_test_course_with_default_grading_policy(cls, display_name, run): + """ + Utility method to create a course with a default grading policy + """ + course = CourseFactory.create(display_name=display_name, run=run) + _ = CourseOverviewFactory.create(id=course.id) + + chapter = ItemFactory.create( + category='chapter', + parent_location=course.location, + display_name="Chapter 1", + ) + # create a problem for each type and minimum count needed by the grading policy + # A section is not considered if the student answers less than "min_count" problems + for grading_type, min_count in (("Homework", 12), ("Lab", 12), ("Midterm Exam", 1), ("Final Exam", 1)): + for num in xrange(min_count): + section = ItemFactory.create( + category='sequential', + parent_location=chapter.location, + due=datetime(2017, 12, 18, 11, 30, 00), + display_name='Sequential {} {}'.format(grading_type, num), + format=grading_type, + graded=True, + ) + vertical = ItemFactory.create( + category='vertical', + parent_location=section.location, + display_name='Vertical {} {}'.format(grading_type, num), + ) + ItemFactory.create( + category='problem', + parent_location=vertical.location, + display_name='Problem {} {}'.format(grading_type, num), + ) + + return course + + +@ddt.ddt +class SingleUserGradesTests(GradeViewTestMixin, APITestCase): + """ + Tests for grades related to a course and specific user + e.g. /api/grades/v1/courses/{course_id}/?username={username} + /api/grades/v1/courses/?course_id={course_id}&username={username} + """ + + @classmethod + def setUpClass(cls): + super(SingleUserGradesTests, cls).setUpClass() + cls.namespaced_url = 'grades_api:v1:course_grades' + + def get_url(self, username): + """ + Helper function to create the url + """ + base_url = reverse( + self.namespaced_url, + kwargs={ + 'course_id': self.course_key, + } + ) + return "{0}?username={1}".format(base_url, username) + + def test_anonymous(self): + """ + Test that an anonymous user cannot access the API and an error is received. + """ + self.client.logout() + resp = self.client.get(self.get_url(self.student.username)) + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_self_get_grade(self): + """ + Test that a user can successfully request her own grade. + """ + resp = self.client.get(self.get_url(self.student.username)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + + def test_nonexistent_user(self): + """ + Test that a request for a nonexistent username returns an error. + """ + self.client.logout() + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get(self.get_url('IDoNotExist')) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_other_get_grade(self): + """ + Test that if a user requests the grade for another user, she receives an error. + """ + self.client.logout() + self.client.login(username=self.other_student.username, password=self.password) + resp = self.client.get(self.get_url(self.student.username)) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_self_get_grade_not_enrolled(self): + """ + Test that a user receives an error if she requests + her own grade in a course where she is not enrolled. + """ + # a user not enrolled in the course cannot request her grade + self.client.logout() + self.client.login(username=self.other_user.username, password=self.password) + resp = self.client.get(self.get_url(self.other_user.username)) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn('error_code', resp.data) + self.assertEqual( + resp.data['error_code'], + 'user_not_enrolled' + ) + + def test_no_grade(self): + """ + Test the grade for a user who has not answered any test. + """ + resp = self.client.get(self.get_url(self.student.username)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + expected_data = [{ + 'username': self.student.username, + 'email': self.student.email, + 'course_id': str(self.course_key), + 'passed': False, + 'percent': 0.0, + 'letter_grade': None + }] + + self.assertEqual(resp.data, expected_data) + + def test_wrong_course_key(self): + """ + Test that a request for an invalid course key returns an error. + """ + def mock_from_string(*args, **kwargs): # pylint: disable=unused-argument + """Mocked function to always raise an exception""" + raise InvalidKeyError('foo', 'bar') + + with patch('opaque_keys.edx.keys.CourseKey.from_string', side_effect=mock_from_string): + resp = self.client.get(self.get_url(self.student.username)) + + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn('error_code', resp.data) + self.assertEqual( + resp.data['error_code'], + 'invalid_course_key' + ) + + def test_course_does_not_exist(self): + """ + Test that requesting a valid, nonexistent course key returns an error as expected. + """ + base_url = reverse( + self.namespaced_url, + kwargs={ + 'course_id': 'course-v1:MITx+8.MechCX+2014_T1', + } + ) + url = "{0}?username={1}".format(base_url, self.student.username) + resp = self.client.get(url) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn('error_code', resp.data) + self.assertEqual( + resp.data['error_code'], + 'course_does_not_exist' + ) + + @ddt.data( + ({'letter_grade': None, 'percent': 0.4, 'passed': False}), + ({'letter_grade': 'Pass', 'percent': 1, 'passed': True}), + ) + def test_grade(self, grade): + """ + Test that the user gets her grade in case she answered tests with an insufficient score. + """ + with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade: + grade_fields = { + 'letter_grade': grade['letter_grade'], + 'percent': grade['percent'], + 'passed': grade['letter_grade'] is not None, + + } + mock_grade.return_value = MagicMock(**grade_fields) + resp = self.client.get(self.get_url(self.student.username)) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + expected_data = { + 'username': self.student.username, + 'email': self.student.email, + 'course_id': str(self.course_key), + } + + expected_data.update(grade) + self.assertEqual(resp.data, [expected_data]) + + def test_staff_can_see_student(self): + """ + Ensure that staff members can see her student's grades. + """ + self.client.logout() + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get(self.get_url(self.student.username)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + expected_data = [{ + 'username': self.student.username, + 'email': self.student.email, + 'letter_grade': None, + 'percent': 0.0, + 'course_id': str(self.course_key), + 'passed': False + }] + self.assertEqual(resp.data, expected_data) + + +@ddt.ddt +class CourseGradesViewTest(GradeViewTestMixin, APITestCase): + """ + Tests for grades related to all users in a course + e.g. /api/grades/v1/courses/{course_id}/ + /api/grades/v1/courses/?course_id={course_id} + """ + + @classmethod + def setUpClass(cls): + super(CourseGradesViewTest, cls).setUpClass() + cls.namespaced_url = 'grades_api:v1:course_grades' + + def get_url(self, course_key=None): + """ + Helper function to create the url + """ + base_url = reverse( + self.namespaced_url, + kwargs={ + 'course_id': course_key or self.course_key, + } + ) + + return base_url + + def test_anonymous(self): + self.client.logout() + resp = self.client.get(self.get_url()) + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_student(self): + resp = self.client.get(self.get_url()) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_course_does_not_exist(self): + self.client.logout() + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get( + self.get_url(course_key='course-v1:MITx+8.MechCX+2014_T1') + ) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_course_no_enrollments(self): + self.client.logout() + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get( + self.get_url(course_key=self.empty_course.id) + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data, []) + + def test_staff_can_get_all_grades(self): + self.client.logout() + self.client.login(username=self.global_staff.username, password=self.password) + resp = self.client.get(self.get_url()) + + # this should have permission to access this API endpoint + self.assertEqual(resp.status_code, status.HTTP_200_OK) + expected_data = [ + { + 'username': self.student.username, + 'email': self.student.email, + 'course_id': str(self.course.id), + 'passed': False, + 'percent': 0.0, + 'letter_grade': None + }, + { + 'username': self.other_student.username, + 'email': self.other_student.email, + 'course_id': str(self.course.id), + 'passed': False, + 'percent': 0.0, + 'letter_grade': None + }, + ] + + self.assertEqual(resp.data, expected_data) diff --git a/lms/djangoapps/grades/api/v1/urls.py b/lms/djangoapps/grades/api/v1/urls.py new file mode 100644 index 0000000000..e695314d3b --- /dev/null +++ b/lms/djangoapps/grades/api/v1/urls.py @@ -0,0 +1,25 @@ +""" Grades API v1 URLs. """ +from django.conf import settings +from django.conf.urls import url + +from lms.djangoapps.grades.api.v1 import views +from lms.djangoapps.grades.api.views import CourseGradingPolicy + +urlpatterns = [ + url( + r'^courses/$', + views.CourseGradesView.as_view(), name='course_grades' + ), + url( + r'^courses/{course_id}/$'.format( + course_id=settings.COURSE_ID_PATTERN, + ), + views.CourseGradesView.as_view(), name='course_grades' + ), + url( + r'^policy/courses/{course_id}/$'.format( + course_id=settings.COURSE_ID_PATTERN, + ), + CourseGradingPolicy.as_view(), name='course_grading_policy' + ), +] diff --git a/lms/djangoapps/grades/api/v1/views.py b/lms/djangoapps/grades/api/v1/views.py new file mode 100644 index 0000000000..f9696c3bbd --- /dev/null +++ b/lms/djangoapps/grades/api/v1/views.py @@ -0,0 +1,202 @@ +""" API v0 views. """ +import logging + +from django.contrib.auth import get_user_model +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response + +from enrollment import data as enrollment_data +from student.models import CourseEnrollment +from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.lib.api.permissions import IsUserInUrlOrStaff +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes + +log = logging.getLogger(__name__) +USER_MODEL = get_user_model() + + +@view_auth_classes() +class GradeViewMixin(DeveloperErrorViewMixin): + """ + Mixin class for Grades related views. + """ + def _get_single_user_grade(self, request, course_key): + """ + Returns a grade response for the user object corresponding to the request's 'username' parameter, + or the current request.user if no 'username' was provided. + Args: + request (Request): django request object to check for username or request.user object + course_key (CourseLocator): The course to retrieve user grades for. + + Returns: + A serializable list of grade responses + """ + if 'username' in request.GET: + username = request.GET.get('username') + else: + username = request.user.username + + grade_user = USER_MODEL.objects.get(username=username) + + if not enrollment_data.get_course_enrollment(username, str(course_key)): + raise CourseEnrollment.DoesNotExist + + course_grade = CourseGradeFactory().read(grade_user, course_key=course_key) + return Response([self._make_grade_response(grade_user, course_key, course_grade)]) + + def _get_user_grades(self, course_key): + """ + Get paginated grades for users in a course. + Args: + course_key (CourseLocator): The course to retrieve user grades for. + + Returns: + A serializable list of grade responses + """ + enrollments_in_course = enrollment_data.get_user_enrollments(course_key) + + paged_enrollments = self.paginator.paginate_queryset( + enrollments_in_course, self.request, view=self + ) + users = (enrollment.user for enrollment in paged_enrollments) + grades = CourseGradeFactory().iter(users, course_key=course_key) + + grade_responses = [] + for user, course_grade, exc in grades: + if not exc: + grade_responses.append(self._make_grade_response(user, course_key, course_grade)) + + return Response(grade_responses) + + def _make_grade_response(self, user, course_key, course_grade): + """ + Serialize a single grade to dict to use in Responses + """ + return { + 'username': user.username, + 'email': user.email, + 'course_id': str(course_key), + 'passed': course_grade.passed, + 'percent': course_grade.percent, + 'letter_grade': course_grade.letter_grade, + } + + def perform_authentication(self, request): + """ + Ensures that the user is authenticated (e.g. not an AnonymousUser). + """ + super(GradeViewMixin, self).perform_authentication(request) + if request.user.is_anonymous(): + raise AuthenticationFailed + + +class CourseGradesView(GradeViewMixin, GenericAPIView): + """ + **Use Case** + * Get course grades of all users who are enrolled in a course. + The currently logged-in user may request all enrolled user's grades information + if they are allowed. + **Example Request** + GET /api/grades/v1/courses/{course_id}/ - Get grades for all users in course + GET /api/grades/v1/courses/{course_id}/?username={username} - Get grades for specific user in course + GET /api/grades/v1/courses/?course_id={course_id} - Get grades for all users in course + GET /api/grades/v1/courses/?course_id={course_id}&username={username}- Get grades for specific user in course + **GET Parameters** + A GET request may include the following parameters. + * course_id: (required) A string representation of a Course ID. + * username: (optional) A string representation of a user's username. + **GET Response Values** + If the request for information about the course grade + is successful, an HTTP 200 "OK" response is returned. + The HTTP 200 response has the following values. + * username: A string representation of a user's username passed in the request. + * email: A string representation of a user's email. + * course_id: A string representation of a Course ID. + * passed: Boolean representing whether the course has been + passed according to the course's grading policy. + * percent: A float representing the overall grade for the course + * letter_grade: A letter grade as defined in grading policy (e.g. 'A' 'B' 'C' for 6.002x) or None + **Example GET Response** + [{ + "username": "bob", + "email": "bob@example.com", + "course_id": "course-v1:edX+DemoX+Demo_Course", + "passed": false, + "percent": 0.03, + "letter_grade": null, + }, + { + "username": "fred", + "email": "fred@example.com", + "course_id": "course-v1:edX+DemoX+Demo_Course", + "passed": true, + "percent": 0.83, + "letter_grade": "B", + }, + { + "username": "kate", + "email": "kate@example.com", + "course_id": "course-v1:edX+DemoX+Demo_Course", + "passed": false, + "percent": 0.19, + "letter_grade": null, + }] + """ + permission_classes = (IsUserInUrlOrStaff,) + + def get(self, request, course_id=None): + """ + Gets a course progress status. + Args: + request (Request): Django request object. + course_id (string): URI element specifying the course location. + Can also be passed as a GET parameter instead. + Return: + A JSON serialized representation of the requesting user's current grade status. + """ + username = request.GET.get('username') + + if not course_id: + course_id = request.GET.get('course_id') + + # Validate course exists with provided course_id + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + return self.make_error_response( + status_code=status.HTTP_404_NOT_FOUND, + developer_message='The provided course key cannot be parsed.', + error_code='invalid_course_key' + ) + + if not CourseOverview.get_from_id_if_exists(course_key): + return self.make_error_response( + status_code=status.HTTP_404_NOT_FOUND, + developer_message="Requested grade for unknown course {course}".format(course=course_id), + error_code='course_does_not_exist' + ) + + if username: + # If there is a username passed, get grade for a single user + try: + return self._get_single_user_grade(request, course_key) + except USER_MODEL.DoesNotExist: + return self.make_error_response( + status_code=status.HTTP_404_NOT_FOUND, + developer_message='The user matching the requested username does not exist.', + error_code='user_does_not_exist' + ) + except CourseEnrollment.DoesNotExist: + return self.make_error_response( + status_code=status.HTTP_404_NOT_FOUND, + developer_message='The user matching the requested username is not enrolled in this course', + error_code='user_not_enrolled' + ) + else: + # If no username passed, get paginated list of grades for all users in course + return self._get_user_grades(course_key) diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py index c0187c89d2..25eae9cdd9 100644 --- a/openedx/core/djangoapps/content/course_overviews/models.py +++ b/openedx/core/djangoapps/content/course_overviews/models.py @@ -324,6 +324,29 @@ class CourseOverview(TimeStampedModel): ) } + @classmethod + def get_from_id_if_exists(cls, course_id): + """ + Return a CourseOverview for the provided course_id if it exists. + Returns None if no CourseOverview exists with the provided course_id + + This method will *not* generate new CourseOverviews or delete outdated + ones. It exists only as a small optimization used when CourseOverviews + are known to exist, for common situations like the student dashboard. + + Callers should assume that this list is incomplete and fall back to + get_from_id if they need to guarantee CourseOverview generation. + """ + try: + course_overview = cls.objects.select_related('image_set').get( + id=course_id, + version__gte=cls.VERSION + ) + except cls.DoesNotExist: + course_overview = None + + return course_overview + def clean_id(self, padding_char='='): """ Returns a unique deterministic base32-encoded ID for the course. diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py index 04279395b4..bbb35b52fc 100644 --- a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py @@ -546,6 +546,18 @@ class CourseOverviewTestCase(CatalogIntegrationMixin, ModuleStoreTestCase): self.assertEqual(len(course_ids_to_overviews), 1) self.assertIn(course_with_overview_1.id, course_ids_to_overviews) + def test_get_from_id_if_exists(self): + course_with_overview = CourseFactory.create(emit_signals=True) + course_id_to_overview = CourseOverview.get_from_id_if_exists(course_with_overview.id) + self.assertEqual(course_with_overview.id, course_id_to_overview.id) + + overview_prev_version = CourseOverview.get_from_id_if_exists(course_with_overview.id) + overview_prev_version.version = CourseOverview.VERSION - 1 + overview_prev_version.save() + + course_id_to_overview = CourseOverview.get_from_id_if_exists(course_with_overview.id) + self.assertEqual(course_id_to_overview, None) + @attr(shard=3) @ddt.ddt diff --git a/openedx/core/lib/api/permissions.py b/openedx/core/lib/api/permissions.py index 4f712a5c01..84684fd7e6 100644 --- a/openedx/core/lib/api/permissions.py +++ b/openedx/core/lib/api/permissions.py @@ -62,7 +62,10 @@ class IsUserInUrl(permissions.BasePermission): Note: a 404 is returned for non-staff instead of a 403. This is to prevent users from being able to detect the existence of accounts. """ - url_username = request.parser_context.get('kwargs', {}).get('username', '') + url_username = ( + request.parser_context.get('kwargs', {}).get('username') or + request.GET.get('username', '') + ) if request.user.username.lower() != url_username.lower(): if request.user.is_staff: return False # staff gets 403