Commit 67d7d888 by Renzo Lucioni

Merge pull request #231 from edx/renzo/atomic-publication

Atomic Course publication
parents 2849067f 5c04d71c
"""Health check constants.""" """Constants core to the ecommerce app."""
ISO_8601_FORMAT = u'%Y-%m-%dT%H:%M:%SZ' ISO_8601_FORMAT = u'%Y-%m-%dT%H:%M:%SZ'
# Regex used to match course IDs.
COURSE_ID_REGEX = r'[^/+]+(/|\+)[^/+]+(/|\+)[^/]+'
COURSE_ID_PATTERN = r'(?P<course_id>{})'.format(COURSE_ID_REGEX)
class Status(object): class Status(object):
"""Health statuses.""" """Health statuses."""
OK = u"OK" OK = u"OK"
......
from django.conf import settings
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
from ecommerce.core.constants import COURSE_ID_PATTERN
from ecommerce.courses import views from ecommerce.courses import views
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url(r'^$', views.CourseListView.as_view(), name='list'), url(r'^$', views.CourseListView.as_view(), name='list'),
url(r'^migrate/$', views.CourseMigrationView.as_view(), name='migrate'), url(r'^migrate/$', views.CourseMigrationView.as_view(), name='migrate'),
url(r'^{}/$'.format(settings.COURSE_ID_PATTERN), views.CourseDetailView.as_view(), name='detail'), url(r'^{}/$'.format(COURSE_ID_PATTERN), views.CourseDetailView.as_view(), name='detail'),
) )
from django.conf import settings
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
from ecommerce.core.constants import COURSE_ID_PATTERN
from ecommerce.credit.views import Checkout from ecommerce.credit.views import Checkout
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url(r'^checkout/{course}/$'.format(course=settings.COURSE_ID_PATTERN), url(r'^checkout/{course}/$'.format(course=COURSE_ID_PATTERN),
Checkout.as_view(), name='checkout'), Checkout.as_view(), name='checkout'),
) )
"""Serializers for order and line item data.""" """Serializers for data manipulated by ecommerce API endpoints."""
from decimal import Decimal
import logging
from dateutil.parser import parse
from django.db import transaction
from django.utils.translation import ugettext_lazy as _
from oscar.core.loading import get_model, get_class from oscar.core.loading import get_model, get_class
from rest_framework import serializers from rest_framework import serializers
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
import waffle
from ecommerce.core.constants import ISO_8601_FORMAT from ecommerce.core.constants import ISO_8601_FORMAT, COURSE_ID_REGEX
from ecommerce.courses.models import Course from ecommerce.courses.models import Course
logger = logging.getLogger(__name__)
BillingAddress = get_model('order', 'BillingAddress') BillingAddress = get_model('order', 'BillingAddress')
Line = get_model('order', 'Line') Line = get_model('order', 'Line')
Order = get_model('order', 'Order') Order = get_model('order', 'Order')
...@@ -113,6 +122,7 @@ class RefundSerializer(serializers.ModelSerializer): ...@@ -113,6 +122,7 @@ class RefundSerializer(serializers.ModelSerializer):
class CourseSerializer(serializers.HyperlinkedModelSerializer): class CourseSerializer(serializers.HyperlinkedModelSerializer):
id = serializers.RegexField(COURSE_ID_REGEX, max_length=255)
products_url = serializers.SerializerMethodField() products_url = serializers.SerializerMethodField()
last_edited = serializers.SerializerMethodField() last_edited = serializers.SerializerMethodField()
...@@ -130,3 +140,104 @@ class CourseSerializer(serializers.HyperlinkedModelSerializer): ...@@ -130,3 +140,104 @@ class CourseSerializer(serializers.HyperlinkedModelSerializer):
extra_kwargs = { extra_kwargs = {
'url': {'view_name': COURSE_DETAIL_VIEW} 'url': {'view_name': COURSE_DETAIL_VIEW}
} }
class AtomicPublicationSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializer for saving and publishing a Course and associated products.
Using a ModelSerializer for the Course data makes it difficult to use this serializer to handle updates.
The automatically applied validation logic rejects course IDs which already exist in the database.
"""
id = serializers.RegexField(COURSE_ID_REGEX, max_length=255)
name = serializers.CharField(max_length=255)
products = serializers.ListField()
def validate_products(self, products):
"""Validate product data."""
for product in products:
# Verify that each product is intended to be a Seat.
product_class = product.get('product_class')
if product_class != 'Seat':
raise serializers.ValidationError(
_(u"Invalid product class [{product_class}] requested.".format(product_class=product_class))
)
# Verify that attributes required to create a Seat are present.
attrs = self._flatten(product['attribute_values'])
if attrs.get('certificate_type') is None:
raise serializers.ValidationError(_(u"Products must have a certificate type."))
elif attrs.get('id_verification_required') is None:
raise serializers.ValidationError(_(u"Products must indicate whether ID verification is required."))
# Verify that a price is present.
if product.get('price') is None:
raise serializers.ValidationError(_(u"Products must have a price."))
return products
def save(self):
"""Save and publish Course and associated products."
Returns:
tuple: A Boolean indicating whether the Course was created, an Exception,
if one was raised (else None), and a message for the user, if necessary (else None).
"""
course_id = self.validated_data['id']
course_name = self.validated_data['name']
products = self.validated_data['products']
try:
# Explicitly delimit operations which will be rolled back if an exception is raised.
with transaction.atomic():
course, created = Course.objects.get_or_create(id=course_id, defaults={'name': course_name})
for product in products:
attrs = self._flatten(product['attribute_values'])
# Extract arguments required for Seat creation, deserializing as necessary.
certificate_type = attrs['certificate_type']
id_verification_required = (attrs['id_verification_required'] == 'True')
price = Decimal(product['price'])
# Extract arguments which are optional for Seat creation, deserializing as necessary.
expires = product.get('expires')
expires = parse(expires) if expires else None
credit_provider = attrs.get('credit_provider')
credit_hours = attrs.get('credit_hours')
credit_hours = int(credit_hours) if credit_hours else None
course.create_or_update_seat(
certificate_type,
id_verification_required,
price,
expires=expires,
credit_provider=credit_provider,
credit_hours=credit_hours,
)
if waffle.switch_is_active('publish_course_modes_to_lms'):
published = course.publish_to_lms()
if published:
return created, None, None
else:
message = (
u'An error occurred while publishing [{course_id}] to LMS. '
u'No data has been saved or published.'
).format(course_id=course_id)
raise Exception(message)
else:
message = (
u'Course [{course_id}] was not published to LMS '
u'because the switch [publish_course_modes_to_lms] is disabled. '
u'Data has been saved, but not published.'
).format(course_id=course_id)
logger.info(message)
return created, None, message
except Exception as e: # pylint: disable=broad-except
logger.exception(u'Failed to save and publish [%s]: [%s]', course_id, e.message)
return False, e, e.message
def _flatten(self, attrs):
"""Transform a list of attribute names and values into a dictionary keyed on the names."""
return {attr['name']: attr['value'] for attr in attrs}
...@@ -2,11 +2,14 @@ from django.conf.urls import patterns, url, include ...@@ -2,11 +2,14 @@ from django.conf.urls import patterns, url, include
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from rest_framework_extensions.routers import ExtendedSimpleRouter from rest_framework_extensions.routers import ExtendedSimpleRouter
from ecommerce.core.constants import COURSE_ID_PATTERN
from ecommerce.extensions.api.v2 import views from ecommerce.extensions.api.v2 import views
ORDER_NUMBER_PATTERN = r'(?P<number>[-\w]+)' ORDER_NUMBER_PATTERN = r'(?P<number>[-\w]+)'
BASKET_ID_PATTERN = r'(?P<basket_id>[\w]+)' BASKET_ID_PATTERN = r'(?P<basket_id>[\w]+)'
BASKET_URLS = patterns( BASKET_URLS = patterns(
'', '',
url(r'^$', views.BasketCreateView.as_view(), name='create'), url(r'^$', views.BasketCreateView.as_view(), name='create'),
...@@ -43,12 +46,23 @@ REFUND_URLS = patterns( ...@@ -43,12 +46,23 @@ REFUND_URLS = patterns(
url(r'^(?P<pk>[\d]+)/process/$', views.RefundProcessView.as_view(), name='process'), url(r'^(?P<pk>[\d]+)/process/$', views.RefundProcessView.as_view(), name='process'),
) )
ATOMIC_PUBLICATION_URLS = patterns(
'',
url(r'^$', views.AtomicPublicationView.as_view(), name='create'),
url(
r'^{course_id}$'.format(course_id=COURSE_ID_PATTERN),
views.AtomicPublicationView.as_view(),
name='update'
),
)
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url(r'^baskets/', include(BASKET_URLS, namespace='baskets')), url(r'^baskets/', include(BASKET_URLS, namespace='baskets')),
url(r'^orders/', include(ORDER_URLS, namespace='orders')), url(r'^orders/', include(ORDER_URLS, namespace='orders')),
url(r'^payment/', include(PAYMENT_URLS, namespace='payment')), url(r'^payment/', include(PAYMENT_URLS, namespace='payment')),
url(r'^refunds/', include(REFUND_URLS, namespace='refunds')), url(r'^refunds/', include(REFUND_URLS, namespace='refunds')),
url(r'^publication/', include(ATOMIC_PUBLICATION_URLS, namespace='publication')),
) )
router = ExtendedSimpleRouter() router = ExtendedSimpleRouter()
......
...@@ -12,9 +12,10 @@ from rest_framework.response import Response ...@@ -12,9 +12,10 @@ from rest_framework.response import Response
from rest_framework_extensions.mixins import NestedViewSetMixin from rest_framework_extensions.mixins import NestedViewSetMixin
import waffle import waffle
from ecommerce.core.constants import COURSE_ID_REGEX
from ecommerce.courses.models import Course from ecommerce.courses.models import Course
from ecommerce.extensions.analytics.utils import audit_log from ecommerce.extensions.analytics.utils import audit_log
from ecommerce.extensions.api import data, exceptions as api_exceptions, serializers from ecommerce.extensions.api import data as data_api, exceptions as api_exceptions, serializers
from ecommerce.extensions.api.constants import APIConstants as AC from ecommerce.extensions.api.constants import APIConstants as AC
from ecommerce.extensions.api.exceptions import BadRequestException from ecommerce.extensions.api.exceptions import BadRequestException
from ecommerce.extensions.api.permissions import CanActForUser from ecommerce.extensions.api.permissions import CanActForUser
...@@ -25,6 +26,7 @@ from ecommerce.extensions.payment.helpers import (get_processor_class, get_defau ...@@ -25,6 +26,7 @@ from ecommerce.extensions.payment.helpers import (get_processor_class, get_defau
get_processor_class_by_name) get_processor_class_by_name)
from ecommerce.extensions.refund.api import find_orders_associated_with_course, create_refunds from ecommerce.extensions.refund.api import find_orders_associated_with_course, create_refunds
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Order = get_model('order', 'Order') Order = get_model('order', 'Order')
...@@ -125,7 +127,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView): ...@@ -125,7 +127,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView):
} }
} }
""" """
basket = data.get_basket(request.user) basket = data_api.get_basket(request.user)
requested_products = request.data.get(AC.KEYS.PRODUCTS) requested_products = request.data.get(AC.KEYS.PRODUCTS)
if requested_products: if requested_products:
...@@ -134,7 +136,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView): ...@@ -134,7 +136,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView):
sku = requested_product.get(AC.KEYS.SKU) sku = requested_product.get(AC.KEYS.SKU)
if sku: if sku:
try: try:
product = data.get_product(sku) product = data_api.get_product(sku)
except api_exceptions.ProductNotFoundError as error: except api_exceptions.ProductNotFoundError as error:
return self._report_bad_request(error.message, api_exceptions.PRODUCT_NOT_FOUND_USER_MESSAGE) return self._report_bad_request(error.message, api_exceptions.PRODUCT_NOT_FOUND_USER_MESSAGE)
else: else:
...@@ -220,7 +222,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView): ...@@ -220,7 +222,7 @@ class BasketCreateView(EdxOrderPlacementMixin, generics.CreateAPIView):
response_data = self._generate_basic_response(basket) response_data = self._generate_basic_response(basket)
if basket.total_excl_tax == AC.FREE: if basket.total_excl_tax == AC.FREE:
order_metadata = data.get_order_metadata(basket) order_metadata = data_api.get_order_metadata(basket)
logger.info( logger.info(
u"Preparing to place order [%s] for the contents of basket [%d]", u"Preparing to place order [%s] for the contents of basket [%d]",
...@@ -493,7 +495,7 @@ class NonDestroyableModelViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixi ...@@ -493,7 +495,7 @@ class NonDestroyableModelViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixi
class CourseViewSet(NonDestroyableModelViewSet): class CourseViewSet(NonDestroyableModelViewSet):
lookup_value_regex = settings.COURSE_ID_REGEX lookup_value_regex = COURSE_ID_REGEX
queryset = Course.objects.all() queryset = Course.objects.all()
serializer_class = serializers.CourseSerializer serializer_class = serializers.CourseSerializer
permission_classes = (IsAuthenticated, IsAdminUser,) permission_classes = (IsAuthenticated, IsAdminUser,)
...@@ -521,3 +523,34 @@ class ProductViewSet(NestedViewSetMixin, NonDestroyableModelViewSet): ...@@ -521,3 +523,34 @@ class ProductViewSet(NestedViewSetMixin, NonDestroyableModelViewSet):
queryset = Product.objects.all() queryset = Product.objects.all()
serializer_class = serializers.ProductSerializer serializer_class = serializers.ProductSerializer
permission_classes = (IsAuthenticated, IsAdminUser,) permission_classes = (IsAuthenticated, IsAdminUser,)
class AtomicPublicationView(generics.CreateAPIView, generics.UpdateAPIView):
"""Attempt to save and publish a Course and associated products.
If either fails, the entire operation is rolled back. This keeps Otto and the LMS in sync.
"""
permission_classes = (IsAuthenticated, IsAdminUser,)
serializer_class = serializers.AtomicPublicationSerializer
def post(self, request, *args, **kwargs):
return self._save_and_publish(request.data)
def put(self, request, *args, **kwargs):
return self._save_and_publish(request.data, course_id=kwargs['course_id'])
def _save_and_publish(self, data, course_id=None):
"""Create or update a Course and associated products, then publish the result."""
if course_id is not None:
data['id'] = course_id
serializer = self.get_serializer(data=data)
is_valid = serializer.is_valid(raise_exception=True)
if is_valid:
created, failure, message = serializer.save()
if failure:
return Response({'error': message}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
content = serializer.data
content['message'] = message if message else None
return Response(content, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
...@@ -409,9 +409,11 @@ REST_FRAMEWORK = { ...@@ -409,9 +409,11 @@ REST_FRAMEWORK = {
} }
# END DJANGO REST FRAMEWORK # END DJANGO REST FRAMEWORK
# Resolving deprecation warning # Resolving deprecation warning
TEST_RUNNER = 'django.test.runner.DiscoverRunner' TEST_RUNNER = 'django.test.runner.DiscoverRunner'
# COOKIE CONFIGURATION # COOKIE CONFIGURATION
# The purpose of customizing the cookie names is to avoid conflicts when # The purpose of customizing the cookie names is to avoid conflicts when
# multiple Django services are running behind the same hostname. # multiple Django services are running behind the same hostname.
...@@ -421,9 +423,6 @@ CSRF_COOKIE_NAME = 'ecommerce_csrftoken' ...@@ -421,9 +423,6 @@ CSRF_COOKIE_NAME = 'ecommerce_csrftoken'
LANGUAGE_COOKIE_NAME = 'ecommerce_language' LANGUAGE_COOKIE_NAME = 'ecommerce_language'
# END COOKIE CONFIGURATION # END COOKIE CONFIGURATION
# Standard regex for course_id.
COURSE_ID_REGEX = r'[^/+]+(/|\+)[^/+]+(/|\+)[^/]+'
COURSE_ID_PATTERN = r'(?P<course_id>{})'.format(COURSE_ID_REGEX)
PLATFORM_NAME = 'Your Platform Name Here' PLATFORM_NAME = 'Your Platform Name Here'
THEME_SCSS = 'sass/themes/default.scss' THEME_SCSS = 'sass/themes/default.scss'
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