Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
ecommerce
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
ecommerce
Commits
67d7d888
Commit
67d7d888
authored
Jul 31, 2015
by
Renzo Lucioni
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #231 from edx/renzo/atomic-publication
Atomic Course publication
parents
2849067f
5c04d71c
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
406 additions
and
15 deletions
+406
-15
ecommerce/core/constants.py
+6
-1
ecommerce/courses/urls.py
+3
-2
ecommerce/credit/urls.py
+2
-2
ecommerce/extensions/api/serializers.py
+113
-2
ecommerce/extensions/api/v2/tests/views/test_publication.py
+228
-0
ecommerce/extensions/api/v2/urls.py
+14
-0
ecommerce/extensions/api/v2/views.py
+38
-5
ecommerce/settings/base.py
+2
-3
No files found.
ecommerce/core/constants.py
View file @
67d7d888
"""
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"
...
...
ecommerce/courses/urls.py
View file @
67d7d888
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'
),
)
)
ecommerce/credit/urls.py
View file @
67d7d888
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'
),
)
)
ecommerce/extensions/api/serializers.py
View file @
67d7d888
"""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
}
ecommerce/extensions/api/v2/tests/views/test_publication.py
0 → 100644
View file @
67d7d888
This diff is collapsed.
Click to expand it.
ecommerce/extensions/api/v2/urls.py
View file @
67d7d888
...
@@ -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
()
...
...
ecommerce/extensions/api/v2/views.py
View file @
67d7d888
...
@@ -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
)
ecommerce/settings/base.py
View file @
67d7d888
...
@@ -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'
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment