Commit 69e9ac1a by Clinton Blackburn

Exposing course verification deadline via Commerce API

The course now supports reading and writing the verification deadline attribute.

XCOM-536
parent 38d7a12f
......@@ -7,6 +7,7 @@ from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from course_modes.models import CourseMode
from verify_student.models import VerificationDeadline
log = logging.getLogger(__name__)
......@@ -17,9 +18,10 @@ class Course(object):
modes = None
_deleted_modes = None
def __init__(self, id, modes): # pylint: disable=invalid-name,redefined-builtin
def __init__(self, id, modes, verification_deadline=None): # pylint: disable=invalid-name,redefined-builtin
self.id = CourseKey.from_string(unicode(id)) # pylint: disable=invalid-name
self.modes = list(modes)
self.verification_deadline = verification_deadline
self._deleted_modes = []
@property
......@@ -55,6 +57,10 @@ class Course(object):
@transaction.commit_on_success
def save(self, *args, **kwargs): # pylint: disable=unused-argument
""" Save the CourseMode objects to the database. """
# Update the verification deadline for the course (not the individual modes)
VerificationDeadline.set_deadline(self.id, self.verification_deadline)
for mode in self.modes:
mode.course_id = self.id
mode.mode_display_name = self.get_mode_display_name(mode)
......@@ -66,6 +72,8 @@ class Course(object):
def update(self, attrs):
""" Update the model with external data (usually passed via API call). """
self.verification_deadline = attrs.get('verification_deadline')
existing_modes = {mode.mode_slug: mode for mode in self.modes}
merged_modes = set()
merged_mode_keys = set()
......@@ -100,7 +108,8 @@ class Course(object):
course_modes = CourseMode.objects.filter(course_id=course_id)
if course_modes:
return cls(unicode(course_id), list(course_modes))
verification_deadline = VerificationDeadline.deadline_for_course(course_id)
return cls(course_id, list(course_modes), verification_deadline=verification_deadline)
return None
......
""" API v1 serializers. """
from datetime import datetime
import pytz
from rest_framework import serializers
from commerce.api.v1.models import Course
......@@ -26,11 +29,35 @@ class CourseSerializer(serializers.Serializer):
""" Course serializer. """
id = serializers.CharField() # pylint: disable=invalid-name
name = serializers.CharField(read_only=True)
verification_deadline = serializers.DateTimeField(blank=True)
modes = CourseModeSerializer(many=True, allow_add_remove=True)
def validate(self, attrs):
""" Ensure the verification deadline occurs AFTER the course mode enrollment deadlines. """
verification_deadline = attrs.get('verification_deadline', None)
if verification_deadline:
upgrade_deadline = None
# Find the earliest upgrade deadline
for mode in attrs['modes']:
expires = mode.expiration_datetime
if expires:
# If we don't already have an upgrade_deadline value, use datetime.max so that we can actually
# complete the comparison.
upgrade_deadline = min(expires, upgrade_deadline or datetime.max.replace(tzinfo=pytz.utc))
# In cases where upgrade_deadline is None (e.g. the verified professional mode), allow a verification
# deadline to be set anyway.
if upgrade_deadline is not None and verification_deadline < upgrade_deadline:
raise serializers.ValidationError(
'Verification deadline must be after the course mode upgrade deadlines.')
return attrs
def restore_object(self, attrs, instance=None):
if instance is None:
return Course(attrs['id'], attrs['modes'])
return Course(attrs['id'], attrs['modes'], attrs['verification_deadline'])
instance.update(attrs)
return instance
......@@ -8,12 +8,14 @@ from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
import pytz
from rest_framework.utils.encoders import JSONEncoder
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from course_modes.models import CourseMode
from student.tests.factories import UserFactory
from verify_student.models import VerificationDeadline
PASSWORD = 'test'
JSON_CONTENT_TYPE = 'application/json'
......@@ -31,30 +33,37 @@ class CourseApiViewTestMixin(object):
self.course_mode = CourseMode.objects.create(course_id=self.course.id, mode_slug=u'verified', min_price=100,
currency=u'USD', sku=u'ABC123')
@staticmethod
def _serialize_course_mode(course_mode):
@classmethod
def _serialize_datetime(cls, dt): # pylint: disable=invalid-name
""" Serializes datetime values using Django REST Framework's encoder.
Use this to simplify equality assertions.
"""
if dt:
return JSONEncoder().default(dt)
return None
@classmethod
def _serialize_course_mode(cls, course_mode):
""" Serialize a CourseMode to a dict. """
# encode the datetime (if nonempty) using DRF's encoder, simplifying
# equality assertions.
expires = course_mode.expiration_datetime
if expires is not None:
expires = JSONEncoder().default(expires)
return {
u'name': course_mode.mode_slug,
u'currency': course_mode.currency.lower(),
u'price': course_mode.min_price,
u'sku': course_mode.sku,
u'expires': expires,
u'expires': cls._serialize_datetime(course_mode.expiration_datetime),
}
@classmethod
def _serialize_course(cls, course, modes=None):
def _serialize_course(cls, course, modes=None, verification_deadline=None):
""" Serializes a course to a Python dict. """
modes = modes or []
verification_deadline = verification_deadline or VerificationDeadline.deadline_for_course(course.id)
return {
u'id': unicode(course.id),
u'name': unicode(course.display_name),
u'verification_deadline': cls._serialize_datetime(verification_deadline),
u'modes': [cls._serialize_course_mode(mode) for mode in modes]
}
......@@ -91,6 +100,9 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase)
self.user = UserFactory.create()
self.client.login(username=self.user.username, password=PASSWORD)
permission = Permission.objects.get(name='Can change course mode')
self.user.user_permissions.add(permission)
@ddt.data('get', 'post', 'put')
def test_authentication_required(self, method):
""" Verify only authenticated users can access the view. """
......@@ -100,6 +112,7 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase)
@ddt.data('post', 'put')
def test_authorization_required(self, method):
self.user.user_permissions.clear()
""" Verify create/edit operations require appropriate permissions. """
response = getattr(self.client, method)(self.path, content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 403)
......@@ -119,32 +132,72 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase)
response = self.client.get(path, content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 404)
def test_update(self):
""" Verify the view supports updating a course. """
permission = Permission.objects.get(name='Can change course mode')
self.user.user_permissions.add(permission)
expiration_datetime = datetime.now()
def _get_update_response_and_expected_data(self, mode_expiration, verification_deadline):
""" Returns expected data and response for course update. """
expected_course_mode = CourseMode(
mode_slug=u'verified',
min_price=200,
currency=u'USD',
sku=u'ABC123',
expiration_datetime=expiration_datetime
expiration_datetime=mode_expiration
)
expected = self._serialize_course(self.course, [expected_course_mode])
expected = self._serialize_course(self.course, [expected_course_mode], verification_deadline)
# Sanity check: The API should return HTTP status 200 for updates
response = self.client.put(self.path, json.dumps(expected), content_type=JSON_CONTENT_TYPE)
return response, expected
def test_update(self):
""" Verify the view supports updating a course. """
# Sanity check: Ensure no verification deadline is set
self.assertIsNone(VerificationDeadline.deadline_for_course(self.course.id))
# Generate the expected data
verification_deadline = datetime(year=2020, month=12, day=31, tzinfo=pytz.utc)
expiration_datetime = datetime.now(pytz.utc)
response, expected = self._get_update_response_and_expected_data(expiration_datetime, verification_deadline)
# Sanity check: The API should return HTTP status 200 for updates
self.assertEqual(response.status_code, 200)
# Verify the course and modes are returned as JSON
actual = json.loads(response.content)
self.assertEqual(actual, expected)
# Verify the verification deadline is updated
self.assertEqual(VerificationDeadline.deadline_for_course(self.course.id), verification_deadline)
def test_update_invalid_dates(self):
"""
Verify the API does not allow the verification deadline to be set before the course mode upgrade deadlines.
"""
expiration_datetime = datetime.now(pytz.utc)
verification_deadline = datetime(year=1915, month=5, day=7, tzinfo=pytz.utc)
response, __ = self._get_update_response_and_expected_data(expiration_datetime, verification_deadline)
self.assertEqual(response.status_code, 400)
# Verify the error message is correct
actual = json.loads(response.content)
expected = {
'non_field_errors': ['Verification deadline must be after the course mode upgrade deadlines.']
}
self.assertEqual(actual, expected)
def test_update_verification_deadline_without_expiring_modes(self):
""" Verify verification deadline can be set if no course modes expire.
This accounts for the verified professional mode, which requires verification but should never expire.
"""
verification_deadline = datetime(year=1915, month=5, day=7, tzinfo=pytz.utc)
response, __ = self._get_update_response_and_expected_data(None, verification_deadline)
self.assertEqual(response.status_code, 200)
self.assertEqual(VerificationDeadline.deadline_for_course(self.course.id), verification_deadline)
def test_update_overwrite(self):
""" Verify that data submitted via PUT overwrites/deletes modes that are
not included in the body of the request. """
permission = Permission.objects.get(name='Can change course mode')
self.user.user_permissions.add(permission)
course_id = unicode(self.course.id)
expected_course_mode = CourseMode(mode_slug=u'credit', min_price=500, currency=u'USD', sku=u'ABC123')
expected = self._serialize_course(self.course, [expected_course_mode])
......@@ -165,9 +218,6 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase)
def test_update_professional_expiration(self, mode_slug, expiration_datetime):
""" Verify that pushing a mode with a professional certificate and an expiration datetime
will be rejected (this is not allowed). """
permission = Permission.objects.get(name='Can change course mode')
self.user.user_permissions.add(permission)
mode = self._serialize_course_mode(
CourseMode(
mode_slug=mode_slug,
......
......@@ -41,3 +41,8 @@ class CourseRetrieveUpdateView(RetrieveUpdateAPIView):
return course
raise Http404
def pre_save(self, obj):
# There is nothing to pre-save. The default behavior changes the Course.id attribute from
# a CourseKey to a string, which is not desired.
pass
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment