[LEARNER-1183] Prepare program data to be presented on program marketing page
[LEARNER-1393] Filter program course runs by status
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""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()
|
||||
|
||||
@@ -881,8 +901,8 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
|
||||
def test_learner_eligibility_for_one_click_purchase(self):
|
||||
"""
|
||||
Learner should be eligible for one click purchase if:
|
||||
- program is eligible for one click purchase
|
||||
- learner is not enrolled in any of the course runs associated with the program
|
||||
- program is eligible for one click purchase
|
||||
- learner is not enrolled in any of the course runs associated with the program
|
||||
"""
|
||||
data = ProgramMarketingDataExtender(self.program, self.user).extend()
|
||||
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
|
||||
@@ -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']]
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- 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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user