From eff3a070053be457607cf58740285f1726bb7a92 Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Mon, 1 Apr 2019 16:07:12 -0400 Subject: [PATCH] Add a course_modes REST API. --- .../djangoapps/course_modes/api/__init__.py | 0 .../course_modes/api/serializers.py | 57 +++ common/djangoapps/course_modes/api/urls.py | 11 + .../course_modes/api/v1/__init__.py | 0 .../course_modes/api/v1/tests/__init__.py | 0 .../course_modes/api/v1/tests/test_views.py | 368 ++++++++++++++++++ common/djangoapps/course_modes/api/v1/urls.py | 23 ++ .../djangoapps/course_modes/api/v1/views.py | 173 ++++++++ lms/urls.py | 1 + 9 files changed, 633 insertions(+) create mode 100644 common/djangoapps/course_modes/api/__init__.py create mode 100644 common/djangoapps/course_modes/api/serializers.py create mode 100644 common/djangoapps/course_modes/api/urls.py create mode 100644 common/djangoapps/course_modes/api/v1/__init__.py create mode 100644 common/djangoapps/course_modes/api/v1/tests/__init__.py create mode 100644 common/djangoapps/course_modes/api/v1/tests/test_views.py create mode 100644 common/djangoapps/course_modes/api/v1/urls.py create mode 100644 common/djangoapps/course_modes/api/v1/views.py diff --git a/common/djangoapps/course_modes/api/__init__.py b/common/djangoapps/course_modes/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/course_modes/api/serializers.py b/common/djangoapps/course_modes/api/serializers.py new file mode 100644 index 0000000000..ef3dfe5df7 --- /dev/null +++ b/common/djangoapps/course_modes/api/serializers.py @@ -0,0 +1,57 @@ +""" +Course modes API serializers. +""" +from rest_framework import serializers + +from course_modes.models import CourseMode + + +class CourseModeSerializer(serializers.Serializer): + """ + Serializer for the CourseMode model. + The ``course_id``, ``mode_slug``, ``mode_display_name``, and ``currency`` fields + are all required for a create operation. Neither the ``course_id`` + nor the ``mode_slug`` fields can be modified during an update operation. + """ + UNCHANGEABLE_FIELDS = {'course_id', 'mode_slug'} + + course_id = serializers.CharField() + mode_slug = serializers.CharField() + mode_display_name = serializers.CharField() + min_price = serializers.IntegerField(required=False) + currency = serializers.CharField() + expiration_datetime = serializers.DateTimeField(required=False) + expiration_datetime_is_explicit = serializers.BooleanField(required=False) + description = serializers.CharField(required=False) + sku = serializers.CharField(required=False) + bulk_sku = serializers.CharField(required=False) + + def create(self, validated_data): + """ + This method must be implemented for use in our + ListCreateAPIView. + """ + return CourseMode.objects.create(**validated_data) + + def update(self, instance, validated_data): + """ + This method must be implemented for use in our + RetrieveUpdateDestroyAPIView. + """ + errors = {} + + for field in validated_data: + if field in self.UNCHANGEABLE_FIELDS: + errors[field] = ['This field cannot be modified.'] + + if errors: + raise serializers.ValidationError(errors) + + for modifiable_field in validated_data: + setattr( + instance, + modifiable_field, + validated_data.get(modifiable_field, getattr(instance, modifiable_field)) + ) + instance.save() + return instance diff --git a/common/djangoapps/course_modes/api/urls.py b/common/djangoapps/course_modes/api/urls.py new file mode 100644 index 0000000000..3f1d1d4286 --- /dev/null +++ b/common/djangoapps/course_modes/api/urls.py @@ -0,0 +1,11 @@ +""" +URL definitions for the course_modes API. +""" +from django.conf.urls import include, url + + +app_name = 'common.djangoapps.course_modes.api' + +urlpatterns = [ + url(r'^v1/', include('course_modes.api.v1.urls', namespace='v1')), +] diff --git a/common/djangoapps/course_modes/api/v1/__init__.py b/common/djangoapps/course_modes/api/v1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/course_modes/api/v1/tests/__init__.py b/common/djangoapps/course_modes/api/v1/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/course_modes/api/v1/tests/test_views.py b/common/djangoapps/course_modes/api/v1/tests/test_views.py new file mode 100644 index 0000000000..82d1749031 --- /dev/null +++ b/common/djangoapps/course_modes/api/v1/tests/test_views.py @@ -0,0 +1,368 @@ +""" +Tests for the course modes API. +""" +from __future__ import absolute_import, unicode_literals + +from itertools import product +import json +import unittest + +import ddt +from django.conf import settings +from django.urls import reverse +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.test import APITestCase +from six import text_type + +from course_modes.api.v1.views import CourseModesView +from course_modes.models import CourseMode +from course_modes.tests.factories import CourseModeFactory +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES +from openedx.core.djangoapps.user_authn.tests.utils import AuthAndScopesTestMixin, AuthType, JWT_AUTH_TYPES +from student.tests.factories import UserFactory + + +@ddt.ddt +class CourseModesViewTestBase(AuthAndScopesTestMixin): + """ + Tests for the course modes list/create API endpoints. + """ + default_scopes = CourseModesView.required_scopes + view_name = '' + + @classmethod + def setUpClass(cls): + cls.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') + cls.course = CourseOverviewFactory.create(id=cls.course_key) + cls.audit_mode = CourseModeFactory.create( + course_id=cls.course_key, + mode_slug='audit', + mode_display_name='Audit', + min_price=0, + ) + cls.verified_mode = CourseModeFactory.create( + course_id=cls.course_key, + mode_slug='verified', + mode_display_name='Verified', + min_price=25, + ) + + @classmethod + def tearDownClass(cls): + cls.audit_mode.delete() + cls.verified_mode.delete() + + def setUp(self): + super(CourseModesViewTestBase, self).setUp() + # overwrite self.student to be a staff member, since only staff + # should be able to access the course_modes API endpoints. + # This is needed to make a handful of tests inherited from AuthAndScopesTestMixin pass. + # Note that we also inherit here self.global_staff (a staff user) + # and self.other_student, which remains a non-staff user. + self.student = UserFactory.create(password=self.user_password, is_staff=True) + + def assert_success_response_for_student(self, response): + """ + Required method to implement AuthAndScopesTestMixin. + """ + # TODO + pass + + @ddt.data(*product(JWT_AUTH_TYPES, (True, False))) + @ddt.unpack + def test_jwt_on_behalf_of_user(self, auth_type, scopes_enforced): + """ + We have to override this super method due to this API + being restricted to staff users only. + """ + with ENFORCE_JWT_SCOPES.override(active=scopes_enforced): + jwt_token = self._create_jwt_token(self.student, auth_type, include_me_filter=True) + # include_me_filter=True means a JWT filter will require the username + # of the requesting user to be in the requested URL + url = self.get_url(self.student) + '?username={}'.format(self.student.username) + + resp = self.get_response(AuthType.jwt, token=jwt_token, url=url) + assert status.HTTP_200_OK == resp.status_code + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class TestCourseModesListViews(CourseModesViewTestBase, APITestCase): + """ + Tests for the course modes list/create API endpoints. + """ + view_name = 'course_modes_api:v1:course_modes_list' + + # pylint: disable=unused-argument + def get_url(self, username=None, course_id=None): + """ + Required method to implement AuthAndScopesTestMixin. + """ + kwargs = { + 'course_id': text_type(course_id or self.course_key) + } + return reverse(self.view_name, kwargs=kwargs) + + def test_list_course_modes_student_forbidden(self): + self.client.login(username=self.other_student.username, password=self.user_password) + url = self.get_url(course_id=self.course_key) + + response = self.client.get(url) + + assert status.HTTP_403_FORBIDDEN == response.status_code + + def test_list_course_modes_happy_path(self): + self.client.login(username=self.global_staff.username, password=self.user_password) + url = self.get_url(course_id=self.course_key) + + response = self.client.get(url) + + assert status.HTTP_200_OK == response.status_code + actual_results = sorted( + [dict(item) for item in response.data], + key=lambda item: item['mode_slug'], + ) + expected_results = [ + { + 'course_id': text_type(self.course_key), + 'mode_slug': 'audit', + 'mode_display_name': 'Audit', + 'min_price': 0, + 'currency': 'usd', + 'expiration_datetime': None, + 'expiration_datetime_is_explicit': False, + 'description': None, + 'sku': None, + 'bulk_sku': None, + }, + { + 'course_id': text_type(self.course_key), + 'mode_slug': 'verified', + 'mode_display_name': 'Verified', + 'min_price': 25, + 'currency': 'usd', + 'expiration_datetime': None, + 'expiration_datetime_is_explicit': False, + 'description': None, + 'sku': None, + 'bulk_sku': None, + }, + ] + assert expected_results == actual_results + + def test_post_course_mode_forbidden(self): + self.client.login(username=self.other_student.username, password=self.user_password) + url = self.get_url(course_id=self.course_key) + + response = self.client.post(url, data={'it': 'does not matter'}) + + assert status.HTTP_403_FORBIDDEN == response.status_code + + def test_post_course_mode_happy_path(self): + self.client.login(username=self.global_staff.username, password=self.user_password) + url = self.get_url(course_id=self.course_key) + + request_payload = { + 'course_id': text_type(self.course_key), + 'mode_slug': 'masters', + 'mode_display_name': 'Masters', + 'currency': 'usd', + } + + response = self.client.post(url, data=request_payload) + + assert status.HTTP_201_CREATED == response.status_code + new_mode = CourseMode.objects.get(course_id=self.course_key, mode_slug='masters') + assert self.course_key == new_mode.course_id + assert 'masters' == new_mode.mode_slug + assert 'Masters' == new_mode.mode_display_name + assert 0 == new_mode.min_price # 0 is the default defined on the models.CourseMode.currency field + assert 'usd' == new_mode.currency + + def test_post_course_mode_fails_when_missing_required_fields(self): + self.client.login(username=self.global_staff.username, password=self.user_password) + url = self.get_url(course_id=self.course_key) + + request_payload = { + 'course_id': text_type(self.course_key), + 'mode_slug': 'phd', + } + + response = self.client.post(url, data=request_payload) + + assert status.HTTP_400_BAD_REQUEST == response.status_code + expected_data = { + 'currency': [ + 'This field is required.' + ], + 'mode_display_name': [ + 'This field is required.' + ] + } + assert expected_data == response.data + assert 0 == CourseMode.objects.filter(course_id=self.course_key, mode_slug='phd').count() + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class TestCourseModesDetailViews(CourseModesViewTestBase, APITestCase): + """ + Tests for the course modes retrieve/update/delete API endpoints. + """ + view_name = 'course_modes_api:v1:course_modes_detail' + + # pylint: disable=unused-argument + def get_url(self, username=None, course_id=None, mode_slug=None): + """ + Required method to implement AuthAndScopesTestMixin. + """ + kwargs = { + 'course_id': text_type(course_id or self.course_key), + 'mode_slug': mode_slug or 'audit', + } + return reverse(self.view_name, kwargs=kwargs) + + def test_retrieve_course_mode_student_forbidden(self): + self.client.login(username=self.other_student.username, password=self.user_password) + url = self.get_url(mode_slug='audit') + + response = self.client.get(url) + + assert status.HTTP_403_FORBIDDEN == response.status_code + + def test_retrieve_course_mode_does_not_exist(self): + self.client.login(username=self.global_staff.username, password=self.user_password) + url = self.get_url(mode_slug='does-not-exist') + + response = self.client.get(url) + + assert status.HTTP_404_NOT_FOUND == response.status_code + + def test_retrieve_course_mode_happy_path(self): + self.client.login(username=self.global_staff.username, password=self.user_password) + url = self.get_url(mode_slug='audit') + + response = self.client.get(url) + + assert status.HTTP_200_OK == response.status_code + actual_data = dict(response.data) + expected_data = { + 'course_id': text_type(self.course_key), + 'mode_slug': 'audit', + 'mode_display_name': 'Audit', + 'min_price': 0, + 'currency': 'usd', + 'expiration_datetime': None, + 'expiration_datetime_is_explicit': False, + 'description': None, + 'sku': None, + 'bulk_sku': None, + } + assert expected_data == actual_data + + def test_update_course_mode_student_forbidden(self): + self.client.login(username=self.other_student.username, password=self.user_password) + url = self.get_url(mode_slug='audit') + + response = self.client.patch( + url, + content_type='application/merge-patch+json', + data=json.dumps({'it': 'does not matter'}), + ) + + assert status.HTTP_403_FORBIDDEN == response.status_code + + def test_update_course_mode_does_not_exist(self): + self.client.login(username=self.global_staff.username, password=self.user_password) + url = self.get_url(mode_slug='does-not-exist') + + response = self.client.patch( + url, + data=json.dumps({'it': 'does not matter'}), + content_type='application/merge-patch+json', + ) + + assert status.HTTP_404_NOT_FOUND == response.status_code + + def test_update_course_mode_happy_path(self): + _ = CourseModeFactory.create( + course_id=self.course_key, + mode_slug='prof-ed', + mode_display_name='Professional Education', + min_price=100, + currency='jpy', + ) + self.client.login(username=self.global_staff.username, password=self.user_password) + url = self.get_url(mode_slug='prof-ed') + + response = self.client.patch( + url, + data=json.dumps({ + 'min_price': 222, + 'mode_display_name': 'Something Else', + }), + content_type='application/merge-patch+json', + ) + + assert status.HTTP_204_NO_CONTENT == response.status_code + updated_mode = CourseMode.objects.get(course_id=self.course_key, mode_slug='prof-ed') + assert 222 == updated_mode.min_price + assert 'Something Else' == updated_mode.mode_display_name + assert 'jpy' == updated_mode.currency + + def test_update_course_mode_fails_when_updating_static_fields(self): + self.client.login(username=self.global_staff.username, password=self.user_password) + url = self.get_url(mode_slug='audit') + + response = self.client.patch( + url, + data=json.dumps({ + 'course_id': 'course-v1:edX+DemoX+Demo_Course2', + 'mode_slug': 'audit-2', + }), + content_type='application/merge-patch+json', + ) + + assert status.HTTP_400_BAD_REQUEST == response.status_code + expected_data = { + 'course_id': [ + 'This field cannot be modified.' + ], + 'mode_slug': [ + 'This field cannot be modified.' + ] + } + assert expected_data == response.data + assert 'audit' == self.audit_mode.mode_slug + assert self.course_key == self.audit_mode.course_id + + def test_delete_course_mode_student_forbidden(self): + self.client.login(username=self.other_student.username, password=self.user_password) + url = self.get_url(mode_slug='audit') + + response = self.client.delete(url) + + assert status.HTTP_403_FORBIDDEN == response.status_code + + def test_delete_course_mode_does_not_exist(self): + self.client.login(username=self.global_staff.username, password=self.user_password) + url = self.get_url(mode_slug='does-not-exist') + + response = self.client.delete(url) + + assert status.HTTP_404_NOT_FOUND == response.status_code + + def test_delete_course_mode_happy_path(self): + _ = CourseModeFactory.create( + course_id=self.course_key, + mode_slug='bachelors', + mode_display_name='Bachelors', + min_price=1000, + ) + self.client.login(username=self.global_staff.username, password=self.user_password) + url = self.get_url(mode_slug='bachelors') + + response = self.client.delete(url) + + assert status.HTTP_204_NO_CONTENT == response.status_code + assert 0 == CourseMode.objects.filter(course_id=self.course_key, mode_slug='bachelors').count() diff --git a/common/djangoapps/course_modes/api/v1/urls.py b/common/djangoapps/course_modes/api/v1/urls.py new file mode 100644 index 0000000000..8456b1f449 --- /dev/null +++ b/common/djangoapps/course_modes/api/v1/urls.py @@ -0,0 +1,23 @@ +""" +URL definitions for the course_modes v1 API. +""" +from django.conf import settings +from django.conf.urls import url + +from course_modes.api.v1 import views + + +app_name = 'v1' + +urlpatterns = [ + url( + r'^courses/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN), + views.CourseModesView.as_view(), + name='course_modes_list' + ), + url( + r'^courses/{course_id}/(?P.*)$'.format(course_id=settings.COURSE_ID_PATTERN), + views.CourseModesDetailView.as_view(), + name='course_modes_detail' + ), +] diff --git a/common/djangoapps/course_modes/api/v1/views.py b/common/djangoapps/course_modes/api/v1/views.py new file mode 100644 index 0000000000..4d674ed932 --- /dev/null +++ b/common/djangoapps/course_modes/api/v1/views.py @@ -0,0 +1,173 @@ +""" +Defines the "ReSTful" API for course modes. +""" + +import logging + +from django.shortcuts import get_object_or_404 +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 opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView +from rest_framework.response import Response + +from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser +from openedx.core.lib.api.parsers import MergePatchParser + +from course_modes.api.serializers import CourseModeSerializer +from course_modes.models import CourseMode + +log = logging.getLogger(__name__) + + +class CourseModesMixin(object): + """ + A base class for course modes views that specifies authentication, permissions, + serialization, pagination, and the base queryset. + """ + authentication_classes = ( + JwtAuthentication, + OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + # When not considering JWT conditions, this permission class grants access + # to any authenticated client that is staff. When consider JWT, the client + # must be granted access to this resource via their JWT scopes. + permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,) + required_scopes = ['course_modes:read'] + serializer_class = CourseModeSerializer + pagination_class = None + lookup_field = 'course_id' + queryset = CourseMode.objects.all() + + +class CourseModesView(CourseModesMixin, ListCreateAPIView): + """ + View to list or create course modes for a course. + + **Use Case** + + List all course modes for a course, or create a new + course mode. + + **Example Requests** + + GET /api/course_modes/v1/courses/{course_id}/ + + Returns a list of all existing course modes for a course. + + POST /api/course_modes/v1/courses/{course_id}/ + + Creates a new course mode in a course. + + **Response Values** + + For each HTTP verb below, an HTTP 404 "Not Found" response is returned if the + requested course id does not exist. + + GET: If the request is successful, an HTTP 200 "OK" response is returned + along with a list of course mode dictionaries within a course. + The details are contained in a JSON dictionary as follows: + + * course_id: The course identifier. + * mode_slug: The short name for the course mode. + * mode_display_name: The verbose name for the course mode. + * min_price: The minimum price for which a user can + enroll in this mode. + * currency: The currency of the listed prices. + * expiration_datetime: The date and time after which + users cannot enroll in the course in this mode (not required for POST). + * expiration_datetime_is_explicit: Whether the expiration_datetime field was + explicitly set (not required for POST). + * description: A description of this mode (not required for POST). + * sku: The SKU for this mode (for ecommerce purposes, not required for POST). + * bulk_sku: The bulk SKU for this mode (for ecommerce purposes, not required for POST). + + POST: If the request is successful, an HTTP 201 "Created" response is returned. + """ + pass + + +class CourseModesDetailView(CourseModesMixin, RetrieveUpdateDestroyAPIView): + """ + View to retrieve, update, or delete a specific course mode for a course. + + **Use Case** + + Get or update course mode details for a specific course mode on a course. + Or you may delete a specific course mode from a course. + + **Example Requests** + + GET /api/course_modes/v1/courses/{course_id}/{mode_slug} + + Returns details on an existing course mode for a course. + + PATCH /api/course_modes/v1/courses/{course_id}/{mode_slug} + + Updates (via merge) details of an existing course mode for a course. + + DELETE /api/course_modes/v1/courses/{course_id}/{mode_slug} + + Deletes an existing course mode for a course. + + **Response Values** + + For each HTTP verb below, an HTTP 404 "Not Found" response is returned if the + requested course id does not exist, or the mode slug does not exist within the course. + + GET: If the request is successful, an HTTP 200 "OK" response is returned + along with a details for a single course mode within a course. The details are contained + in a JSON dictionary as follows: + + * course_id: The course identifier. + * mode_slug: The short name for the course mode. + * mode_display_name: The verbose name for the course mode. + * min_price: The minimum price for which a user can + enroll in this mode. + * currency: The currency of the listed prices. + * expiration_datetime: The date and time after which + users cannot enroll in the course in this mode (not required for PATCH). + * expiration_datetime_is_explicit: Whether the expiration_datetime field was + explicitly set (not required for PATCH). + * description: A description of this mode (not required for PATCH). + * sku: The SKU for this mode (for ecommerce purposes, not required for PATCH). + * bulk_sku: The bulk SKU for this mode (for ecommerce purposes, not required for PATCH). + + PATCH: If the request is successful, an HTTP 204 "No Content" response is returned. + If "application/merge-patch+json" is not the specified content type, + a 415 "Unsupported Media Type" response is returned. + + DELETE: If the request is successful, an HTTP 204 "No Content" response is returned. + """ + http_method_names = ['get', 'patch', 'delete', 'head', 'options'] + parser_classes = (MergePatchParser,) + multiple_lookup_fields = ('course_id', 'mode_slug') + + def get_object(self): + queryset = self.get_queryset() + query_filter = {} + for field in self.multiple_lookup_fields: + query_filter[field] = self.kwargs[field] + + query_filter['course_id'] = CourseKey.from_string(query_filter['course_id']) + + obj = get_object_or_404(queryset, **query_filter) + self.check_object_permissions(self.request, obj) + return obj + + def patch(self, request, *args, **kwargs): + """ + Performs a partial update of a CourseMode instance. + """ + course_mode = self.get_object() + serializer = self.serializer_class(course_mode, data=request.data, partial=True) + + if serializer.is_valid(raise_exception=True): + serializer.save() # can also raise ValidationError + return Response( + status=status.HTTP_204_NO_CONTENT, + content_type='application/json', + ) diff --git a/lms/urls.py b/lms/urls.py index a1cbbd3524..9c62d0d12c 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -128,6 +128,7 @@ urlpatterns = [ # Multiple course modes and identity verification url(r'^course_modes/', include('course_modes.urls')), + url(r'^api/course_modes/', include('course_modes.api.urls', namespace='course_modes_api')), url(r'^verify_student/', include('verify_student.urls')), # URLs for managing dark launches of languages