diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 9f4499a42e5d..9653f515a809 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -86,6 +86,7 @@ from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles + from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.lib.teams_config import TeamsConfig @@ -132,7 +133,7 @@ }, { 'report_type': 'problem responses', - 'instructor_api_endpoint': 'get_problem_responses', + 'instructor_api_endpoint': 'api_instructor:get_problem_responses', 'task_api_endpoint': 'lms.djangoapps.instructor_task.api.submit_calculate_problem_responses_csv', 'extra_instructor_api_kwargs': {}, } @@ -152,7 +153,7 @@ 'export_ora2_submission_files', 'export_ora2_summary', 'get_grading_config', - 'get_problem_responses', + 'api_instructor:get_problem_responses', 'get_proctored_exam_results', 'get_student_enrollment_status', 'get_student_progress_url', @@ -163,8 +164,8 @@ 'list_email_content', 'list_entrance_exam_instructor_tasks', 'list_forum_members', - 'list_instructor_tasks', - 'list_report_downloads', + 'api_instructor:list_instructor_tasks', + 'api_instructor:list_report_downloads', 'mark_student_can_skip_entrance_exam', 'modify_access', 'register_and_enroll_students', @@ -413,14 +414,14 @@ def setUp(self): {'unique_student_identifier': self.user.email, 'rolename': 'Moderator', 'action': 'allow'}), ('list_forum_members', {'rolename': FORUM_ROLE_COMMUNITY_TA}), ('send_email', {'send_to': '["staff"]', 'subject': 'test', 'message': 'asdf'}), - ('list_instructor_tasks', {}), + ('api_instructor:list_instructor_tasks', {}), ('list_background_email_tasks', {}), - ('list_report_downloads', {}), + ('api_instructor:list_report_downloads', {}), ('calculate_grades_csv', {}), ('get_students_features', {}), ('get_students_who_may_enroll', {}), ('get_proctored_exam_results', {}), - ('get_problem_responses', {}), + ('api_instructor:get_problem_responses', {}), ('export_ora2_data', {}), ('export_ora2_submission_files', {}), ('export_ora2_summary', {}), @@ -498,7 +499,7 @@ def _access_problem_responses_endpoint(self, msg): mock_problem_key.course_key = self.course.id with patch.object(UsageKey, 'from_string') as patched_method: patched_method.return_value = mock_problem_key - self._access_endpoint('get_problem_responses', {}, 200, msg) + self._access_endpoint('api_instructor:get_problem_responses', {}, 200, msg) def test_staff_level(self): """ @@ -518,7 +519,7 @@ def test_staff_level(self): # TODO: make these work if endpoint in ['update_forum_role_membership', 'list_forum_members']: continue - elif endpoint == 'get_problem_responses': + elif endpoint == 'api_instructor:get_problem_responses': self._access_problem_responses_endpoint( "Staff member should be allowed to access endpoint " + endpoint ) @@ -555,7 +556,7 @@ def test_instructor_level(self): # TODO: make these work if endpoint in ['update_forum_role_membership']: continue - elif endpoint == 'get_problem_responses': + elif endpoint == 'api_instructor:get_problem_responses': self._access_problem_responses_endpoint( "Instructor should be allowed to access endpoint " + endpoint ) @@ -2493,7 +2494,7 @@ def test_get_problem_responses_invalid_location(self): message when users submit an invalid problem location. """ url = reverse( - 'get_problem_responses', + 'api_instructor:get_problem_responses', kwargs={'course_id': str(self.course.id)} ) problem_location = '' @@ -2528,7 +2529,7 @@ def test_get_problem_responses_successful(self): message if CSV generation was started successfully. """ url = reverse( - 'get_problem_responses', + 'api_instructor:get_problem_responses', kwargs={'course_id': str(self.course.id)} ) problem_location = '' @@ -2548,7 +2549,7 @@ def test_get_problem_responses_already_running(self): message if CSV generation is already in progress. """ url = reverse( - 'get_problem_responses', + 'api_instructor:get_problem_responses', kwargs={'course_id': str(self.course.id)} ) task_type = 'problem_responses_csv' @@ -2739,7 +2740,7 @@ def test_list_report_downloads_error(self, mock_error): """ ex_status = 503 ex_reason = 'Slow Down' - url = reverse('list_report_downloads', kwargs={'course_id': str(self.course.id)}) + url = reverse('api_instructor:list_report_downloads', kwargs={'course_id': str(self.course.id)}) with patch('storages.backends.s3boto.S3BotoStorage.listdir', side_effect=BotoServerError(ex_status, ex_reason)): response = self.client.post(url, {}) mock_error.assert_called_with( @@ -2753,7 +2754,7 @@ def test_list_report_downloads_error(self, mock_error): assert res_json == {'downloads': []} def test_list_report_downloads(self): - url = reverse('list_report_downloads', kwargs={'course_id': str(self.course.id)}) + url = reverse('api_instructor:list_report_downloads', kwargs={'course_id': str(self.course.id)}) with patch('lms.djangoapps.instructor_task.models.DjangoStorageReportStore.links_for') as mock_links_for: mock_links_for.return_value = [ ('mock_file_name_1', 'https://1.mock.url'), @@ -3539,7 +3540,7 @@ def setUp(self): def test_list_instructor_tasks_running(self, act): """ Test list of all running tasks. """ act.return_value = self.tasks - url = reverse('list_instructor_tasks', kwargs={'course_id': str(self.course.id)}) + url = reverse('api_instructor:list_instructor_tasks', kwargs={'course_id': str(self.course.id)}) mock_factory = MockCompletionInfo() with patch( 'lms.djangoapps.instructor.views.instructor_task_helpers.get_task_completion_info' @@ -3581,7 +3582,7 @@ def test_list_background_email_tasks(self, act): def test_list_instructor_tasks_problem(self, act): """ Test list task history for problem. """ act.return_value = self.tasks - url = reverse('list_instructor_tasks', kwargs={'course_id': str(self.course.id)}) + url = reverse('api_instructor:list_instructor_tasks', kwargs={'course_id': str(self.course.id)}) mock_factory = MockCompletionInfo() with patch( 'lms.djangoapps.instructor.views.instructor_task_helpers.get_task_completion_info' @@ -3604,7 +3605,7 @@ def test_list_instructor_tasks_problem(self, act): def test_list_instructor_tasks_problem_student(self, act): """ Test list task history for problem AND student. """ act.return_value = self.tasks - url = reverse('list_instructor_tasks', kwargs={'course_id': str(self.course.id)}) + url = reverse('api_instructor:list_instructor_tasks', kwargs={'course_id': str(self.course.id)}) mock_factory = MockCompletionInfo() with patch( 'lms.djangoapps.instructor.views.instructor_task_helpers.get_task_completion_info' @@ -3626,6 +3627,88 @@ def test_list_instructor_tasks_problem_student(self, act): assert actual_tasks == expected_tasks +class TestInstructorAPIOAuth(SharedModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Test instructor API OAuth endpoint support. + """ + password = 'password' + + @classmethod + def setUpClass(cls): + super(TestInstructorAPIOAuth, cls).setUpClass() + cls.course = CourseFactory.create( + entrance_exam_id='i4x://{}/{}/chapter/Entrance_exam'.format('test_org', 'test_course') + ) + + def setUp(self): + super().setUp() + self.staff = GlobalStaffFactory() + + @patch('lms.djangoapps.instructor_task.api.get_running_instructor_tasks') + def test_list_instructor_tasks_oauth(self, act): + """ + Test if list_instructor_tasks endpoints supports OAuth + """ + act.return_value = [] + url = reverse('api_instructor:list_instructor_tasks', kwargs={'course_id': text_type(self.course.id)}) + # OAuth Client + oauth_client = ApplicationFactory.create() + access_token = AccessTokenFactory.create( + user=self.staff, + application=oauth_client + ).token + headers = { + 'HTTP_AUTHORIZATION': 'Bearer ' + access_token + } + mock_factory = MockCompletionInfo() + with patch( + 'lms.djangoapps.instructor_task.views.get_task_completion_info' + ) as mock_completion_info: + mock_completion_info.side_effect = mock_factory.mock_get_task_completion_info + response = self.client.post(url, {}, **headers) + self.assertEqual(response.status_code, 200) + + def test_get_problem_responses_oauth(self): + """ + Test whether get_problem_responses allows access via OAuth + """ + url = reverse('api_instructor:get_problem_responses', kwargs={'course_id': text_type(self.course.id)}) + problem_location = '' + + # OAuth Client + oauth_client = ApplicationFactory.create() + access_token = AccessTokenFactory.create( + user=self.staff, + application=oauth_client + ).token + headers = { + 'HTTP_AUTHORIZATION': 'Bearer ' + access_token + } + + response = self.client.post(url, {'problem_location': problem_location}, **headers) + # Http error 400 means Bad request, but our user was authorized + self.assertEqual(response.status_code, 400) + + def test_list_report_downloads_oauth(self): + """ + Test whether list_report_downloads allows access via OAuth + """ + url = reverse('api_instructor:list_report_downloads', kwargs={'course_id': text_type(self.course.id)}) + + # OAuth Client + oauth_client = ApplicationFactory.create() + access_token = AccessTokenFactory.create( + user=self.staff, + application=oauth_client + ).token + headers = { + 'HTTP_AUTHORIZATION': 'Bearer ' + access_token + } + + response = self.client.post(url, {}, **headers) + self.assertEqual(response.status_code, 200) + + @patch('lms.djangoapps.instructor_task.api.get_instructor_task_history', autospec=True) class TestInstructorEmailContentList(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ diff --git a/lms/djangoapps/instructor/urls.py b/lms/djangoapps/instructor/urls.py new file mode 100644 index 000000000000..e420a700a64c --- /dev/null +++ b/lms/djangoapps/instructor/urls.py @@ -0,0 +1,33 @@ +""" +Instructor API endpoint new urls. +""" + +from django.conf import settings +from django.conf.urls import url + +import lms.djangoapps.instructor.views.api as instructor_api + +app_name = 'instructor' +urlpatterns = [ + url( + r'^v1/course/{}/tasks$'.format( + settings.COURSE_ID_PATTERN, + ), + instructor_api.InstructorTasks.as_view(), + name='list_instructor_tasks', + ), + url( + r'^v1/course/{}/reports$'.format( + settings.COURSE_ID_PATTERN, + ), + instructor_api.ReportDownloadsList.as_view(), + name='list_report_downloads', + ), + url( + r'^v1/course/{}/reports/problem_responses$'.format( + settings.COURSE_ID_PATTERN, + ), + instructor_api.ProblemResponseReport.as_view(), + name='get_problem_responses', + ), +] diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 959f8cce3cf2..c6dffddc07ad 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -970,23 +970,22 @@ def extract_user_info(user): return JsonResponse(response_payload) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -@common_exceptions_400 -def get_problem_responses(request, course_id): +# The non-atomic decorator is required because this view calls a celery +# task which uses the 'outer_atomic' context manager. +@view_auth_classes(additional_permission_classes=(IsCourseStaffInstructor,)) +@method_decorator(transaction.non_atomic_requests, name='dispatch') +@method_decorator(common_exceptions_400, name='post') +@method_decorator(require_course_permission(permissions.CAN_RESEARCH), name='post') +class ProblemResponseReport(DeveloperErrorViewMixin, APIView): """ - Initiate generation of a CSV file containing all student answers - to a given problem. + **Use Cases** **Example requests** - POST /courses/{course_id}/instructor/api/get_problem_responses { + POST /api/instructor/v1/course/{course_id}/reports/problem_responses { "problem_location": "{usage_key1},{usage_key2},{usage_key3}"" } - POST /courses/{course_id}/instructor/api/get_problem_responses { + POST /api/instructor/v1/course/{course_id}/reports/problem_responses { "problem_location": "{usage_key}", "problem_types_filter": "problem" } @@ -1016,31 +1015,39 @@ def get_problem_responses(request, course_id): "task_id": "4e49522f-31d9-431a-9cff-dd2a2bf4c85a" } ``` - - Responds with BadRequest if any of the provided problem locations are faulty. """ - course_key = CourseKey.from_string(course_id) - # A comma-separated list of problem locations - # The name of the POST parameter is `problem_location` (not pluralised) in - # order to preserve backwards compatibility with existing third-party - # scripts. - problem_locations = request.POST.get('problem_location', '') - # A comma-separated list of block types - problem_types_filter = request.POST.get('problem_types_filter', '') - report_type = _('problem responses') - try: - for problem_location in problem_locations.split(','): - problem_key = UsageKey.from_string(problem_location).map_into_course(course_key) # lint-amnesty, pylint: disable=unused-variable - except InvalidKeyError: - return JsonResponseBadRequest(_("Could not find problem with this location.")) + @cache_control(no_cache=True, no_store=True, must_revalidate=True) + def post(self, request, course_id): + """ + Initiate generation of a CSV file containing all student answers + to a given problem. - task = task_api.submit_calculate_problem_responses_csv( - request, course_key, problem_locations, problem_types_filter, - ) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + Responds with BadRequest if any of the provided problem locations are faulty. + """ - return JsonResponse({"status": success_status, "task_id": task.task_id}) + course_key = CourseKey.from_string(course_id) + # A comma-separated list of problem locations + # The name of the POST parameter is `problem_location` (not pluralised) in + # order to preserve backwards compatibility with existing third-party + # scripts. + problem_locations = request.data.get('problem_location', '') + # A comma-separated list of block types + problem_types_filter = request.data.get('problem_types_filter', '') + report_type = _('problem responses') + + try: + for problem_location in problem_locations.split(','): + UsageKey.from_string(problem_location).map_into_course(course_key) + except InvalidKeyError: + return JsonResponseBadRequest(_("Could not find problem with this location.")) + + task = task_api.submit_calculate_problem_responses_csv( + request, course_key, problem_locations, problem_types_filter, + ) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + + return JsonResponse({"status": success_status, "task_id": task.task_id}) @require_POST @@ -1851,50 +1858,83 @@ def list_email_content(request, course_id): return JsonResponse(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.SHOW_TASKS) -def list_instructor_tasks(request, course_id): +@view_auth_classes(additional_permission_classes=(IsCourseStaffInstructor,)) +@method_decorator(require_course_permission(permissions.SHOW_TASKS), name='post') +class InstructorTasks(DeveloperErrorViewMixin, APIView): """ - List instructor tasks. + **Use Cases** + + Lists currently running instructor tasks - Takes optional query paremeters. + **Parameters** - With no arguments, lists running tasks. - `problem_location_str` lists task history for problem - `problem_location_str` and `unique_student_identifier` lists task history for problem AND student (intersection) - """ - course_id = CourseKey.from_string(course_id) - problem_location_str = strip_if_string(request.POST.get('problem_location_str', False)) - student = request.POST.get('unique_student_identifier', None) - if student is not None: - student = get_student_from_identifier(student) - if student and not problem_location_str: - return HttpResponseBadRequest( - "unique_student_identifier must accompany problem_location_str" - ) + **Example Requests**: - if problem_location_str: - try: - module_state_key = UsageKey.from_string(problem_location_str).map_into_course(course_id) - except InvalidKeyError: - return HttpResponseBadRequest() - if student: - # Specifying for a single student's history on this problem - tasks = task_api.get_instructor_task_history(course_id, module_state_key, student) + POST /api/instructor/v1/course/{course_id}/tasks + + **Response Values** + { + "tasks": [ + { + "status": "Incomplete", + "task_type": "grade_problems", + "task_id": "2519ff31-22d9-4a62-91e2-55495895b355", + "created": "2019-01-15T18:00:15.902470+00:00", + "task_input": "{}", + "duration_sec": "unknown", + "task_message": "No status information available", + "requester": "staff", + "task_state": "PROGRESS" + } + ] + } + """ + @cache_control(no_cache=True, no_store=True, must_revalidate=True) + def post(self, request, course_id): + """ + List instructor tasks. + """ + course_id = CourseKey.from_string(course_id) + problem_location_str = strip_if_string(request.data.get('problem_location_str', False)) + student = request.data.get('unique_student_identifier', None) + if student is not None: + student = get_student_from_identifier(student) + + if student and not problem_location_str: + return HttpResponseBadRequest( + "unique_student_identifier must accompany problem_location_str" + ) + + if problem_location_str: + try: + module_state_key = course_id.make_usage_key_from_deprecated_string(problem_location_str) + except InvalidKeyError: + return HttpResponseBadRequest() + if student: + # Specifying for a single student's history on this problem + tasks = task_api.get_instructor_task_history( + course_id, + module_state_key, + student + ) + else: + # Specifying for single problem's history + tasks = task_api.get_instructor_task_history( + course_id, + module_state_key + ) else: - # Specifying for single problem's history - tasks = task_api.get_instructor_task_history(course_id, module_state_key) - else: - # If no problem or student, just get currently running tasks - tasks = task_api.get_running_instructor_tasks(course_id) + # If no problem or student, just get currently running tasks + tasks = task_api.get_running_instructor_tasks(course_id) - response_payload = { - 'tasks': list(map(extract_task_features, tasks)), - } - return JsonResponse(response_payload) + response_payload = { + 'tasks': list(map(extract_task_features, tasks)), + } + return JsonResponse(response_payload) @require_POST @@ -1939,28 +1979,48 @@ def list_entrance_exam_instructor_tasks(request, course_id): return JsonResponse(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -def list_report_downloads(request, course_id): +@view_auth_classes(additional_permission_classes=(IsCourseStaffInstructor,)) +@method_decorator(require_course_permission(permissions.CAN_RESEARCH), name='post') +class ReportDownloadsList(DeveloperErrorViewMixin, APIView): """ - List grade CSV files that are available for download for this course. + **Use Cases** + + Lists reports available for download + + **Example Requests**: + + POST /api/instructor/v1/course/{course_id}/reports - Takes the following query parameters: - - (optional) report_name - name of the report + **Response Values** + { + "downloads": [ + { + "url": "https://1.mock.url", + "link": "mock_file_name_1", + "name": "mock_file_name_1" + } + ] + } """ - course_id = CourseKey.from_string(course_id) - report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') - report_name = request.POST.get("report_name", None) + @cache_control(no_cache=True, no_store=True, must_revalidate=True) + def post(self, request, course_id): + """ + List grade CSV files that are available for download for this course. - response_payload = { - 'downloads': [ - dict(name=name, url=url, link=HTML('{}').format(HTML(url), Text(name))) - for name, url in report_store.links_for(course_id) if report_name is None or name == report_name - ] - } - return JsonResponse(response_payload) + Takes the following query parameters: + - (optional) report_name - name of the report + """ + course_id = CourseKey.from_string(course_id) + report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') + report_name = request.data.get("report_name", None) + + response_payload = { + 'downloads': [ + dict(name=name, url=url, link=HTML(u'{}').format(HTML(url), Text(name))) + for name, url in report_store.links_for(course_id) if report_name is None or name == report_name + ] + } + return JsonResponse(response_payload) @require_POST diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 8fc74f80dfb3..e46b793a2c80 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -13,7 +13,6 @@ url(r'^list_course_role_members$', api.list_course_role_members, name='list_course_role_members'), url(r'^modify_access$', api.modify_access, name='modify_access'), url(r'^bulk_beta_modify_access$', api.bulk_beta_modify_access, name='bulk_beta_modify_access'), - url(r'^get_problem_responses$', api.get_problem_responses, name='get_problem_responses'), url(r'^get_grading_config$', api.get_grading_config, name='get_grading_config'), url(r'^get_students_features(?P/csv)?$', api.get_students_features, name='get_students_features'), url(r'^get_issued_certificates/$', api.get_issued_certificates, name='get_issued_certificates'), @@ -31,7 +30,6 @@ name='list_entrance_exam_instructor_tasks'), url(r'^mark_student_can_skip_entrance_exam', api.mark_student_can_skip_entrance_exam, name='mark_student_can_skip_entrance_exam'), - url(r'^list_instructor_tasks$', api.list_instructor_tasks, name='list_instructor_tasks'), url(r'^list_background_email_tasks$', api.list_background_email_tasks, name='list_background_email_tasks'), url(r'^list_email_content$', api.list_email_content, name='list_email_content'), url(r'^list_forum_members$', api.list_forum_members, name='list_forum_members'), @@ -46,7 +44,6 @@ url(r'^get_proctored_exam_results$', api.get_proctored_exam_results, name='get_proctored_exam_results'), # Grade downloads... - url(r'^list_report_downloads$', api.list_report_downloads, name='list_report_downloads'), url(r'^calculate_grades_csv$', api.calculate_grades_csv, name='calculate_grades_csv'), url(r'^problem_grade_report$', api.problem_grade_report, name='problem_grade_report'), @@ -77,4 +74,21 @@ url(r'^generate_bulk_certificate_exceptions', api.generate_bulk_certificate_exceptions, name='generate_bulk_certificate_exceptions'), url(r'^certificate_invalidation_view/$', api.certificate_invalidation_view, name='certificate_invalidation_view'), + + # Instructor endpoints moved to the new API, kept here for backwards compatibility + url( + r'^list_instructor_tasks$', + api.InstructorTasks.as_view(), + name='list_instructor_tasks_old', + ), + url( + r'^get_problem_responses$', + api.ProblemResponseReport.as_view(), + name='get_problem_responses_old', + ), + url( + r'^list_report_downloads$', + api.ReportDownloadsList.as_view(), + name='list_report_downloads_old', + ), ] diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 3d4efaca2a57..83577b41743c 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -354,7 +354,7 @@ def _section_certificates(course): kwargs={'course_id': course.id} ), 'list_instructor_tasks_url': reverse( - 'list_instructor_tasks', + 'api_instructor:list_instructor_tasks', kwargs={'course_id': course.id} ), } @@ -414,7 +414,10 @@ def _section_course_info(course, access): 'start_date': course.start, 'end_date': course.end, 'num_sections': len(course.children), - 'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': str(course_key)}), + 'list_instructor_tasks_url': reverse( + 'api_instructor:list_instructor_tasks', + kwargs={'course_id': str(course_key)} + ), } if settings.FEATURES.get('DISPLAY_ANALYTICS_ENROLLMENTS'): @@ -563,7 +566,10 @@ def _section_student_admin(course, access): 'mark_student_can_skip_entrance_exam', kwargs={'course_id': str(course_key)}, ), - 'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': str(course_key)}), + 'list_instructor_tasks_url': reverse( + 'api_instructor:list_instructor_tasks', + kwargs={'course_id': str(course_key)} + ), 'list_entrace_exam_instructor_tasks_url': reverse( 'list_entrance_exam_instructor_tasks', kwargs={'course_id': str(course_key)} @@ -607,9 +613,12 @@ def _section_data_download(course, access): 'section_display_name': _('Data Download'), 'access': access, 'show_generate_proctored_exam_report_button': show_proctored_report_button, - 'get_problem_responses_url': reverse('get_problem_responses', kwargs={'course_id': str(course_key)}), - 'get_grading_config_url': reverse('get_grading_config', kwargs={'course_id': str(course_key)}), - 'get_students_features_url': reverse('get_students_features', kwargs={'course_id': str(course_key)}), + 'get_problem_responses_url': reverse( + 'api_instructor:get_problem_responses', + kwargs={'course_id': str(course_key)} + ), + 'get_grading_config_url': reverse('get_grading_config', kwargs={'course_id': six.text_type(course_key)}), + 'get_students_features_url': reverse('get_students_features', kwargs={'course_id': six.text_type(course_key)}), 'get_issued_certificates_url': reverse( 'get_issued_certificates', kwargs={'course_id': str(course_key)} ), @@ -620,11 +629,17 @@ def _section_data_download(course, access): 'list_proctored_results_url': reverse( 'get_proctored_exam_results', kwargs={'course_id': str(course_key)} ), - 'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': str(course_key)}), - 'list_report_downloads_url': reverse('list_report_downloads', kwargs={'course_id': str(course_key)}), - 'calculate_grades_csv_url': reverse('calculate_grades_csv', kwargs={'course_id': str(course_key)}), - 'problem_grade_report_url': reverse('problem_grade_report', kwargs={'course_id': str(course_key)}), - 'course_has_survey': True if course.course_survey_name else False, # lint-amnesty, pylint: disable=simplifiable-if-expression + 'list_instructor_tasks_url': reverse( + 'api_instructor:list_instructor_tasks', + kwargs={'course_id': str(course_key)} + ), + 'list_report_downloads_url': reverse( + 'api_instructor:list_report_downloads', + kwargs={'course_id': str(course_key)} + ), + 'calculate_grades_csv_url': reverse('calculate_grades_csv', kwargs={'course_id': six.text_type(course_key)}), + 'problem_grade_report_url': reverse('problem_grade_report', kwargs={'course_id': six.text_type(course_key)}), + 'course_has_survey': True if course.course_survey_name else False, 'course_survey_results_url': reverse( 'get_course_survey_results', kwargs={'course_id': str(course_key)} ), @@ -686,7 +701,7 @@ def _section_send_email(course, access): 'course_modes': course_modes, 'default_cohort_name': DEFAULT_COHORT_NAME, 'list_instructor_tasks_url': reverse( - 'list_instructor_tasks', kwargs={'course_id': str(course_key)} + 'api_instructor:list_instructor_tasks', kwargs={'course_id': str(course_key)} ), 'email_background_tasks_url': reverse( 'list_background_email_tasks', kwargs={'course_id': str(course_key)} diff --git a/lms/urls.py b/lms/urls.py index 7489a9688066..9f38baeab770 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -545,6 +545,9 @@ name='set_course_mode_price', ), + # Instructor API (accessible via OAuth) + url(r'^api/instructor/', include('lms.djangoapps.instructor.urls', namespace='api_instructor')), + # Discussions Management url( r'^courses/{}/discussions/settings$'.format( diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index bcc395a7632c..5ec6310229e2 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -110,9 +110,16 @@ def get_serializer_context(self): return result -def view_auth_classes(is_user=False, is_authenticated=True): +def view_auth_classes(is_user=False, is_authenticated=True, additional_permission_classes=None): """ Function and class decorator that abstracts the authentication and permission checks for api views. + + Parameters + is_user (bool): Requires the requesting user to be present in the URL + is_authenticated (bool): Requires the requesting user to be authenticated + additional_permission_classes (tuple[BasePermission]): + Tuple of classes deriving from rest_framework.permissions.BasePermission + that should additionally be required of users """ def _decorator(func_or_class): """ @@ -129,6 +136,8 @@ def _decorator(func_or_class): func_or_class.permission_classes += (IsAuthenticated,) if is_user: func_or_class.permission_classes += (IsUserInUrl,) + if additional_permission_classes: + func_or_class.permission_classes += additional_permission_classes return func_or_class return _decorator