Commit 0c375a95 by Renzo Lucioni

Merge pull request #245 from edx/renzo/verification-deadline

Support migration and publication of course verification deadlines
parents 9dff7779 9f8c1884
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('courses', '0003_auto_20150618_1108'),
]
operations = [
migrations.AddField(
model_name='course',
name='verification_deadline',
field=models.DateTimeField(help_text='Last date/time on which verification for this product can be submitted.', null=True, blank=True),
preserve_default=True,
),
migrations.AddField(
model_name='historicalcourse',
name='verification_deadline',
field=models.DateTimeField(help_text='Last date/time on which verification for this product can be submitted.', null=True, blank=True),
preserve_default=True,
),
]
...@@ -3,6 +3,7 @@ import logging ...@@ -3,6 +3,7 @@ import logging
from django.db import models, transaction from django.db import models, transaction
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
from oscar.core.loading import get_model from oscar.core.loading import get_model
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
...@@ -21,6 +22,11 @@ StockRecord = get_model('partner', 'StockRecord') ...@@ -21,6 +22,11 @@ StockRecord = get_model('partner', 'StockRecord')
class Course(models.Model): class Course(models.Model):
id = models.CharField(null=False, max_length=255, primary_key=True, verbose_name='ID') id = models.CharField(null=False, max_length=255, primary_key=True, verbose_name='ID')
name = models.CharField(null=False, max_length=255) name = models.CharField(null=False, max_length=255)
verification_deadline = models.DateTimeField(
null=True,
blank=True,
help_text=_('Last date/time on which verification for this product can be submitted.')
)
history = HistoricalRecords() history = HistoricalRecords()
thumbnail_url = models.URLField(null=True, blank=True) thumbnail_url = models.URLField(null=True, blank=True)
......
...@@ -16,6 +16,9 @@ class LMSPublisher(object): ...@@ -16,6 +16,9 @@ class LMSPublisher(object):
return seat.expires.isoformat() return seat.expires.isoformat()
def get_course_verification_deadline(self, course):
return course.verification_deadline.isoformat() if course.verification_deadline else None
def serialize_seat_for_commerce_api(self, seat): def serialize_seat_for_commerce_api(self, seat):
""" Serializes a course seat product to a dict that can be further serialized to JSON. """ """ Serializes a course seat product to a dict that can be further serialized to JSON. """
stock_record = seat.stockrecords.first() stock_record = seat.stockrecords.first()
...@@ -43,11 +46,15 @@ class LMSPublisher(object): ...@@ -43,11 +46,15 @@ class LMSPublisher(object):
logger.error('COMMERCE_API_URL is not set. Commerce data will not be published!') logger.error('COMMERCE_API_URL is not set. Commerce data will not be published!')
return False return False
modes = [self.serialize_seat_for_commerce_api(seat) for seat in course.seat_products]
course_id = course.id course_id = course.id
name = course.name
verification_deadline = self.get_course_verification_deadline(course)
modes = [self.serialize_seat_for_commerce_api(seat) for seat in course.seat_products]
data = { data = {
'id': course_id, 'id': course_id,
'modes': modes 'name': name,
'verification_deadline': verification_deadline,
'modes': modes,
} }
url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), course_id) url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), course_id)
......
...@@ -89,6 +89,8 @@ class LMSPublisherTests(CourseCatalogTestMixin, TestCase): ...@@ -89,6 +89,8 @@ class LMSPublisherTests(CourseCatalogTestMixin, TestCase):
actual = json.loads(last_request.body) actual = json.loads(last_request.body)
expected = { expected = {
'id': self.course.id, 'id': self.course.id,
'name': self.course.name,
'verification_deadline': self.course.verification_deadline.isoformat(),
'modes': [self.publisher.serialize_seat_for_commerce_api(seat) for seat in self.course.seat_products] 'modes': [self.publisher.serialize_seat_for_commerce_api(seat) for seat in self.course.seat_products]
} }
self.assertDictEqual(actual, expected) self.assertDictEqual(actual, expected)
......
...@@ -135,7 +135,7 @@ class CourseSerializer(serializers.HyperlinkedModelSerializer): ...@@ -135,7 +135,7 @@ class CourseSerializer(serializers.HyperlinkedModelSerializer):
class Meta(object): class Meta(object):
model = Course model = Course
fields = ('id', 'url', 'name', 'type', 'products_url', 'last_edited') fields = ('id', 'url', 'name', 'verification_deadline', 'type', 'products_url', 'last_edited')
read_only_fields = ('type',) read_only_fields = ('type',)
extra_kwargs = { extra_kwargs = {
'url': {'view_name': COURSE_DETAIL_VIEW} 'url': {'view_name': COURSE_DETAIL_VIEW}
...@@ -150,6 +150,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab ...@@ -150,6 +150,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab
""" """
id = serializers.RegexField(COURSE_ID_REGEX, max_length=255) id = serializers.RegexField(COURSE_ID_REGEX, max_length=255)
name = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255)
verification_deadline = serializers.DateTimeField()
products = serializers.ListField() products = serializers.ListField()
def validate_products(self, products): def validate_products(self, products):
...@@ -184,6 +185,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab ...@@ -184,6 +185,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab
""" """
course_id = self.validated_data['id'] course_id = self.validated_data['id']
course_name = self.validated_data['name'] course_name = self.validated_data['name']
course_verification_deadline = self.validated_data['verification_deadline']
products = self.validated_data['products'] products = self.validated_data['products']
try: try:
...@@ -200,6 +202,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab ...@@ -200,6 +202,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab
with transaction.atomic(): with transaction.atomic():
course, created = Course.objects.get_or_create(id=course_id) course, created = Course.objects.get_or_create(id=course_id)
course.name = course_name course.name = course_name
course.verification_deadline = course_verification_deadline
course.save() course.save()
for product in products: for product in products:
......
...@@ -42,6 +42,7 @@ class CourseViewSetTests(TestServerUrlMixin, CourseCatalogTestMixin, UserMixin, ...@@ -42,6 +42,7 @@ class CourseViewSetTests(TestServerUrlMixin, CourseCatalogTestMixin, UserMixin,
return { return {
'id': course.id, 'id': course.id,
'name': course.name, 'name': course.name,
'verification_deadline': course.verification_deadline,
'type': course.type, 'type': course.type,
'url': self.get_full_url(reverse('api:v2:course-detail', kwargs={'pk': course.id})), 'url': self.get_full_url(reverse('api:v2:course-detail', kwargs={'pk': course.id})),
'products_url': products_url, 'products_url': products_url,
......
...@@ -32,6 +32,7 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase): ...@@ -32,6 +32,7 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase):
self.data = { self.data = {
'id': self.course_id, 'id': self.course_id,
'name': self.course_name, 'name': self.course_name,
'verification_deadline': EXPIRES_STRING,
'products': [ 'products': [
{ {
'product_class': 'Seat', 'product_class': 'Seat',
...@@ -82,7 +83,11 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase): ...@@ -82,7 +83,11 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase):
if recreate: if recreate:
# Create a Course. # Create a Course.
course = Course.objects.create(id=self.course_id, name=self.course_name) course = Course.objects.create(
id=self.course_id,
name=self.course_name,
verification_deadline=EXPIRES,
)
# Create associated products. # Create associated products.
for product in self.data['products']: for product in self.data['products']:
...@@ -120,6 +125,9 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase): ...@@ -120,6 +125,9 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase):
course = Course.objects.get(id=course_id) course = Course.objects.get(id=course_id)
self.assertEqual(course.name, expected['name']) self.assertEqual(course.name, expected['name'])
verification_deadline = EXPIRES if expected['verification_deadline'] else None
self.assertEqual(course.verification_deadline, verification_deadline)
# Validate product structure. # Validate product structure.
products = expected['products'] products = expected['products']
expected_child_products = len(products) expected_child_products = len(products)
......
...@@ -2,7 +2,7 @@ from __future__ import unicode_literals ...@@ -2,7 +2,7 @@ from __future__ import unicode_literals
import logging import logging
from optparse import make_option from optparse import make_option
import dateutil.parser from dateutil.parser import parse
from django.conf import settings from django.conf import settings
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.db import transaction from django.db import transaction
...@@ -24,9 +24,12 @@ class MigratedCourse(object): ...@@ -24,9 +24,12 @@ class MigratedCourse(object):
Loaded data is NOT persisted until the save() method is called. Loaded data is NOT persisted until the save() method is called.
""" """
name, modes = self._retrieve_data_from_lms(access_token) name, verification_deadline, modes = self._retrieve_data_from_lms(access_token)
self.course.name = name self.course.name = name
self.course.verification_deadline = verification_deadline
self.course.save() self.course.save()
self._get_products(modes) self._get_products(modes)
def _build_lms_url(self, path): def _build_lms_url(self, path):
...@@ -35,40 +38,65 @@ class MigratedCourse(object): ...@@ -35,40 +38,65 @@ class MigratedCourse(object):
host = settings.LMS_URL_ROOT.strip('/') host = settings.LMS_URL_ROOT.strip('/')
return '{host}/{path}'.format(host=host, path=path) return '{host}/{path}'.format(host=host, path=path)
def _retrieve_data_from_lms(self, access_token): def _query_commerce_api(self, headers):
""" """Get course name and verification deadline from the Commerce API."""
Retrieves the course name and modes from the LMS. if not settings.COMMERCE_API_URL:
""" message = 'Aborting migration. COMMERCE_API_URL is not set.'
headers = { logger.error(message)
'Accept': 'application/json', raise Exception(message)
'Authorization': 'Bearer ' + access_token
}
# Get course name from Course Structure API url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), self.course.id)
url = self._build_lms_url('api/course_structure/v0/courses/{}/'.format(self.course.id)) timeout = settings.COMMERCE_API_TIMEOUT
response = requests.get(url, headers=headers)
response = requests.get(url, headers=headers, timeout=timeout)
if response.status_code != 200: if response.status_code != 200:
raise Exception('Unable to retrieve course name: [{status}] - {body}'.format(status=response.status_code, raise Exception('Unable to retrieve course name and verification deadline: [{status}] - {body}'.format(
body=response.content)) status=response.status_code,
body=response.content
))
data = response.json() data = response.json()
logger.debug(data) logger.debug(data)
course_name = data['name'] course_name = data['name']
if course_name is None:
message = u'Aborting migration. No name is available for {}.'.format(self.course.id)
logger.error(message)
raise Exception(message)
# Get modes and pricing from Enrollment API course_verification_deadline = data['verification_deadline']
course_verification_deadline = parse(course_verification_deadline) if course_verification_deadline else None
return course_name, course_verification_deadline
def _query_enrollment_api(self, headers):
"""Get modes and pricing from Enrollment API."""
url = self._build_lms_url('api/enrollment/v1/course/{}'.format(self.course.id)) url = self._build_lms_url('api/enrollment/v1/course/{}'.format(self.course.id))
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
if response.status_code != 200: if response.status_code != 200:
raise Exception('Unable to retrieve course modes: [{status}] - {body}'.format(status=response.status_code, raise Exception('Unable to retrieve course modes: [{status}] - {body}'.format(
body=response.content)) status=response.status_code,
body=response.content
))
data = response.json() data = response.json()
logger.debug(data) logger.debug(data)
modes = data['course_modes'] return data['course_modes']
def _retrieve_data_from_lms(self, access_token):
"""
Retrieves the course name and modes from the LMS.
"""
headers = {
'Accept': 'application/json',
'Authorization': 'Bearer ' + access_token
}
course_name, course_verification_deadline = self._query_commerce_api(headers)
modes = self._query_enrollment_api(headers)
return course_name, modes return course_name, course_verification_deadline, modes
def _get_products(self, modes): def _get_products(self, modes):
""" Creates/updates course seat products. """ """ Creates/updates course seat products. """
...@@ -77,7 +105,7 @@ class MigratedCourse(object): ...@@ -77,7 +105,7 @@ class MigratedCourse(object):
id_verification_required = Course.is_mode_verified(mode['slug']) id_verification_required = Course.is_mode_verified(mode['slug'])
price = mode['min_price'] price = mode['min_price']
expires = mode.get('expiration_datetime') expires = mode.get('expiration_datetime')
expires = dateutil.parser.parse(expires) if expires else None expires = parse(expires) if expires else None
self.course.create_or_update_seat(certificate_type, id_verification_required, price, expires=expires) self.course.create_or_update_seat(certificate_type, id_verification_required, price, expires=expires)
......
...@@ -36,6 +36,8 @@ StockRecord = get_model('partner', 'StockRecord') ...@@ -36,6 +36,8 @@ StockRecord = get_model('partner', 'StockRecord')
class CourseMigrationTestMixin(CourseCatalogTestMixin): class CourseMigrationTestMixin(CourseCatalogTestMixin):
course_id = 'aaa/bbb/ccc' course_id = 'aaa/bbb/ccc'
commerce_api_url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), course_id)
enrollment_api_url = urljoin(settings.LMS_URL_ROOT, 'api/enrollment/v1/course/{}'.format(course_id))
prices = { prices = {
'honor': 0, 'honor': 0,
...@@ -47,18 +49,20 @@ class CourseMigrationTestMixin(CourseCatalogTestMixin): ...@@ -47,18 +49,20 @@ class CourseMigrationTestMixin(CourseCatalogTestMixin):
def _mock_lms_api(self): def _mock_lms_api(self):
self.assertTrue(httpretty.is_enabled, 'httpretty must be enabled to mock LMS API calls.') self.assertTrue(httpretty.is_enabled, 'httpretty must be enabled to mock LMS API calls.')
# Mock Course Structure API # Mock Commerce API
url = urljoin(settings.LMS_URL_ROOT, 'api/course_structure/v0/courses/{}/'.format(self.course_id)) body = {
httpretty.register_uri(httpretty.GET, url, body='{"name": "A Tést Côurse"}', content_type=JSON) 'name': 'A Tést Côurse',
'verification_deadline': EXPIRES_STRING,
}
httpretty.register_uri(httpretty.GET, self.commerce_api_url, body=json.dumps(body), content_type=JSON)
# Mock Enrollment API # Mock Enrollment API
url = urljoin(settings.LMS_URL_ROOT, 'api/enrollment/v1/course/{}'.format(self.course_id))
body = { body = {
'course_id': self.course_id, 'course_id': self.course_id,
'course_modes': [{'slug': seat_type, 'min_price': price, 'expiration_datetime': EXPIRES_STRING} for 'course_modes': [{'slug': seat_type, 'min_price': price, 'expiration_datetime': EXPIRES_STRING} for
seat_type, price in self.prices.iteritems()] seat_type, price in self.prices.iteritems()]
} }
httpretty.register_uri(httpretty.GET, url, body=json.dumps(body), content_type=JSON) httpretty.register_uri(httpretty.GET, self.enrollment_api_url, body=json.dumps(body), content_type=JSON)
def assert_stock_record_valid(self, stock_record, seat, price): def assert_stock_record_valid(self, stock_record, seat, price):
""" Verify the given StockRecord is configured correctly. """ """ Verify the given StockRecord is configured correctly. """
...@@ -67,9 +71,9 @@ class CourseMigrationTestMixin(CourseCatalogTestMixin): ...@@ -67,9 +71,9 @@ class CourseMigrationTestMixin(CourseCatalogTestMixin):
self.assertEqual(stock_record.price_currency, 'USD') self.assertEqual(stock_record.price_currency, 'USD')
self.assertEqual(stock_record.partner_sku, generate_sku(seat)) self.assertEqual(stock_record.partner_sku, generate_sku(seat))
def assert_seat_valid(self, seat, certificate_type): def assert_seat_valid(self, seat, mode):
""" Verify the given seat is configured correctly. """ """ Verify the given seat is configured correctly. """
certificate_type = Course.certificate_type_for_mode(certificate_type) certificate_type = Course.certificate_type_for_mode(mode)
expected_title = 'Seat in A Tést Côurse with {} certificate'.format(certificate_type) expected_title = 'Seat in A Tést Côurse with {} certificate'.format(certificate_type)
if seat.attr.id_verification_required: if seat.attr.id_verification_required:
...@@ -78,9 +82,8 @@ class CourseMigrationTestMixin(CourseCatalogTestMixin): ...@@ -78,9 +82,8 @@ class CourseMigrationTestMixin(CourseCatalogTestMixin):
self.assertEqual(seat.title, expected_title) self.assertEqual(seat.title, expected_title)
self.assertEqual(seat.attr.certificate_type, certificate_type) self.assertEqual(seat.attr.certificate_type, certificate_type)
self.assertEqual(seat.expires, EXPIRES) self.assertEqual(seat.expires, EXPIRES)
self.assertEqual(seat.attr.certificate_type, certificate_type)
self.assertEqual(seat.attr.course_key, self.course_id) self.assertEqual(seat.attr.course_key, self.course_id)
# self.assertEqual(seat.attr.id_verification_required, Course.is_mode_verified(certificate_type)) self.assertEqual(seat.attr.id_verification_required, Course.is_mode_verified(mode))
def assert_course_migrated(self): def assert_course_migrated(self):
""" Verify the course was migrated and saved to the database. """ """ Verify the course was migrated and saved to the database. """
...@@ -134,6 +137,7 @@ class MigratedCourseTests(CourseMigrationTestMixin, TestCase): ...@@ -134,6 +137,7 @@ class MigratedCourseTests(CourseMigrationTestMixin, TestCase):
# Verify created objects match mocked data # Verify created objects match mocked data
parent_seat = course.parent_seat_product parent_seat = course.parent_seat_product
self.assertEqual(parent_seat.title, 'Seat in A Tést Côurse') self.assertEqual(parent_seat.title, 'Seat in A Tést Côurse')
self.assertEqual(course.verification_deadline, EXPIRES)
for seat in course.seat_products: for seat in course.seat_products:
certificate_type = seat.attr.certificate_type certificate_type = seat.attr.certificate_type
...@@ -142,6 +146,18 @@ class MigratedCourseTests(CourseMigrationTestMixin, TestCase): ...@@ -142,6 +146,18 @@ class MigratedCourseTests(CourseMigrationTestMixin, TestCase):
logger.info('Validating objects for %s certificate type...', certificate_type) logger.info('Validating objects for %s certificate type...', certificate_type)
self.assert_stock_record_valid(seat.stockrecords.first(), seat, Decimal(self.prices[certificate_type])) self.assert_stock_record_valid(seat.stockrecords.first(), seat, Decimal(self.prices[certificate_type]))
@httpretty.activate
def test_course_name_missing(self):
"""Verify that an exception is raised when the Commerce API doesn't return a course name."""
body = {
'name': None,
'verification_deadline': EXPIRES_STRING,
}
httpretty.register_uri(httpretty.GET, self.commerce_api_url, body=json.dumps(body), content_type=JSON)
migrated_course = MigratedCourse(self.course_id)
self.assertRaises(Exception, migrated_course.load_from_lms, ACCESS_TOKEN)
class CommandTests(CourseMigrationTestMixin, TestCase): class CommandTests(CourseMigrationTestMixin, TestCase):
def setUp(self): def setUp(self):
......
...@@ -68,6 +68,8 @@ LMS_HEARTBEAT_URL = get_lms_url('/heartbeat') ...@@ -68,6 +68,8 @@ LMS_HEARTBEAT_URL = get_lms_url('/heartbeat')
# The location of the LMS student dashboard # The location of the LMS student dashboard
LMS_DASHBOARD_URL = get_lms_url('/dashboard') LMS_DASHBOARD_URL = get_lms_url('/dashboard')
COMMERCE_API_URL = get_lms_url('/api/commerce/v1/')
# END URL CONFIGURATION # END URL CONFIGURATION
......
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