Commit bf164ae9 by Marko Jevtic

[LEARNER-1183] Prepare program data to be presented on program marketing page

[LEARNER-1393] Filter program course runs by status
parent 9c4869c1
...@@ -801,8 +801,13 @@ def program_marketing(request, program_uuid): ...@@ -801,8 +801,13 @@ def program_marketing(request, program_uuid):
if not program_data: if not program_data:
raise Http404 raise Http404
program = ProgramMarketingDataExtender(program_data, request.user).extend()
skus = program.get('skus')
ecommerce_service = EcommerceService()
return render_to_response('courseware/program_marketing.html', { 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 @@ ...@@ -3,6 +3,7 @@
from functools import partial from functools import partial
import factory import factory
import uuid
from faker import Faker from faker import Faker
...@@ -34,6 +35,19 @@ def generate_zulu_datetime(): ...@@ -34,6 +35,19 @@ def generate_zulu_datetime():
return fake.date_time().isoformat() + 'Z' 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): class DictFactoryBase(factory.Factory):
""" """
Subclass this to make factories that can be used to produce fake API response Subclass this to make factories that can be used to produce fake API response
...@@ -77,12 +91,15 @@ class OrganizationFactory(DictFactoryBase): ...@@ -77,12 +91,15 @@ class OrganizationFactory(DictFactoryBase):
key = factory.Faker('word') key = factory.Faker('word')
name = factory.Faker('company') name = factory.Faker('company')
uuid = factory.Faker('uuid4') uuid = factory.Faker('uuid4')
logo_image_url = factory.Faker('image_url')
class SeatFactory(DictFactoryBase): class SeatFactory(DictFactoryBase):
type = factory.Faker('word')
price = factory.Faker('random_int')
currency = 'USD' 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): class CourseRunFactory(DictFactoryBase):
...@@ -91,13 +108,13 @@ class CourseRunFactory(DictFactoryBase): ...@@ -91,13 +108,13 @@ class CourseRunFactory(DictFactoryBase):
enrollment_end = factory.LazyFunction(generate_zulu_datetime) enrollment_end = factory.LazyFunction(generate_zulu_datetime)
enrollment_start = factory.LazyFunction(generate_zulu_datetime) enrollment_start = factory.LazyFunction(generate_zulu_datetime)
image = ImageFactory() image = ImageFactory()
is_enrolled = False
key = factory.LazyFunction(generate_course_run_key) key = factory.LazyFunction(generate_course_run_key)
marketing_url = factory.Faker('url') marketing_url = factory.Faker('url')
pacing_type = 'self_paced' pacing_type = 'self_paced'
seats = factory.LazyFunction(partial(generate_instances, SeatFactory)) seats = factory.LazyFunction(partial(generate_instances, SeatFactory))
short_description = factory.Faker('sentence') short_description = factory.Faker('sentence')
start = factory.LazyFunction(generate_zulu_datetime) start = factory.LazyFunction(generate_zulu_datetime)
status = 'published'
title = factory.Faker('catch_phrase') title = factory.Faker('catch_phrase')
type = 'verified' type = 'verified'
uuid = factory.Faker('uuid4') uuid = factory.Faker('uuid4')
...@@ -112,20 +129,57 @@ class CourseFactory(DictFactoryBase): ...@@ -112,20 +129,57 @@ class CourseFactory(DictFactoryBase):
uuid = factory.Faker('uuid4') 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): class ProgramFactory(DictFactoryBase):
authoring_organizations = factory.LazyFunction(partial(generate_instances, OrganizationFactory, count=1)) authoring_organizations = factory.LazyFunction(partial(generate_instances, OrganizationFactory, count=1))
applicable_seat_types = []
banner_image = factory.LazyFunction(generate_sized_stdimage) banner_image = factory.LazyFunction(generate_sized_stdimage)
card_image_url = factory.Faker('image_url') card_image_url = factory.Faker('image_url')
courses = factory.LazyFunction(partial(generate_instances, CourseFactory)) 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 is_program_eligible_for_one_click_purchase = True
job_outlook_items = factory.LazyFunction(partial(generate_instances, JobOutlookItemFactory))
marketing_slug = factory.Faker('slug') marketing_slug = factory.Faker('slug')
marketing_url = factory.Faker('url') 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' status = 'active'
subtitle = factory.Faker('sentence') subtitle = factory.Faker('sentence')
title = factory.Faker('catch_phrase') title = factory.Faker('catch_phrase')
type = factory.Faker('word') type = factory.Faker('word')
uuid = factory.Faker('uuid4') uuid = factory.Faker('uuid4')
hidden = False weeks_to_complete = fake.random_int(1, 45)
class ProgramTypeFactory(DictFactoryBase): class ProgramTypeFactory(DictFactoryBase):
......
"""Tests covering Programs utilities.""" """Tests covering Programs utilities."""
# pylint: disable=no-member # pylint: disable=no-member
import datetime import datetime
import json
import uuid import uuid
import ddt import ddt
import httpretty
from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
...@@ -14,6 +17,7 @@ from pytz import utc ...@@ -14,6 +17,7 @@ from pytz import utc
from course_modes.models import CourseMode from course_modes.models import CourseMode
from lms.djangoapps.certificates.api import MODES from lms.djangoapps.certificates.api import MODES
from lms.djangoapps.commerce.tests.test_utils import update_commerce_config 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 ( from openedx.core.djangoapps.catalog.tests.factories import (
generate_course_run_key, generate_course_run_key,
ProgramFactory, ProgramFactory,
...@@ -30,15 +34,15 @@ from openedx.core.djangoapps.programs.utils import ( ...@@ -30,15 +34,15 @@ from openedx.core.djangoapps.programs.utils import (
get_certificates, get_certificates,
) )
from openedx.core.djangolib.testing.utils import skip_unless_lms 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 util.date_utils import strftime_localized
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory as ModuleStoreCourseFactory from xmodule.modulestore.tests.factories import CourseFactory as ModuleStoreCourseFactory
UTILS_MODULE = 'openedx.core.djangoapps.programs.utils'
CERTIFICATES_API_MODULE = 'lms.djangoapps.certificates.api' 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 @ddt.ddt
...@@ -809,6 +813,7 @@ class TestGetCertificates(TestCase): ...@@ -809,6 +813,7 @@ class TestGetCertificates(TestCase):
@skip_unless_lms @skip_unless_lms
class TestProgramMarketingDataExtender(ModuleStoreTestCase): class TestProgramMarketingDataExtender(ModuleStoreTestCase):
"""Tests of the program data extender utility class.""" """Tests of the program data extender utility class."""
ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT = '{root}/api/v2/baskets/calculate/'.format(root=ECOMMERCE_URL_ROOT)
instructors = { instructors = {
'instructors': [ 'instructors': [
{ {
...@@ -825,13 +830,16 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): ...@@ -825,13 +830,16 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
def setUp(self): def setUp(self):
super(TestProgramMarketingDataExtender, self).setUp() 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.course_price = 100
self.number_of_courses = 2 self.number_of_courses = 2
self.program = ProgramFactory( self.program = ProgramFactory(
courses=[self._create_course(self.course_price) for __ in range(self.number_of_courses)] 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. Creates the course in mongo and update it with the instructor data.
Also creates catalog course with respect to course run. Also creates catalog course with respect to course run.
...@@ -846,12 +854,24 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): ...@@ -846,12 +854,24 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
course = self.update_course(course, self.user.id) course = self.update_course(course, self.user.id)
course_run = CourseRunFactory( course_run = CourseRunFactory(
is_enrolled=is_enrolled,
key=unicode(course.id), key=unicode(course.id),
seats=[SeatFactory(price=course_price)] seats=[SeatFactory(price=course_price)]
) )
return CourseFactory(course_runs=[course_run]) 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): def test_instructors(self):
data = ProgramMarketingDataExtender(self.program, self.user).extend() data = ProgramMarketingDataExtender(self.program, self.user).extend()
...@@ -896,10 +916,136 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): ...@@ -896,10 +916,136 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
data = ProgramMarketingDataExtender(program, self.user).extend() data = ProgramMarketingDataExtender(program, self.user).extend()
self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) 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( program2 = ProgramFactory(
courses=courses, courses=[course],
is_program_eligible_for_one_click_purchase=True is_program_eligible_for_one_click_purchase=True
) )
data = ProgramMarketingDataExtender(program2, self.user).extend() data = ProgramMarketingDataExtender(program2, self.user).extend()
self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) 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']]
)
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Helper functions for working with Programs.""" """Helper functions for working with Programs."""
import datetime import datetime
import logging
from collections import defaultdict from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from itertools import chain from itertools import chain
...@@ -8,17 +9,21 @@ from urlparse import urljoin ...@@ -8,17 +9,21 @@ from urlparse import urljoin
from dateutil.parser import parse from dateutil.parser import parse
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import cache from django.core.cache import cache
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from edx_rest_api_client.exceptions import SlumberBaseException
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from pytz import utc from pytz import utc
from requests.exceptions import ConnectionError, Timeout
from course_modes.models import CourseMode from course_modes.models import CourseMode
from lms.djangoapps.certificates import api as certificate_api from lms.djangoapps.certificates import api as certificate_api
from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.access import has_access
from openedx.core.djangoapps.catalog.utils import get_programs 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.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credentials.utils import get_credentials from openedx.core.djangoapps.credentials.utils import get_credentials
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -28,6 +33,8 @@ from xmodule.modulestore.django import modulestore ...@@ -28,6 +33,8 @@ from xmodule.modulestore.django import modulestore
# The datetime module's strftime() methods require a year >= 1900. # The datetime module's strftime() methods require a year >= 1900.
DEFAULT_ENROLLMENT_START_DATE = datetime.datetime(1900, 1, 1, tzinfo=utc) DEFAULT_ENROLLMENT_START_DATE = datetime.datetime(1900, 1, 1, tzinfo=utc)
log = logging.getLogger(__name__)
def get_program_marketing_url(programs_config): def get_program_marketing_url(programs_config):
"""Build a URL used to link to programs on the marketing site.""" """Build a URL used to link to programs on the marketing site."""
...@@ -507,27 +514,25 @@ class ProgramMarketingDataExtender(ProgramDataExtender): ...@@ -507,27 +514,25 @@ class ProgramMarketingDataExtender(ProgramDataExtender):
uuid=self.data['uuid'] uuid=self.data['uuid']
) )
program_instructors = cache.get(cache_key) 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']: for course in self.data['courses']:
self._execute('_collect_course', course) self._execute('_collect_course', course)
if not program_instructors: if not program_instructors:
for course_run in course['course_runs']: for course_run in course['course_runs']:
self._execute('_collect_instructors', course_run) 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: if not program_instructors:
# We cache the program instructors list to avoid repeated modulestore queries # We cache the program instructors list to avoid repeated modulestore queries
program_instructors = self.instructors.values() program_instructors = self.instructors.values()
cache.set(cache_key, program_instructors, 3600) cache.set(cache_key, program_instructors, 3600)
self.data.update({ self.data['instructors'] = program_instructors
'instructors': program_instructors,
'is_learner_eligible_for_one_click_purchase': is_learner_eligible_for_one_click_purchase, 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 @classmethod
def _handlers(cls, prefix): def _handlers(cls, prefix):
...@@ -582,3 +587,53 @@ class ProgramMarketingDataExtender(ProgramDataExtender): ...@@ -582,3 +587,53 @@ class ProgramMarketingDataExtender(ProgramDataExtender):
self.instructors.update( self.instructors.update(
{instructor.get('name'): instructor for instructor in course_instructors.get('instructors', [])} {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,
})
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