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):
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):
......
"""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']]
)
# -*- 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,
})
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