Commit 09405a75 by Clinton Blackburn

Merge pull request #9193 from edx/patch/2015-08-04

ECOM Patch
parents 69be9000 69e9ac1a
""" API v1 models. """ """ API v1 models. """
from itertools import groupby from itertools import groupby
import logging
import logging
from django.db import transaction from django.db import transaction
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from course_modes.models import CourseMode from course_modes.models import CourseMode
from verify_student.models import VerificationDeadline
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -17,11 +18,25 @@ class Course(object): ...@@ -17,11 +18,25 @@ class Course(object):
modes = None modes = None
_deleted_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.id = CourseKey.from_string(unicode(id)) # pylint: disable=invalid-name
self.modes = list(modes) self.modes = list(modes)
self.verification_deadline = verification_deadline
self._deleted_modes = [] self._deleted_modes = []
@property
def name(self):
""" Return course name. """
course_id = CourseKey.from_string(unicode(self.id)) # pylint: disable=invalid-name
try:
return CourseOverview.get_from_id(course_id).display_name
except CourseOverview.DoesNotExist:
# NOTE (CCB): Ideally, the course modes table should only contain data for courses that exist in
# modulestore. If that is not the case, say for local development/testing, carry on without failure.
log.warning('Failed to retrieve CourseOverview for [%s]. Using empty course name.', course_id)
return None
def get_mode_display_name(self, mode): def get_mode_display_name(self, mode):
""" Returns display name for the given mode. """ """ Returns display name for the given mode. """
slug = mode.mode_slug.strip().lower() slug = mode.mode_slug.strip().lower()
...@@ -42,6 +57,10 @@ class Course(object): ...@@ -42,6 +57,10 @@ class Course(object):
@transaction.commit_on_success @transaction.commit_on_success
def save(self, *args, **kwargs): # pylint: disable=unused-argument def save(self, *args, **kwargs): # pylint: disable=unused-argument
""" Save the CourseMode objects to the database. """ """ 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: for mode in self.modes:
mode.course_id = self.id mode.course_id = self.id
mode.mode_display_name = self.get_mode_display_name(mode) mode.mode_display_name = self.get_mode_display_name(mode)
...@@ -53,6 +72,8 @@ class Course(object): ...@@ -53,6 +72,8 @@ class Course(object):
def update(self, attrs): def update(self, attrs):
""" Update the model with external data (usually passed via API call). """ """ 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} existing_modes = {mode.mode_slug: mode for mode in self.modes}
merged_modes = set() merged_modes = set()
merged_mode_keys = set() merged_mode_keys = set()
...@@ -87,7 +108,8 @@ class Course(object): ...@@ -87,7 +108,8 @@ class Course(object):
course_modes = CourseMode.objects.filter(course_id=course_id) course_modes = CourseMode.objects.filter(course_id=course_id)
if course_modes: 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 return None
......
""" API v1 serializers. """ """ API v1 serializers. """
from datetime import datetime
import pytz
from rest_framework import serializers from rest_framework import serializers
from commerce.api.v1.models import Course from commerce.api.v1.models import Course
...@@ -25,11 +28,36 @@ class CourseModeSerializer(serializers.ModelSerializer): ...@@ -25,11 +28,36 @@ class CourseModeSerializer(serializers.ModelSerializer):
class CourseSerializer(serializers.Serializer): class CourseSerializer(serializers.Serializer):
""" Course serializer. """ """ Course serializer. """
id = serializers.CharField() # pylint: disable=invalid-name 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) 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): def restore_object(self, attrs, instance=None):
if instance is None: if instance is None:
return Course(attrs['id'], attrs['modes']) return Course(attrs['id'], attrs['modes'], attrs['verification_deadline'])
instance.update(attrs) instance.update(attrs)
return instance return instance
...@@ -8,12 +8,14 @@ from django.conf import settings ...@@ -8,12 +8,14 @@ from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test.utils import override_settings from django.test.utils import override_settings
import pytz
from rest_framework.utils.encoders import JSONEncoder from rest_framework.utils.encoders import JSONEncoder
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from course_modes.models import CourseMode from course_modes.models import CourseMode
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from verify_student.models import VerificationDeadline
PASSWORD = 'test' PASSWORD = 'test'
JSON_CONTENT_TYPE = 'application/json' JSON_CONTENT_TYPE = 'application/json'
...@@ -31,20 +33,38 @@ class CourseApiViewTestMixin(object): ...@@ -31,20 +33,38 @@ class CourseApiViewTestMixin(object):
self.course_mode = CourseMode.objects.create(course_id=self.course.id, mode_slug=u'verified', min_price=100, self.course_mode = CourseMode.objects.create(course_id=self.course.id, mode_slug=u'verified', min_price=100,
currency=u'USD', sku=u'ABC123') currency=u'USD', sku=u'ABC123')
@staticmethod @classmethod
def _serialize_course_mode(course_mode): 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. """ """ 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 { return {
u'name': course_mode.mode_slug, u'name': course_mode.mode_slug,
u'currency': course_mode.currency.lower(), u'currency': course_mode.currency.lower(),
u'price': course_mode.min_price, u'price': course_mode.min_price,
u'sku': course_mode.sku, 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, 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]
} }
...@@ -66,12 +86,7 @@ class CourseListViewTests(CourseApiViewTestMixin, ModuleStoreTestCase): ...@@ -66,12 +86,7 @@ class CourseListViewTests(CourseApiViewTestMixin, ModuleStoreTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
actual = json.loads(response.content) actual = json.loads(response.content)
expected = [ expected = [self._serialize_course(self.course, [self.course_mode])]
{
u'id': unicode(self.course.id),
u'modes': [self._serialize_course_mode(self.course_mode)]
}
]
self.assertListEqual(actual, expected) self.assertListEqual(actual, expected)
...@@ -85,6 +100,9 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase) ...@@ -85,6 +100,9 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase)
self.user = UserFactory.create() self.user = UserFactory.create()
self.client.login(username=self.user.username, password=PASSWORD) 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') @ddt.data('get', 'post', 'put')
def test_authentication_required(self, method): def test_authentication_required(self, method):
""" Verify only authenticated users can access the view. """ """ Verify only authenticated users can access the view. """
...@@ -94,6 +112,7 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase) ...@@ -94,6 +112,7 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase)
@ddt.data('post', 'put') @ddt.data('post', 'put')
def test_authorization_required(self, method): def test_authorization_required(self, method):
self.user.user_permissions.clear()
""" Verify create/edit operations require appropriate permissions. """ """ Verify create/edit operations require appropriate permissions. """
response = getattr(self.client, method)(self.path, content_type=JSON_CONTENT_TYPE) response = getattr(self.client, method)(self.path, content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
...@@ -104,10 +123,7 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase) ...@@ -104,10 +123,7 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
actual = json.loads(response.content) actual = json.loads(response.content)
expected = { expected = self._serialize_course(self.course, [self.course_mode])
u'id': unicode(self.course.id),
u'modes': [self._serialize_course_mode(self.course_mode)]
}
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
def test_retrieve_invalid_course(self): def test_retrieve_invalid_course(self):
...@@ -116,40 +132,75 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase) ...@@ -116,40 +132,75 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase)
response = self.client.get(path, content_type=JSON_CONTENT_TYPE) response = self.client.get(path, content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_update(self): def _get_update_response_and_expected_data(self, mode_expiration, verification_deadline):
""" Verify the view supports updating a course. """ """ Returns expected data and response for course update. """
permission = Permission.objects.get(name='Can change course mode')
self.user.user_permissions.add(permission)
expiration_datetime = datetime.now()
expected_course_mode = CourseMode( expected_course_mode = CourseMode(
mode_slug=u'verified', mode_slug=u'verified',
min_price=200, min_price=200,
currency=u'USD', currency=u'USD',
sku=u'ABC123', sku=u'ABC123',
expiration_datetime=expiration_datetime expiration_datetime=mode_expiration
) )
expected = { expected = self._serialize_course(self.course, [expected_course_mode], verification_deadline)
u'id': unicode(self.course.id),
u'modes': [self._serialize_course_mode(expected_course_mode)] # 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) 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) self.assertEqual(response.status_code, 200)
# Verify the course and modes are returned as JSON
actual = json.loads(response.content) actual = json.loads(response.content)
self.assertEqual(actual, expected) 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): def test_update_overwrite(self):
""" Verify that data submitted via PUT overwrites/deletes modes that are """ Verify that data submitted via PUT overwrites/deletes modes that are
not included in the body of the request. """ 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) course_id = unicode(self.course.id)
expected = { expected_course_mode = CourseMode(mode_slug=u'credit', min_price=500, currency=u'USD', sku=u'ABC123')
u'id': course_id, expected = self._serialize_course(self.course, [expected_course_mode])
u'modes': [self._serialize_course_mode(
CourseMode(mode_slug=u'credit', min_price=500, currency=u'USD', sku=u'ABC123')), ]
}
path = reverse('commerce_api:v1:courses:retrieve_update', args=[course_id]) path = reverse('commerce_api:v1:courses:retrieve_update', args=[course_id])
response = self.client.put(path, json.dumps(expected), content_type=JSON_CONTENT_TYPE) response = self.client.put(path, json.dumps(expected), content_type=JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
...@@ -167,9 +218,6 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase) ...@@ -167,9 +218,6 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase)
def test_update_professional_expiration(self, mode_slug, expiration_datetime): def test_update_professional_expiration(self, mode_slug, expiration_datetime):
""" Verify that pushing a mode with a professional certificate and an expiration datetime """ Verify that pushing a mode with a professional certificate and an expiration datetime
will be rejected (this is not allowed). """ 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( mode = self._serialize_course_mode(
CourseMode( CourseMode(
mode_slug=mode_slug, mode_slug=mode_slug,
...@@ -190,19 +238,14 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase) ...@@ -190,19 +238,14 @@ class CourseRetrieveUpdateViewTests(CourseApiViewTestMixin, ModuleStoreTestCase)
def assert_can_create_course(self, **request_kwargs): def assert_can_create_course(self, **request_kwargs):
""" Verify a course can be created by the view. """ """ Verify a course can be created by the view. """
course = CourseFactory.create() course = CourseFactory.create()
course_id = unicode(course.id) expected_modes = [CourseMode(mode_slug=u'verified', min_price=150, currency=u'USD', sku=u'ABC123'),
expected = { CourseMode(mode_slug=u'honor', min_price=0, currency=u'USD', sku=u'DEADBEEF')]
u'id': course_id, expected = self._serialize_course(course, expected_modes)
u'modes': [ path = reverse('commerce_api:v1:courses:retrieve_update', args=[unicode(course.id)])
self._serialize_course_mode(
CourseMode(mode_slug=u'verified', min_price=150, currency=u'USD', sku=u'ABC123')),
self._serialize_course_mode(
CourseMode(mode_slug=u'honor', min_price=0, currency=u'USD', sku=u'DEADBEEF')),
]
}
path = reverse('commerce_api:v1:courses:retrieve_update', args=[course_id])
response = self.client.put(path, json.dumps(expected), content_type=JSON_CONTENT_TYPE, **request_kwargs) response = self.client.put(path, json.dumps(expected), content_type=JSON_CONTENT_TYPE, **request_kwargs)
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
actual = json.loads(response.content) actual = json.loads(response.content)
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
......
...@@ -41,3 +41,8 @@ class CourseRetrieveUpdateView(RetrieveUpdateAPIView): ...@@ -41,3 +41,8 @@ class CourseRetrieveUpdateView(RetrieveUpdateAPIView):
return course return course
raise Http404 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