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
from django.db import models, transaction
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
from oscar.core.loading import get_model
from simple_history.models import HistoricalRecords
......@@ -21,6 +22,11 @@ StockRecord = get_model('partner', 'StockRecord')
class Course(models.Model):
id = models.CharField(null=False, max_length=255, primary_key=True, verbose_name='ID')
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()
thumbnail_url = models.URLField(null=True, blank=True)
......
......@@ -16,6 +16,9 @@ class LMSPublisher(object):
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):
""" Serializes a course seat product to a dict that can be further serialized to JSON. """
stock_record = seat.stockrecords.first()
......@@ -43,11 +46,15 @@ class LMSPublisher(object):
logger.error('COMMERCE_API_URL is not set. Commerce data will not be published!')
return False
modes = [self.serialize_seat_for_commerce_api(seat) for seat in course.seat_products]
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 = {
'id': course_id,
'modes': modes
'name': name,
'verification_deadline': verification_deadline,
'modes': modes,
}
url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), course_id)
......
......@@ -89,6 +89,8 @@ class LMSPublisherTests(CourseCatalogTestMixin, TestCase):
actual = json.loads(last_request.body)
expected = {
'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]
}
self.assertDictEqual(actual, expected)
......
......@@ -135,7 +135,7 @@ class CourseSerializer(serializers.HyperlinkedModelSerializer):
class Meta(object):
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',)
extra_kwargs = {
'url': {'view_name': COURSE_DETAIL_VIEW}
......@@ -150,6 +150,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab
"""
id = serializers.RegexField(COURSE_ID_REGEX, max_length=255)
name = serializers.CharField(max_length=255)
verification_deadline = serializers.DateTimeField()
products = serializers.ListField()
def validate_products(self, products):
......@@ -184,6 +185,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab
"""
course_id = self.validated_data['id']
course_name = self.validated_data['name']
course_verification_deadline = self.validated_data['verification_deadline']
products = self.validated_data['products']
try:
......@@ -200,6 +202,7 @@ class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=ab
with transaction.atomic():
course, created = Course.objects.get_or_create(id=course_id)
course.name = course_name
course.verification_deadline = course_verification_deadline
course.save()
for product in products:
......
......@@ -42,6 +42,7 @@ class CourseViewSetTests(TestServerUrlMixin, CourseCatalogTestMixin, UserMixin,
return {
'id': course.id,
'name': course.name,
'verification_deadline': course.verification_deadline,
'type': course.type,
'url': self.get_full_url(reverse('api:v2:course-detail', kwargs={'pk': course.id})),
'products_url': products_url,
......
......@@ -32,6 +32,7 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase):
self.data = {
'id': self.course_id,
'name': self.course_name,
'verification_deadline': EXPIRES_STRING,
'products': [
{
'product_class': 'Seat',
......@@ -82,7 +83,11 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase):
if recreate:
# 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.
for product in self.data['products']:
......@@ -120,6 +125,9 @@ class AtomicPublicationTests(CourseCatalogTestMixin, UserMixin, TestCase):
course = Course.objects.get(id=course_id)
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.
products = expected['products']
expected_child_products = len(products)
......
......@@ -2,7 +2,7 @@ from __future__ import unicode_literals
import logging
from optparse import make_option
import dateutil.parser
from dateutil.parser import parse
from django.conf import settings
from django.core.management import BaseCommand
from django.db import transaction
......@@ -24,9 +24,12 @@ class MigratedCourse(object):
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.verification_deadline = verification_deadline
self.course.save()
self._get_products(modes)
def _build_lms_url(self, path):
......@@ -35,40 +38,65 @@ class MigratedCourse(object):
host = settings.LMS_URL_ROOT.strip('/')
return '{host}/{path}'.format(host=host, path=path)
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
}
def _query_commerce_api(self, headers):
"""Get course name and verification deadline from the Commerce API."""
if not settings.COMMERCE_API_URL:
message = 'Aborting migration. COMMERCE_API_URL is not set.'
logger.error(message)
raise Exception(message)
# Get course name from Course Structure API
url = self._build_lms_url('api/course_structure/v0/courses/{}/'.format(self.course.id))
response = requests.get(url, headers=headers)
url = '{}/courses/{}/'.format(settings.COMMERCE_API_URL.rstrip('/'), self.course.id)
timeout = settings.COMMERCE_API_TIMEOUT
response = requests.get(url, headers=headers, timeout=timeout)
if response.status_code != 200:
raise Exception('Unable to retrieve course name: [{status}] - {body}'.format(status=response.status_code,
body=response.content))
raise Exception('Unable to retrieve course name and verification deadline: [{status}] - {body}'.format(
status=response.status_code,
body=response.content
))
data = response.json()
logger.debug(data)
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))
response = requests.get(url, headers=headers)
if response.status_code != 200:
raise Exception('Unable to retrieve course modes: [{status}] - {body}'.format(status=response.status_code,
body=response.content))
raise Exception('Unable to retrieve course modes: [{status}] - {body}'.format(
status=response.status_code,
body=response.content
))
data = response.json()
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):
""" Creates/updates course seat products. """
......@@ -77,7 +105,7 @@ class MigratedCourse(object):
id_verification_required = Course.is_mode_verified(mode['slug'])
price = mode['min_price']
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)
......
......@@ -36,6 +36,8 @@ StockRecord = get_model('partner', 'StockRecord')
class CourseMigrationTestMixin(CourseCatalogTestMixin):
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 = {
'honor': 0,
......@@ -47,18 +49,20 @@ class CourseMigrationTestMixin(CourseCatalogTestMixin):
def _mock_lms_api(self):
self.assertTrue(httpretty.is_enabled, 'httpretty must be enabled to mock LMS API calls.')
# Mock Course Structure API
url = urljoin(settings.LMS_URL_ROOT, 'api/course_structure/v0/courses/{}/'.format(self.course_id))
httpretty.register_uri(httpretty.GET, url, body='{"name": "A Tést Côurse"}', content_type=JSON)
# Mock Commerce API
body = {
'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
url = urljoin(settings.LMS_URL_ROOT, 'api/enrollment/v1/course/{}'.format(self.course_id))
body = {
'course_id': self.course_id,
'course_modes': [{'slug': seat_type, 'min_price': price, 'expiration_datetime': EXPIRES_STRING} for
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):
""" Verify the given StockRecord is configured correctly. """
......@@ -67,9 +71,9 @@ class CourseMigrationTestMixin(CourseCatalogTestMixin):
self.assertEqual(stock_record.price_currency, 'USD')
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. """
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)
if seat.attr.id_verification_required:
......@@ -78,9 +82,8 @@ class CourseMigrationTestMixin(CourseCatalogTestMixin):
self.assertEqual(seat.title, expected_title)
self.assertEqual(seat.attr.certificate_type, certificate_type)
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.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):
""" Verify the course was migrated and saved to the database. """
......@@ -134,6 +137,7 @@ class MigratedCourseTests(CourseMigrationTestMixin, TestCase):
# Verify created objects match mocked data
parent_seat = course.parent_seat_product
self.assertEqual(parent_seat.title, 'Seat in A Tést Côurse')
self.assertEqual(course.verification_deadline, EXPIRES)
for seat in course.seat_products:
certificate_type = seat.attr.certificate_type
......@@ -142,6 +146,18 @@ class MigratedCourseTests(CourseMigrationTestMixin, TestCase):
logger.info('Validating objects for %s certificate type...', 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):
def setUp(self):
......
......@@ -68,6 +68,8 @@ LMS_HEARTBEAT_URL = get_lms_url('/heartbeat')
# The location of the LMS student dashboard
LMS_DASHBOARD_URL = get_lms_url('/dashboard')
COMMERCE_API_URL = get_lms_url('/api/commerce/v1/')
# 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