diff --git a/lms/djangoapps/grades/api/__init__.py b/lms/djangoapps/grades/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/grades/api/tests/__init__.py b/lms/djangoapps/grades/api/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/grades/api/tests/test_views.py b/lms/djangoapps/grades/api/tests/test_views.py new file mode 100644 index 0000000000..4ba173371c --- /dev/null +++ b/lms/djangoapps/grades/api/tests/test_views.py @@ -0,0 +1,247 @@ +""" +Tests for the views +""" +from datetime import datetime + +import ddt +from django.core.urlresolvers import reverse +from mock import patch +from opaque_keys import InvalidKeyError +from pytz import UTC +from rest_framework import status +from rest_framework.test import APITestCase + +from lms.djangoapps.grades.tests.utils import mock_get_score +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 + + +@ddt.ddt +class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): + """ + Tests for the Current Grade View + + 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(CurrentGradeViewTest, cls).setUpClass() + + cls.course = CourseFactory.create(display_name='test course', run="Testing_course") + + chapter = ItemFactory.create( + category='chapter', + parent_location=cls.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(2013, 9, 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), + ) + + 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) + 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, + ) + + cls.namespaced_url = 'grades_api:user_grade_detail' + + def setUp(self): + super(CurrentGradeViewTest, self).setUp() + self.client.login(username=self.student.username, password=self.password) + + 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. + """ + 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_mismatch') # pylint: disable=no-member + + 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) + self.assertIn('error_code', resp.data) # pylint: disable=no-member + self.assertEqual(resp.data['error_code'], 'user_mismatch') # pylint: disable=no-member + + 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) # pylint: disable=no-member + self.assertEqual( + resp.data['error_code'], # pylint: disable=no-member + 'user_or_course_does_not_exist' + ) + + 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) # pylint: disable=no-member + self.assertEqual( + resp.data['error_code'], # pylint: disable=no-member + '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) # pylint: disable=no-member + self.assertEqual( + resp.data['error_code'], # pylint: disable=no-member + 'user_or_course_does_not_exist' + ) + + 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, + 'letter_grade': None, + 'percent': 0.0, + 'course_key': str(self.course_key), + 'passed': False + }] + self.assertEqual(resp.data, expected_data) # pylint: disable=no-member + + @ddt.data( + ((2, 5), {'letter_grade': None, 'percent': 0.4, 'passed': False}), + ((5, 5), {'letter_grade': 'Pass', 'percent': 1, 'passed': True}), + ) + @ddt.unpack + def test_grade(self, grade, result): + """ + Test that the user gets her grade in case she answered tests with an insufficient score. + """ + with mock_get_score(*grade): + 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, + 'course_key': str(self.course_key), + } + expected_data.update(result) + self.assertEqual(resp.data, [expected_data]) # pylint: disable=no-member diff --git a/lms/djangoapps/grades/api/urls.py b/lms/djangoapps/grades/api/urls.py new file mode 100644 index 0000000000..f617160923 --- /dev/null +++ b/lms/djangoapps/grades/api/urls.py @@ -0,0 +1,18 @@ +""" Grades API URLs. """ +from django.conf import settings +from django.conf.urls import ( + patterns, + url, +) + +from lms.djangoapps.grades.api import views + +urlpatterns = patterns( + '', + url( + r'^v0/course_grade/{course_id}/users/$'.format( + course_id=settings.COURSE_ID_PATTERN + ), + views.UserGradeView.as_view(), name='user_grade_detail' + ), +) diff --git a/lms/djangoapps/grades/api/views.py b/lms/djangoapps/grades/api/views.py new file mode 100644 index 0000000000..92198b95c0 --- /dev/null +++ b/lms/djangoapps/grades/api/views.py @@ -0,0 +1,144 @@ +""" API v0 views. """ +import logging + +from django.http import Http404 +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.authentication import SessionAuthentication +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from lms.djangoapps.ccx.utils import prep_course_for_grading +from lms.djangoapps.courseware import courses +from lms.djangoapps.grades.new.course_grade import CourseGradeFactory +from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin + +log = logging.getLogger(__name__) + + +class UserGradeView(DeveloperErrorViewMixin, GenericAPIView): + """ + **Use Case** + + * Get the current course grades for users in a course. + Currently, getting the grade for only an individual user is supported. + + **Example Request** + + GET /api/grades/v0/course_grade/{course_id}/users/?username={username} + + **GET Parameters** + + A GET request must include the following parameters. + + * course_id: A string representation of a Course ID. + * username: 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. + + * course_id: A string representation of a Course ID. + + * passed: Boolean representing whether the course has been + passed according 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", + "course_key": "edX/DemoX/Demo_Course", + "passed": false, + "percent": 0.03, + "letter_grade": None, + }] + + """ + authentication_classes = ( + OAuth2AuthenticationAllowInactiveUser, + SessionAuthentication, + ) + permission_classes = (IsAuthenticated, ) + + def get(self, request, course_id): + """ + Gets a course progress status. + + Args: + request (Request): Django request object. + course_id (string): URI element specifying the course location. + + Return: + A JSON serialized representation of the requesting user's current grade status. + """ + username = request.GET.get('username') + + # only the student can access her own grade status info + if request.user.username != username: + log.info( + 'User %s tried to access the grade for user %s.', + request.user.username, + username + ) + return self.make_error_response( + status_code=status.HTTP_404_NOT_FOUND, + developer_message='The user requested does not match the logged in user.', + error_code='user_mismatch' + ) + + # build the course key + 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' + ) + # load the course + try: + course = courses.get_course_with_access( + request.user, + 'load', + course_key, + depth=None, + check_if_enrolled=True + ) + except Http404: + log.info('Course with ID "%s" not found', course_id) + return self.make_error_response( + status_code=status.HTTP_404_NOT_FOUND, + developer_message='The user, the course or both do not exist.', + error_code='user_or_course_does_not_exist' + ) + prep_course_for_grading(course, request) + course_grade = CourseGradeFactory(request.user).create(course) + if not course_grade.has_access_to_course: + # This means the student didn't have access to the course + log.info('User %s not allowed to access grade for course %s', request.user.username, username) + return self.make_error_response( + status_code=status.HTTP_403_FORBIDDEN, + developer_message='The user does not have access to the course.', + error_code='user_does_not_have_access_to_course' + ) + + return Response([{ + 'username': username, + 'course_key': course_id, + 'passed': course_grade.passed, + 'percent': course_grade.percent, + 'letter_grade': course_grade.letter_grade, + }]) diff --git a/lms/djangoapps/grades/new/course_grade.py b/lms/djangoapps/grades/new/course_grade.py index d1dca3f131..38a398625f 100644 --- a/lms/djangoapps/grades/new/course_grade.py +++ b/lms/djangoapps/grades/new/course_grade.py @@ -52,6 +52,18 @@ class CourseGrade(object): locations_to_weighted_scores.update(subsection_grade.locations_to_weighted_scores) return locations_to_weighted_scores + @lazy + def grade_value(self): + """ + Helper function to extract the grade value as calculated by the course's grader. + """ + # Grading policy might be overriden by a CCX, need to reset it + self.course.set_grading_policy(self.course.grading_policy) + return self.course.grader.grade( + self.subsection_grade_totals_by_format, + generate_random_scores=settings.GENERATE_PROFILE_SCORES + ) + @property def has_access_to_course(self): """ @@ -60,22 +72,41 @@ class CourseGrade(object): """ return len(self.course_structure) > 0 - @lazy + @property + def percent(self): + """ + Returns a rounded percent from the overall grade. + """ + return round(self.grade_value['percent'] * 100 + 0.05) / 100 + + @property + def letter_grade(self): + """ + Returns a letter representing the grade. + """ + return self._compute_letter_grade(self.percent) + + @property + def passed(self): + """ + Check user's course passing status. Return True if passed. + """ + nonzero_cutoffs = [cutoff for cutoff in self.course.grade_cutoffs.values() if cutoff > 0] + success_cutoff = min(nonzero_cutoffs) if nonzero_cutoffs else None + return success_cutoff and self.percent >= success_cutoff + + @property def summary(self): """ Returns the grade summary as calculated by the course's grader. """ - # Grading policy might be overriden by a CCX, need to reset it - self.course.set_grading_policy(self.course.grading_policy) - grade_summary = self.course.grader.grade( - self.subsection_grade_totals_by_format, - generate_random_scores=settings.GENERATE_PROFILE_SCORES - ) + grade_summary = self.grade_value # We round the grade here, to make sure that the grade is a whole percentage and # doesn't get displayed differently than it gets grades - grade_summary['percent'] = round(grade_summary['percent'] * 100 + 0.05) / 100 - grade_summary['grade'] = self._compute_letter_grade(grade_summary['percent']) + grade_summary['percent'] = self.percent + grade_summary['grade'] = self.letter_grade + grade_summary['totaled_scores'] = self.subsection_grade_totals_by_format grade_summary['raw_scores'] = list(self.locations_to_weighted_scores.itervalues()) diff --git a/lms/urls.py b/lms/urls.py index 44b1f4147b..2e165ec27b 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -492,6 +492,7 @@ urlpatterns += ( name='courseware_position', ), + # progress page url( r'^courses/{}/progress$'.format( settings.COURSE_ID_PATTERN, @@ -499,6 +500,7 @@ urlpatterns += ( 'courseware.views.views.progress', name='progress', ), + # Takes optional student_id for instructor use--shows profile as that student sees it. url( r'^courses/{}/progress/(?P[^/]*)/$'.format( @@ -508,6 +510,13 @@ urlpatterns += ( name='student_progress', ), + # rest api for grades + url( + r'^api/grades/', + include('lms.djangoapps.grades.api.urls', namespace='grades_api') + ), + + # For the instructor url( r'^courses/{}/instructor$'.format( diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index ad8921115e..40058c1ea3 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -32,11 +32,14 @@ class DeveloperErrorViewMixin(object): (auth failure, method not allowed, etc.) by generating an error response conforming to our API conventions with a developer message. """ - def make_error_response(self, status_code, developer_message): + def make_error_response(self, status_code, developer_message, error_code=None): """ Build an error response with the given status code and developer_message """ - return Response({"developer_message": developer_message}, status=status_code) + error_data = {"developer_message": developer_message} + if error_code is not None: + error_data['error_code'] = error_code + return Response(error_data, status=status_code) def make_validation_error_response(self, validation_error): """