diff --git a/lms/djangoapps/grades/api/tests/__init__.py b/lms/djangoapps/grades/api/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/grades/api/tests/test_views.py b/lms/djangoapps/grades/api/tests/test_views.py deleted file mode 100644 index 420cb6882e..0000000000 --- a/lms/djangoapps/grades/api/tests/test_views.py +++ /dev/null @@ -1,548 +0,0 @@ -""" -Tests for the views -""" -from datetime import datetime -from urllib import urlencode - -import ddt -from django.urls import reverse -from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory -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 capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory -from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, StaffFactory -from lms.djangoapps.grades.tests.utils import mock_passing_grade -from student.tests.factories import CourseEnrollmentFactory, UserFactory -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls - - -@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 - } - } - """ - shard = 4 - MODULESTORE = TEST_DATA_SPLIT_MODULESTORE - - @classmethod - def setUpClass(cls): - super(CurrentGradeViewTest, cls).setUpClass() - - cls.course = CourseFactory.create(display_name='test course', run="Testing_course") - with cls.store.bulk_operations(cls.course.id): - - 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, tzinfo=UTC), - 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) - cls.staff = StaffFactory(course_key=cls.course.id, 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, - ) - - 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, - } - ) - query_string = '' - if username: - query_string = '?' + urlencode(dict(username=username)) - return base_url + query_string - - 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. - """ - with check_mongo_calls(3): - resp = self.client.get(self.get_url(self.student.username)) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - # redo with block structure now in the cache - with check_mongo_calls(3): - resp = self.client.get(self.get_url(self.student.username)) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - # and again, with the username defaulting to the current user - with check_mongo_calls(3): - resp = self.client.get(self.get_url(None)) - 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_403_FORBIDDEN) - self.assertIn('error_code', resp.data) - self.assertEqual(resp.data['error_code'], 'user_mismatch') - - 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_403_FORBIDDEN) - self.assertIn('error_code', resp.data) - self.assertEqual(resp.data['error_code'], 'user_mismatch') - - 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_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) - 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'], - 'user_or_course_does_not_exist' - ) - - @ddt.data( - 'staff', 'global_staff' - ) - def test_staff_can_see_student(self, staff_user): - """ - Ensure that staff members can see her student's grades. - """ - self.client.logout() - self.client.login(username=getattr(self, staff_user).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, - 'letter_grade': None, - 'percent': 0.0, - 'course_key': str(self.course_key), - 'passed': False - }] - self.assertEqual(resp.data, expected_data) - - @ddt.data( - 'staff', 'global_staff' - ) - def test_staff_requests_nonexistent_user(self, staff_user): - """ - Test that a staff request for a nonexistent username returns an error. - """ - self.client.logout() - 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) - self.assertEqual(resp.data['error_code'], 'user_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) - - @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 mock_passing_grade(letter_grade=grade['letter_grade'], percent=grade['percent']): - 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(grade) - self.assertEqual(resp.data, [expected_data]) - - -@ddt.ddt -class GradingPolicyTestMixin(object): - """ - Mixin class for Grading Policy tests - """ - shard = 4 - view_name = None - - def setUp(self): - super(GradingPolicyTestMixin, self).setUp() - self.create_user_and_access_token() - - def create_user_and_access_token(self): - self.user = GlobalStaffFactory.create() - self.oauth_client = ClientFactory.create() - self.access_token = AccessTokenFactory.create(user=self.user, client=self.oauth_client).token - - @classmethod - def create_course_data(cls): - cls.invalid_course_id = 'foo/bar/baz' - cls.course = CourseFactory.create(display_name='An Introduction to API Testing', raw_grader=cls.raw_grader) - cls.course_id = unicode(cls.course.id) - with cls.store.bulk_operations(cls.course.id, emit_signals=False): - cls.sequential = ItemFactory.create( - category="sequential", - parent_location=cls.course.location, - display_name="Lesson 1", - format="Homework", - graded=True - ) - - factory = MultipleChoiceResponseXMLFactory() - args = {'choices': [False, True, False]} - problem_xml = factory.build_xml(**args) - cls.problem = ItemFactory.create( - category="problem", - parent_location=cls.sequential.location, - display_name="Problem 1", - format="Homework", - data=problem_xml, - ) - - cls.video = ItemFactory.create( - category="video", - parent_location=cls.sequential.location, - display_name="Video 1", - ) - - cls.html = ItemFactory.create( - category="html", - parent_location=cls.sequential.location, - display_name="HTML 1", - ) - - def http_get(self, uri, **headers): - """ - Submit an HTTP GET request - """ - - default_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + self.access_token - } - default_headers.update(headers) - - response = self.client.get(uri, follow=True, **default_headers) - return response - - def assert_get_for_course(self, course_id=None, expected_status_code=200, **headers): - """ - Submit an HTTP GET request to the view for the given course. - Validates the status_code of the response is as expected. - """ - - response = self.http_get( - reverse(self.view_name, kwargs={'course_id': course_id or self.course_id}), - **headers - ) - self.assertEqual(response.status_code, expected_status_code) - return response - - def get_auth_header(self, user): - """ - Returns Bearer auth header with a generated access token - for the given user. - """ - access_token = AccessTokenFactory.create(user=user, client=self.oauth_client).token - return 'Bearer ' + access_token - - def test_get_invalid_course(self): - """ - The view should return a 404 for an invalid course ID. - """ - self.assert_get_for_course(course_id=self.invalid_course_id, expected_status_code=404) - - def test_get(self): - """ - The view should return a 200 for a valid course ID. - """ - return self.assert_get_for_course() - - def test_not_authenticated(self): - """ - The view should return HTTP status 401 if user is unauthenticated. - """ - self.assert_get_for_course(expected_status_code=401, HTTP_AUTHORIZATION=None) - - def test_staff_authorized(self): - """ - The view should return a 200 when provided an access token - for course staff. - """ - user = StaffFactory(course_key=self.course.id) - auth_header = self.get_auth_header(user) - self.assert_get_for_course(HTTP_AUTHORIZATION=auth_header) - - def test_not_authorized(self): - """ - The view should return HTTP status 404 when provided an - access token for an unauthorized user. - """ - user = UserFactory() - auth_header = self.get_auth_header(user) - self.assert_get_for_course(expected_status_code=404, HTTP_AUTHORIZATION=auth_header) - - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) - def test_course_keys(self, modulestore_type): - """ - The view should be addressable by course-keys from both module stores. - """ - course = CourseFactory.create( - start=datetime(2014, 6, 16, 14, 30, tzinfo=UTC), - end=datetime(2015, 1, 16, tzinfo=UTC), - org="MTD", - default_store=modulestore_type, - ) - self.assert_get_for_course(course_id=unicode(course.id)) - - -class CourseGradingPolicyTests(GradingPolicyTestMixin, SharedModuleStoreTestCase): - """ - Tests for CourseGradingPolicy view. - """ - shard = 4 - view_name = 'grades_api:course_grading_policy' - - raw_grader = [ - { - "min_count": 24, - "weight": 0.2, - "type": "Homework", - "drop_count": 0, - "short_label": "HW" - }, - { - "min_count": 4, - "weight": 0.8, - "type": "Exam", - "drop_count": 0, - "short_label": "Exam" - } - ] - - @classmethod - def setUpClass(cls): - super(CourseGradingPolicyTests, cls).setUpClass() - cls.create_course_data() - - def test_get(self): - """ - The view should return grading policy for a course. - """ - response = super(CourseGradingPolicyTests, self).test_get() - - expected = [ - { - "count": 24, - "weight": 0.2, - "assignment_type": "Homework", - "dropped": 0 - }, - { - "count": 4, - "weight": 0.8, - "assignment_type": "Exam", - "dropped": 0 - } - ] - self.assertListEqual(response.data, expected) - - -class CourseGradingPolicyMissingFieldsTests(GradingPolicyTestMixin, SharedModuleStoreTestCase): - """ - Tests for CourseGradingPolicy view when fields are missing. - """ - shard = 4 - view_name = 'grades_api:course_grading_policy' - - # Raw grader with missing keys - raw_grader = [ - { - "min_count": 24, - "weight": 0.2, - "type": "Homework", - "drop_count": 0, - "short_label": "HW" - }, - { - # Deleted "min_count" key - "weight": 0.8, - "type": "Exam", - "drop_count": 0, - "short_label": "Exam" - } - ] - - @classmethod - def setUpClass(cls): - super(CourseGradingPolicyMissingFieldsTests, cls).setUpClass() - cls.create_course_data() - - def test_get(self): - """ - The view should return grading policy for a course. - """ - response = super(CourseGradingPolicyMissingFieldsTests, self).test_get() - - expected = [ - { - "count": 24, - "weight": 0.2, - "assignment_type": "Homework", - "dropped": 0 - }, - { - "count": None, - "weight": 0.8, - "assignment_type": "Exam", - "dropped": 0 - } - ] - self.assertListEqual(response.data, expected) diff --git a/lms/djangoapps/grades/api/urls.py b/lms/djangoapps/grades/api/urls.py index 3d648b1985..933d9b8925 100644 --- a/lms/djangoapps/grades/api/urls.py +++ b/lms/djangoapps/grades/api/urls.py @@ -5,23 +5,9 @@ Grades API URLs. from django.conf import settings from django.conf.urls import include, url -from lms.djangoapps.grades.api import views - app_name = 'lms.djangoapps.grades' urlpatterns = [ - url( - r'^v0/course_grade/{course_id}/users/$'.format( - course_id=settings.COURSE_ID_PATTERN, - ), - views.UserGradeView.as_view(), name='user_grade_detail' - ), - url( - r'^v0/courses/{course_id}/policy/$'.format( - course_id=settings.COURSE_ID_PATTERN, - ), - 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/tests/test_grading_policy_view.py b/lms/djangoapps/grades/api/v1/tests/test_grading_policy_view.py new file mode 100644 index 0000000000..56a971d1aa --- /dev/null +++ b/lms/djangoapps/grades/api/v1/tests/test_grading_policy_view.py @@ -0,0 +1,259 @@ +""" +Tests for the views +""" +from datetime import datetime + +import ddt +from django.urls import reverse +from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory +from pytz import UTC + +from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory +from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, StaffFactory +from student.tests.factories import UserFactory +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + + +@ddt.ddt +class GradingPolicyTestMixin(object): + """ + Mixin class for Grading Policy tests + """ + shard = 4 + view_name = None + + def setUp(self): + super(GradingPolicyTestMixin, self).setUp() + self.create_user_and_access_token() + + def create_user_and_access_token(self): + self.user = GlobalStaffFactory.create() + self.oauth_client = ClientFactory.create() + self.access_token = AccessTokenFactory.create(user=self.user, client=self.oauth_client).token + + @classmethod + def create_course_data(cls): + cls.invalid_course_id = 'foo/bar/baz' + cls.course = CourseFactory.create(display_name='An Introduction to API Testing', raw_grader=cls.raw_grader) + cls.course_id = unicode(cls.course.id) + with cls.store.bulk_operations(cls.course.id, emit_signals=False): + cls.sequential = ItemFactory.create( + category="sequential", + parent_location=cls.course.location, + display_name="Lesson 1", + format="Homework", + graded=True + ) + + factory = MultipleChoiceResponseXMLFactory() + args = {'choices': [False, True, False]} + problem_xml = factory.build_xml(**args) + cls.problem = ItemFactory.create( + category="problem", + parent_location=cls.sequential.location, + display_name="Problem 1", + format="Homework", + data=problem_xml, + ) + + cls.video = ItemFactory.create( + category="video", + parent_location=cls.sequential.location, + display_name="Video 1", + ) + + cls.html = ItemFactory.create( + category="html", + parent_location=cls.sequential.location, + display_name="HTML 1", + ) + + def http_get(self, uri, **headers): + """ + Submit an HTTP GET request + """ + + default_headers = { + 'HTTP_AUTHORIZATION': 'Bearer ' + self.access_token + } + default_headers.update(headers) + + response = self.client.get(uri, follow=True, **default_headers) + return response + + def assert_get_for_course(self, course_id=None, expected_status_code=200, **headers): + """ + Submit an HTTP GET request to the view for the given course. + Validates the status_code of the response is as expected. + """ + + response = self.http_get( + reverse(self.view_name, kwargs={'course_id': course_id or self.course_id}), + **headers + ) + self.assertEqual(response.status_code, expected_status_code) + return response + + def get_auth_header(self, user): + """ + Returns Bearer auth header with a generated access token + for the given user. + """ + access_token = AccessTokenFactory.create(user=user, client=self.oauth_client).token + return 'Bearer ' + access_token + + def test_get_invalid_course(self): + """ + The view should return a 404 for an invalid course ID. + """ + self.assert_get_for_course(course_id=self.invalid_course_id, expected_status_code=404) + + def test_get(self): + """ + The view should return a 200 for a valid course ID. + """ + return self.assert_get_for_course() + + def test_not_authenticated(self): + """ + The view should return HTTP status 401 if user is unauthenticated. + """ + self.assert_get_for_course(expected_status_code=401, HTTP_AUTHORIZATION=None) + + def test_staff_authorized(self): + """ + The view should return a 200 when provided an access token + for course staff. + """ + user = StaffFactory(course_key=self.course.id) + auth_header = self.get_auth_header(user) + self.assert_get_for_course(HTTP_AUTHORIZATION=auth_header) + + def test_not_authorized(self): + """ + The view should return HTTP status 404 when provided an + access token for an unauthorized user. + """ + user = UserFactory() + auth_header = self.get_auth_header(user) + self.assert_get_for_course(expected_status_code=403, HTTP_AUTHORIZATION=auth_header) + + @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) + def test_course_keys(self, modulestore_type): + """ + The view should be addressable by course-keys from both module stores. + """ + course = CourseFactory.create( + start=datetime(2014, 6, 16, 14, 30, tzinfo=UTC), + end=datetime(2015, 1, 16, tzinfo=UTC), + org="MTD", + default_store=modulestore_type, + ) + self.assert_get_for_course(course_id=unicode(course.id)) + + +class CourseGradingPolicyTests(GradingPolicyTestMixin, SharedModuleStoreTestCase): + """ + Tests for CourseGradingPolicy view. + """ + shard = 4 + view_name = 'grades_api:v1:course_grading_policy' + + raw_grader = [ + { + "min_count": 24, + "weight": 0.2, + "type": "Homework", + "drop_count": 0, + "short_label": "HW" + }, + { + "min_count": 4, + "weight": 0.8, + "type": "Exam", + "drop_count": 0, + "short_label": "Exam" + } + ] + + @classmethod + def setUpClass(cls): + super(CourseGradingPolicyTests, cls).setUpClass() + cls.create_course_data() + + def test_get(self): + """ + The view should return grading policy for a course. + """ + response = super(CourseGradingPolicyTests, self).test_get() + + expected = [ + { + "count": 24, + "weight": 0.2, + "assignment_type": "Homework", + "dropped": 0 + }, + { + "count": 4, + "weight": 0.8, + "assignment_type": "Exam", + "dropped": 0 + } + ] + self.assertListEqual(response.data, expected) + + +class CourseGradingPolicyMissingFieldsTests(GradingPolicyTestMixin, SharedModuleStoreTestCase): + """ + Tests for CourseGradingPolicy view when fields are missing. + """ + shard = 4 + view_name = 'grades_api:v1:course_grading_policy' + + # Raw grader with missing keys + raw_grader = [ + { + "min_count": 24, + "weight": 0.2, + "type": "Homework", + "drop_count": 0, + "short_label": "HW" + }, + { + # Deleted "min_count" key + "weight": 0.8, + "type": "Exam", + "drop_count": 0, + "short_label": "Exam" + } + ] + + @classmethod + def setUpClass(cls): + super(CourseGradingPolicyMissingFieldsTests, cls).setUpClass() + cls.create_course_data() + + def test_get(self): + """ + The view should return grading policy for a course. + """ + response = super(CourseGradingPolicyMissingFieldsTests, self).test_get() + + expected = [ + { + "count": 24, + "weight": 0.2, + "assignment_type": "Homework", + "dropped": 0 + }, + { + "count": None, + "weight": 0.8, + "assignment_type": "Exam", + "dropped": 0 + } + ] + self.assertListEqual(response.data, expected) diff --git a/lms/djangoapps/grades/api/v1/urls.py b/lms/djangoapps/grades/api/v1/urls.py index 20503c088d..f3acf71ce6 100644 --- a/lms/djangoapps/grades/api/v1/urls.py +++ b/lms/djangoapps/grades/api/v1/urls.py @@ -3,7 +3,6 @@ from django.conf import settings from django.conf.urls import url from lms.djangoapps.grades.api.v1 import gradebook_views, views -from lms.djangoapps.grades.api.views import CourseGradingPolicy app_name = 'lms.djangoapps.grades' @@ -21,7 +20,7 @@ urlpatterns = [ ), url( r'^policy/courses/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN), - CourseGradingPolicy.as_view(), + views.CourseGradingPolicy.as_view(), name='course_grading_policy' ), url( diff --git a/lms/djangoapps/grades/api/v1/views.py b/lms/djangoapps/grades/api/v1/views.py index 65e2976a9b..7389cd7898 100644 --- a/lms/djangoapps/grades/api/v1/views.py +++ b/lms/djangoapps/grades/api/v1/views.py @@ -2,9 +2,15 @@ import logging from contextlib import contextmanager +from rest_framework import status +from rest_framework.generics import ListAPIView +from rest_framework.response import Response + from edx_rest_framework_extensions import permissions from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.grades.api.serializers import GradingPolicySerializer from lms.djangoapps.grades.api.v1.utils import ( CourseEnrollmentPagination, GradeViewMixin, @@ -14,7 +20,9 @@ from lms.djangoapps.grades.api.v1.utils import ( ) from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.grades.models import PersistentCourseGrade +from opaque_keys import InvalidKeyError from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser +from xmodule.modulestore.django import modulestore log = logging.getLogger(__name__) @@ -136,3 +144,68 @@ class CourseGradesView(GradeViewMixin, PaginatedAPIView): user_grades.append(self._serialize_user_grade(user, course_key, course_grade)) return self.get_paginated_response(user_grades) + + +class CourseGradingPolicy(GradeViewMixin, ListAPIView): + """ + **Use Case** + + Get the course grading policy. + + **Example requests**: + + GET /api/grades/v1/policy/courses/{course_id}/ + + **Response Values** + + * assignment_type: The type of the assignment, as configured by course + staff. For example, course staff might make the assignment types Homework, + Quiz, and Exam. + + * count: The number of assignments of the type. + + * dropped: Number of assignments of the type that are dropped. + + * weight: The weight, or effect, of the assignment type on the learner's + final grade. + """ + allow_empty = False + + authentication_classes = ( + JwtAuthentication, + OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + + def _get_course(self, request, course_id): + """ + Returns the course after parsing the id, checking access, and checking existence. + """ + try: + course_key = get_course_key(request, course_id) + except InvalidKeyError: + raise self.api_error( + status_code=status.HTTP_400_BAD_REQUEST, + developer_message='The provided course key cannot be parsed.', + error_code='invalid_course_key' + ) + + if not has_access(request.user, 'staff', course_key): + raise self.api_error( + status_code=status.HTTP_403_FORBIDDEN, + developer_message='The course does not exist.', + error_code='user_or_course_does_not_exist', + ) + + course = modulestore().get_course(course_key, depth=0) + if not course: + raise self.api_error( + status_code=status.HTTP_404_NOT_FOUND, + developer_message='The course does not exist.', + error_code='user_or_course_does_not_exist', + ) + return course + + def get(self, request, course_id, *args, **kwargs): # pylint: disable=arguments-differ + course = self._get_course(request, course_id) + return Response(GradingPolicySerializer(course.raw_grader, many=True).data) diff --git a/lms/djangoapps/grades/api/views.py b/lms/djangoapps/grades/api/views.py deleted file mode 100644 index a54fe58ac5..0000000000 --- a/lms/djangoapps/grades/api/views.py +++ /dev/null @@ -1,217 +0,0 @@ -""" API v0 views. """ -import logging - -from django.contrib.auth import get_user_model -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.exceptions import AuthenticationFailed -from rest_framework.generics import GenericAPIView, ListAPIView -from rest_framework.response import Response - -from courseware.access import has_access -from lms.djangoapps.courseware import courses -from lms.djangoapps.courseware.exceptions import CourseAccessRedirect -from lms.djangoapps.grades.api.serializers import GradingPolicySerializer -from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes -from student.roles import CourseStaffRole - -log = logging.getLogger(__name__) -USER_MODEL = get_user_model() - - -@view_auth_classes() -class GradeViewMixin(DeveloperErrorViewMixin): - """ - Mixin class for Grades related views. - """ - def _get_course(self, course_key_string, user, access_action): - """ - Returns the course for the given course_key_string after - verifying the requested access to the course by the given user. - """ - try: - course_key = CourseKey.from_string(course_key_string) - except InvalidKeyError: - raise self.api_error( - status_code=status.HTTP_404_NOT_FOUND, - developer_message='The provided course key cannot be parsed.', - error_code='invalid_course_key' - ) - - try: - return courses.get_course_with_access( - user, - access_action, - course_key, - check_if_enrolled=True, - ) - except Http404: - log.info('Course with ID "%s" not found', course_key_string) - except CourseAccessRedirect: - log.info('User %s does not have access to course with ID "%s"', user.username, course_key_string) - - raise self.api_error( - 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', - ) - - def _get_effective_user(self, request, course): - """ - Returns the user object corresponding to the request's 'username' parameter, - or the current request.user if no 'username' was provided. - - Verifies that the request.user has access to the requested users's grades. - Returns a 403 error response if access is denied, or a 404 error response if the user does not exist. - """ - - # Use the request user's if none provided. - if 'username' in request.GET: - username = request.GET.get('username') - else: - username = request.user.username - - if request.user.username == username: - # Any user may request her own grades - return request.user - - # Only a user with staff access may request grades for a user other than herself. - if not has_access(request.user, CourseStaffRole.ROLE, course): - log.info( - 'User %s tried to access the grade for user %s.', - request.user.username, - username - ) - raise self.api_error( - status_code=status.HTTP_403_FORBIDDEN, - developer_message='The user requested does not match the logged in user.', - error_code='user_mismatch' - ) - - try: - return USER_MODEL.objects.get(username=username) - - except USER_MODEL.DoesNotExist: - raise self.api_error( - status_code=status.HTTP_404_NOT_FOUND, - developer_message='The user matching the requested username does not exist.', - error_code='user_does_not_exist' - ) - - def perform_authentication(self, request): - """ - Ensures that the user is authenticated (e.g. not an AnonymousUser), unless DEBUG mode is enabled. - """ - super(GradeViewMixin, self).perform_authentication(request) - if request.user.is_anonymous: - raise AuthenticationFailed - - -class UserGradeView(GradeViewMixin, GenericAPIView): - """ - **Use Case** - - * Get the current course grades for a user in a course. - - The currently logged-in user may request her own grades, or a user with staff access to the course may request - any enrolled user's grades. - - **Example Request** - - GET /api/grades/v0/course_grade/{course_id}/users/?username={username} - - **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. - Defaults to the currently logged-in 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, - }] - - """ - 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. - """ - - course = self._get_course(course_id, request.user, 'load') - grade_user = self._get_effective_user(request, course) - course_grade = CourseGradeFactory().read(grade_user, course) - - return Response([{ - 'username': grade_user.username, - 'course_key': course_id, - 'passed': course_grade.passed, - 'percent': course_grade.percent, - 'letter_grade': course_grade.letter_grade, - }]) - - -class CourseGradingPolicy(GradeViewMixin, ListAPIView): - """ - **Use Case** - - Get the course grading policy. - - **Example requests**: - - GET /api/grades/v0/policy/{course_id}/ - - **Response Values** - - * assignment_type: The type of the assignment, as configured by course - staff. For example, course staff might make the assignment types Homework, - Quiz, and Exam. - - * count: The number of assignments of the type. - - * dropped: Number of assignments of the type that are dropped. - - * weight: The weight, or effect, of the assignment type on the learner's - final grade. - """ - - allow_empty = False - - def get(self, request, course_id, **kwargs): - course = self._get_course(course_id, request.user, 'staff') - return Response(GradingPolicySerializer(course.raw_grader, many=True).data)