Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
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
edx-platform
Commits
bf164ae9
Commit
bf164ae9
authored
Jun 08, 2017
by
Marko Jevtic
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
[LEARNER-1183] Prepare program data to be presented on program marketing page
[LEARNER-1393] Filter program course runs by status
parent
9c4869c1
Show whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
281 additions
and
21 deletions
+281
-21
lms/djangoapps/courseware/views/views.py
+6
-1
openedx/core/djangoapps/catalog/tests/factories.py
+58
-4
openedx/core/djangoapps/programs/tests/test_utils.py
+153
-7
openedx/core/djangoapps/programs/utils.py
+64
-9
No files found.
lms/djangoapps/courseware/views/views.py
View file @
bf164ae9
...
...
@@ -801,8 +801,13 @@ def program_marketing(request, program_uuid):
if
not
program_data
:
raise
Http404
program
=
ProgramMarketingDataExtender
(
program_data
,
request
.
user
)
.
extend
()
skus
=
program
.
get
(
'skus'
)
ecommerce_service
=
EcommerceService
()
return
render_to_response
(
'courseware/program_marketing.html'
,
{
'program'
:
ProgramMarketingDataExtender
(
program_data
,
request
.
user
)
.
extend
()
'buy_button_href'
:
ecommerce_service
.
get_checkout_page_url
(
*
skus
)
if
skus
else
'#courses'
,
'program'
:
program
,
})
...
...
openedx/core/djangoapps/catalog/tests/factories.py
View file @
bf164ae9
...
...
@@ -3,6 +3,7 @@
from
functools
import
partial
import
factory
import
uuid
from
faker
import
Faker
...
...
@@ -34,6 +35,19 @@ def generate_zulu_datetime():
return
fake
.
date_time
()
.
isoformat
()
+
'Z'
def
generate_price_ranges
():
return
[{
'currency'
:
'USD'
,
'max'
:
1000
,
'min'
:
100
,
'total'
:
500
}]
def
generate_seat_sku
():
return
uuid
.
uuid4
()
.
hex
[:
7
]
.
upper
()
class
DictFactoryBase
(
factory
.
Factory
):
"""
Subclass this to make factories that can be used to produce fake API response
...
...
@@ -77,12 +91,15 @@ class OrganizationFactory(DictFactoryBase):
key
=
factory
.
Faker
(
'word'
)
name
=
factory
.
Faker
(
'company'
)
uuid
=
factory
.
Faker
(
'uuid4'
)
logo_image_url
=
factory
.
Faker
(
'image_url'
)
class
SeatFactory
(
DictFactoryBase
):
type
=
factory
.
Faker
(
'word'
)
price
=
factory
.
Faker
(
'random_int'
)
currency
=
'USD'
price
=
factory
.
Faker
(
'random_int'
)
sku
=
factory
.
LazyFunction
(
generate_seat_sku
)
type
=
'verified'
upgrade_deadline
=
factory
.
LazyFunction
(
generate_zulu_datetime
)
class
CourseRunFactory
(
DictFactoryBase
):
...
...
@@ -91,13 +108,13 @@ class CourseRunFactory(DictFactoryBase):
enrollment_end
=
factory
.
LazyFunction
(
generate_zulu_datetime
)
enrollment_start
=
factory
.
LazyFunction
(
generate_zulu_datetime
)
image
=
ImageFactory
()
is_enrolled
=
False
key
=
factory
.
LazyFunction
(
generate_course_run_key
)
marketing_url
=
factory
.
Faker
(
'url'
)
pacing_type
=
'self_paced'
seats
=
factory
.
LazyFunction
(
partial
(
generate_instances
,
SeatFactory
))
short_description
=
factory
.
Faker
(
'sentence'
)
start
=
factory
.
LazyFunction
(
generate_zulu_datetime
)
status
=
'published'
title
=
factory
.
Faker
(
'catch_phrase'
)
type
=
'verified'
uuid
=
factory
.
Faker
(
'uuid4'
)
...
...
@@ -112,20 +129,57 @@ class CourseFactory(DictFactoryBase):
uuid
=
factory
.
Faker
(
'uuid4'
)
class
JobOutlookItemFactory
(
DictFactoryBase
):
value
=
factory
.
Faker
(
'sentence'
)
class
PersonFactory
(
DictFactoryBase
):
bio
=
factory
.
Faker
(
'paragraphs'
)
given_name
=
factory
.
Faker
(
'first_name'
)
family_name
=
factory
.
Faker
(
'last_name'
)
profile_image_url
=
factory
.
Faker
(
'image_url'
)
uuid
=
factory
.
Faker
(
'uuid4'
)
class
EndorserFactory
(
DictFactoryBase
):
person
=
PersonFactory
()
quote
=
factory
.
Faker
(
'sentence'
)
class
ExpectedLearningItemFactory
(
DictFactoryBase
):
value
=
factory
.
Faker
(
'sentence'
)
class
FAQFactory
(
DictFactoryBase
):
answer
=
factory
.
Faker
(
'sentence'
)
question
=
factory
.
Faker
(
'sentence'
)
class
ProgramFactory
(
DictFactoryBase
):
authoring_organizations
=
factory
.
LazyFunction
(
partial
(
generate_instances
,
OrganizationFactory
,
count
=
1
))
applicable_seat_types
=
[]
banner_image
=
factory
.
LazyFunction
(
generate_sized_stdimage
)
card_image_url
=
factory
.
Faker
(
'image_url'
)
courses
=
factory
.
LazyFunction
(
partial
(
generate_instances
,
CourseFactory
))
expected_learning_items
=
factory
.
LazyFunction
(
partial
(
generate_instances
,
CourseFactory
))
faq
=
factory
.
LazyFunction
(
partial
(
generate_instances
,
FAQFactory
))
hidden
=
False
individual_endorsements
=
factory
.
LazyFunction
(
partial
(
generate_instances
,
EndorserFactory
))
is_program_eligible_for_one_click_purchase
=
True
job_outlook_items
=
factory
.
LazyFunction
(
partial
(
generate_instances
,
JobOutlookItemFactory
))
marketing_slug
=
factory
.
Faker
(
'slug'
)
marketing_url
=
factory
.
Faker
(
'url'
)
max_hours_effort_per_week
=
fake
.
random_int
(
21
,
28
)
min_hours_effort_per_week
=
fake
.
random_int
(
7
,
14
)
overview
=
factory
.
Faker
(
'sentence'
)
price_ranges
=
factory
.
LazyFunction
(
generate_price_ranges
)
staff
=
factory
.
LazyFunction
(
partial
(
generate_instances
,
PersonFactory
))
status
=
'active'
subtitle
=
factory
.
Faker
(
'sentence'
)
title
=
factory
.
Faker
(
'catch_phrase'
)
type
=
factory
.
Faker
(
'word'
)
uuid
=
factory
.
Faker
(
'uuid4'
)
hidden
=
False
weeks_to_complete
=
fake
.
random_int
(
1
,
45
)
class
ProgramTypeFactory
(
DictFactoryBase
):
...
...
openedx/core/djangoapps/programs/tests/test_utils.py
View file @
bf164ae9
"""Tests covering Programs utilities."""
# pylint: disable=no-member
import
datetime
import
json
import
uuid
import
ddt
import
httpretty
from
django.conf
import
settings
from
django.core.urlresolvers
import
reverse
from
django.test
import
TestCase
from
django.test.utils
import
override_settings
...
...
@@ -14,6 +17,7 @@ from pytz import utc
from
course_modes.models
import
CourseMode
from
lms.djangoapps.certificates.api
import
MODES
from
lms.djangoapps.commerce.tests.test_utils
import
update_commerce_config
from
lms.djangoapps.commerce.utils
import
EcommerceService
from
openedx.core.djangoapps.catalog.tests.factories
import
(
generate_course_run_key
,
ProgramFactory
,
...
...
@@ -30,15 +34,15 @@ from openedx.core.djangoapps.programs.utils import (
get_certificates
,
)
from
openedx.core.djangolib.testing.utils
import
skip_unless_lms
from
student.tests.factories
import
UserFactory
,
CourseEnrollmentFactory
from
student.tests.factories
import
AnonymousUserFactory
,
UserFactory
,
CourseEnrollmentFactory
from
util.date_utils
import
strftime_localized
from
xmodule.modulestore.tests.django_utils
import
ModuleStoreTestCase
from
xmodule.modulestore.tests.factories
import
CourseFactory
as
ModuleStoreCourseFactory
UTILS_MODULE
=
'openedx.core.djangoapps.programs.utils'
CERTIFICATES_API_MODULE
=
'lms.djangoapps.certificates.api'
ECOMMERCE_URL_ROOT
=
'https://example-ecommerce.com'
ECOMMERCE_URL_ROOT
=
'https://ecommerce.example.com'
UTILS_MODULE
=
'openedx.core.djangoapps.programs.utils'
@ddt.ddt
...
...
@@ -809,6 +813,7 @@ class TestGetCertificates(TestCase):
@skip_unless_lms
class
TestProgramMarketingDataExtender
(
ModuleStoreTestCase
):
"""Tests of the program data extender utility class."""
ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT
=
'{root}/api/v2/baskets/calculate/'
.
format
(
root
=
ECOMMERCE_URL_ROOT
)
instructors
=
{
'instructors'
:
[
{
...
...
@@ -825,13 +830,16 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
def
setUp
(
self
):
super
(
TestProgramMarketingDataExtender
,
self
)
.
setUp
()
# Ensure the E-Commerce service user exists
UserFactory
(
username
=
settings
.
ECOMMERCE_SERVICE_WORKER_USERNAME
,
is_staff
=
True
)
self
.
course_price
=
100
self
.
number_of_courses
=
2
self
.
program
=
ProgramFactory
(
courses
=
[
self
.
_create_course
(
self
.
course_price
)
for
__
in
range
(
self
.
number_of_courses
)]
)
def
_create_course
(
self
,
course_price
,
is_enrolled
=
False
):
def
_create_course
(
self
,
course_price
):
"""
Creates the course in mongo and update it with the instructor data.
Also creates catalog course with respect to course run.
...
...
@@ -846,12 +854,24 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
course
=
self
.
update_course
(
course
,
self
.
user
.
id
)
course_run
=
CourseRunFactory
(
is_enrolled
=
is_enrolled
,
key
=
unicode
(
course
.
id
),
seats
=
[
SeatFactory
(
price
=
course_price
)]
)
return
CourseFactory
(
course_runs
=
[
course_run
])
def
_prepare_program_for_discounted_price_calculation_endpoint
(
self
):
"""
Program's applicable seat types should match some or all seat types of the seats that are a part of the program.
Otherwise, ecommerce API endpoint for calculating the discounted price won't be called.
Returns:
seat: seat for which the discount is applicable
"""
self
.
ecommerce_service
=
EcommerceService
()
seat
=
self
.
program
[
'courses'
][
0
][
'course_runs'
][
0
][
'seats'
][
0
]
self
.
program
[
'applicable_seat_types'
]
=
[
seat
[
'type'
]]
return
seat
def
test_instructors
(
self
):
data
=
ProgramMarketingDataExtender
(
self
.
program
,
self
.
user
)
.
extend
()
...
...
@@ -896,10 +916,136 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
data
=
ProgramMarketingDataExtender
(
program
,
self
.
user
)
.
extend
()
self
.
assertFalse
(
data
[
'is_learner_eligible_for_one_click_purchase'
])
courses
.
append
(
self
.
_create_course
(
self
.
course_price
,
is_enrolled
=
True
))
course
=
self
.
_create_course
(
self
.
course_price
)
CourseEnrollmentFactory
(
user
=
self
.
user
,
course_id
=
course
[
'course_runs'
][
0
][
'key'
])
program2
=
ProgramFactory
(
courses
=
courses
,
courses
=
[
course
]
,
is_program_eligible_for_one_click_purchase
=
True
)
data
=
ProgramMarketingDataExtender
(
program2
,
self
.
user
)
.
extend
()
self
.
assertFalse
(
data
[
'is_learner_eligible_for_one_click_purchase'
])
def
test_multiple_published_course_runs
(
self
):
"""
Learner should not be eligible for one click purchase if:
- program has a course with more than one published course run
"""
course_run_1
=
CourseRunFactory
(
key
=
str
(
ModuleStoreCourseFactory
()
.
id
),
status
=
'published'
)
course_run_2
=
CourseRunFactory
(
key
=
str
(
ModuleStoreCourseFactory
()
.
id
),
status
=
'published'
)
course
=
CourseFactory
(
course_runs
=
[
course_run_1
,
course_run_2
])
program
=
ProgramFactory
(
courses
=
[
CourseFactory
(
course_runs
=
[
CourseRunFactory
(
key
=
str
(
ModuleStoreCourseFactory
()
.
id
),
status
=
'published'
)
]),
course
,
CourseFactory
(
course_runs
=
[
CourseRunFactory
(
key
=
str
(
ModuleStoreCourseFactory
()
.
id
),
status
=
'published'
)
])
],
is_program_eligible_for_one_click_purchase
=
True
)
data
=
ProgramMarketingDataExtender
(
program
,
self
.
user
)
.
extend
()
self
.
assertFalse
(
data
[
'is_learner_eligible_for_one_click_purchase'
])
course_run_2
[
'status'
]
=
'unpublished'
data
=
ProgramMarketingDataExtender
(
program
,
self
.
user
)
.
extend
()
self
.
assertTrue
(
data
[
'is_learner_eligible_for_one_click_purchase'
])
@httpretty.activate
def
test_fetching_program_discounted_price
(
self
):
"""
Authenticated users eligible for one click purchase should see the purchase button
- displaying program's discounted price if it exists.
- leading to ecommerce basket page
"""
self
.
_prepare_program_for_discounted_price_calculation_endpoint
()
mock_discount_data
=
{
'total_incl_tax_excl_discounts'
:
200.0
,
'currency'
:
"USD"
,
'total_incl_tax'
:
50.0
}
httpretty
.
register_uri
(
httpretty
.
GET
,
self
.
ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT
,
body
=
json
.
dumps
(
mock_discount_data
),
content_type
=
'application/json'
)
data
=
ProgramMarketingDataExtender
(
self
.
program
,
self
.
user
)
.
extend
()
self
.
assertEqual
(
data
[
'skus'
],
[
course
[
'course_runs'
][
0
][
'seats'
][
0
][
'sku'
]
for
course
in
self
.
program
[
'courses'
]]
)
self
.
assertEqual
(
data
[
'discount_data'
],
mock_discount_data
)
@httpretty.activate
def
test_fetching_program_discounted_price_as_anonymous_user
(
self
):
"""
Anonymous users should see the purchase button same way the authenticated users do
when the program is eligible for one click purchase.
"""
self
.
_prepare_program_for_discounted_price_calculation_endpoint
()
mock_discount_data
=
{
'total_incl_tax_excl_discounts'
:
200.0
,
'currency'
:
"USD"
,
'total_incl_tax'
:
50.0
}
httpretty
.
register_uri
(
httpretty
.
GET
,
self
.
ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT
,
body
=
json
.
dumps
(
mock_discount_data
),
content_type
=
'application/json'
)
data
=
ProgramMarketingDataExtender
(
self
.
program
,
AnonymousUserFactory
())
.
extend
()
self
.
assertEqual
(
data
[
'skus'
],
[
course
[
'course_runs'
][
0
][
'seats'
][
0
][
'sku'
]
for
course
in
self
.
program
[
'courses'
]]
)
self
.
assertEqual
(
data
[
'discount_data'
],
mock_discount_data
)
def
test_fetching_program_discounted_price_no_applicable_seats
(
self
):
"""
User shouldn't be able to do a one click purchase of a program if a program has no applicable seat types.
"""
data
=
ProgramMarketingDataExtender
(
self
.
program
,
self
.
user
)
.
extend
()
self
.
assertEqual
(
len
(
data
[
'skus'
]),
0
)
@httpretty.activate
def
test_fetching_program_discounted_price_api_exception_caught
(
self
):
"""
User should be able to do a one click purchase of a program even if the ecommerce API throws an exception
during the calculation of program discounted price.
"""
self
.
_prepare_program_for_discounted_price_calculation_endpoint
()
httpretty
.
register_uri
(
httpretty
.
GET
,
self
.
ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT
,
status
=
400
,
content_type
=
'application/json'
)
data
=
ProgramMarketingDataExtender
(
self
.
program
,
self
.
user
)
.
extend
()
self
.
assertEqual
(
data
[
'skus'
],
[
course
[
'course_runs'
][
0
][
'seats'
][
0
][
'sku'
]
for
course
in
self
.
program
[
'courses'
]]
)
openedx/core/djangoapps/programs/utils.py
View file @
bf164ae9
# -*- coding: utf-8 -*-
"""Helper functions for working with Programs."""
import
datetime
import
logging
from
collections
import
defaultdict
from
copy
import
deepcopy
from
itertools
import
chain
...
...
@@ -8,17 +9,21 @@ from urlparse import urljoin
from
dateutil.parser
import
parse
from
django.conf
import
settings
from
django.contrib.auth
import
get_user_model
from
django.core.cache
import
cache
from
django.core.urlresolvers
import
reverse
from
django.utils.functional
import
cached_property
from
edx_rest_api_client.exceptions
import
SlumberBaseException
from
opaque_keys.edx.keys
import
CourseKey
from
pytz
import
utc
from
requests.exceptions
import
ConnectionError
,
Timeout
from
course_modes.models
import
CourseMode
from
lms.djangoapps.certificates
import
api
as
certificate_api
from
lms.djangoapps.commerce.utils
import
EcommerceService
from
lms.djangoapps.courseware.access
import
has_access
from
openedx.core.djangoapps.catalog.utils
import
get_programs
from
openedx.core.djangoapps.commerce.utils
import
ecommerce_api_client
from
openedx.core.djangoapps.content.course_overviews.models
import
CourseOverview
from
openedx.core.djangoapps.credentials.utils
import
get_credentials
from
student.models
import
CourseEnrollment
...
...
@@ -28,6 +33,8 @@ from xmodule.modulestore.django import modulestore
# The datetime module's strftime() methods require a year >= 1900.
DEFAULT_ENROLLMENT_START_DATE
=
datetime
.
datetime
(
1900
,
1
,
1
,
tzinfo
=
utc
)
log
=
logging
.
getLogger
(
__name__
)
def
get_program_marketing_url
(
programs_config
):
"""Build a URL used to link to programs on the marketing site."""
...
...
@@ -507,27 +514,25 @@ class ProgramMarketingDataExtender(ProgramDataExtender):
uuid
=
self
.
data
[
'uuid'
]
)
program_instructors
=
cache
.
get
(
cache_key
)
is_learner_eligible_for_one_click_purchase
=
self
.
data
[
'is_program_eligible_for_one_click_purchase'
]
for
course
in
self
.
data
[
'courses'
]:
self
.
_execute
(
'_collect_course'
,
course
)
if
not
program_instructors
:
for
course_run
in
course
[
'course_runs'
]:
self
.
_execute
(
'_collect_instructors'
,
course_run
)
if
is_learner_eligible_for_one_click_purchase
:
is_learner_eligible_for_one_click_purchase
=
not
any
(
course_run
[
'is_enrolled'
]
for
course_run
in
course
[
'course_runs'
]
)
if
not
program_instructors
:
# We cache the program instructors list to avoid repeated modulestore queries
program_instructors
=
self
.
instructors
.
values
()
cache
.
set
(
cache_key
,
program_instructors
,
3600
)
self
.
data
.
update
({
'instructors'
:
program_instructors
,
'is_learner_eligible_for_one_click_purchase'
:
is_learner_eligible_for_one_click_purchase
,
})
self
.
data
[
'instructors'
]
=
program_instructors
def
extend
(
self
):
"""Execute extension handlers, returning the extended data."""
self
.
data
.
update
(
super
(
ProgramMarketingDataExtender
,
self
)
.
extend
())
self
.
_collect_one_click_purchase_eligibility_data
()
return
self
.
data
@classmethod
def
_handlers
(
cls
,
prefix
):
...
...
@@ -582,3 +587,53 @@ class ProgramMarketingDataExtender(ProgramDataExtender):
self
.
instructors
.
update
(
{
instructor
.
get
(
'name'
):
instructor
for
instructor
in
course_instructors
.
get
(
'instructors'
,
[])}
)
def
_collect_one_click_purchase_eligibility_data
(
self
):
"""
Extend the program data with data about learner's eligibility for one click purchase,
discount data of the program and SKUs of seats that should be added to basket.
"""
applicable_seat_types
=
self
.
data
[
'applicable_seat_types'
]
is_learner_eligible_for_one_click_purchase
=
self
.
data
[
'is_program_eligible_for_one_click_purchase'
]
skus
=
[]
if
is_learner_eligible_for_one_click_purchase
:
for
course
in
self
.
data
[
'courses'
]:
is_learner_eligible_for_one_click_purchase
=
not
any
(
course_run
[
'is_enrolled'
]
for
course_run
in
course
[
'course_runs'
]
)
if
is_learner_eligible_for_one_click_purchase
:
published_course_runs
=
filter
(
lambda
run
:
run
[
'status'
]
==
'published'
,
course
[
'course_runs'
])
if
len
(
published_course_runs
)
==
1
:
for
seat
in
published_course_runs
[
0
][
'seats'
]:
if
seat
[
'type'
]
in
applicable_seat_types
:
skus
.
append
(
seat
[
'sku'
])
else
:
# If a course in the program has more than 1 published course run
# learner won't be eligible for a one click purchase.
is_learner_eligible_for_one_click_purchase
=
False
skus
=
[]
break
else
:
skus
=
[]
break
if
skus
:
try
:
User
=
get_user_model
()
service_user
=
User
.
objects
.
get
(
username
=
settings
.
ECOMMERCE_SERVICE_WORKER_USERNAME
)
api
=
ecommerce_api_client
(
service_user
)
# Make an API call to calculate the discounted price
discount_data
=
api
.
baskets
.
calculate
.
get
(
sku
=
skus
)
self
.
data
.
update
({
'discount_data'
:
discount_data
,
'full_program_price'
:
discount_data
[
'total_incl_tax'
]
})
except
(
ConnectionError
,
SlumberBaseException
,
Timeout
):
log
.
exception
(
'Failed to get discount price for following product SKUs:
%
s '
,
', '
.
join
(
skus
))
self
.
data
.
update
({
'is_learner_eligible_for_one_click_purchase'
:
is_learner_eligible_for_one_click_purchase
,
'skus'
:
skus
,
})
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