Add program marketing view
This change adds the URL configuration and Django view required to implement a program marketing page. It is left to theme builders to implement a template that fulfills their own UX requirements. WL-766
This commit is contained in:
@@ -75,6 +75,12 @@ class OrganizationFactory(DictFactoryBase):
|
||||
uuid = factory.Faker('uuid4')
|
||||
|
||||
|
||||
class SeatFactory(DictFactoryBase):
|
||||
type = factory.Faker('word')
|
||||
price = factory.Faker('random_int')
|
||||
currency = 'USD'
|
||||
|
||||
|
||||
class CourseRunFactory(DictFactoryBase):
|
||||
end = factory.LazyFunction(generate_zulu_datetime)
|
||||
enrollment_end = factory.LazyFunction(generate_zulu_datetime)
|
||||
@@ -82,6 +88,7 @@ class CourseRunFactory(DictFactoryBase):
|
||||
image = ImageFactory()
|
||||
key = factory.LazyFunction(generate_course_run_key)
|
||||
marketing_url = factory.Faker('url')
|
||||
seats = factory.LazyFunction(partial(generate_instances, SeatFactory))
|
||||
pacing_type = 'self_paced'
|
||||
short_description = factory.Faker('sentence')
|
||||
start = factory.LazyFunction(generate_zulu_datetime)
|
||||
|
||||
@@ -18,10 +18,11 @@ from openedx.core.djangoapps.catalog.tests.factories import (
|
||||
ProgramFactory,
|
||||
CourseFactory,
|
||||
CourseRunFactory,
|
||||
SeatFactory,
|
||||
)
|
||||
from openedx.core.djangoapps.programs.tests.factories import ProgressFactory
|
||||
from openedx.core.djangoapps.programs.utils import (
|
||||
DEFAULT_ENROLLMENT_START_DATE, ProgramProgressMeter, ProgramDataExtender
|
||||
DEFAULT_ENROLLMENT_START_DATE, ProgramProgressMeter, ProgramDataExtender, ProgramMarketingDataExtender
|
||||
)
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
@@ -382,18 +383,6 @@ class TestProgramDataExtender(ModuleStoreTestCase):
|
||||
maxDiff = None
|
||||
sku = 'abc123'
|
||||
checkout_path = '/basket'
|
||||
instructors = {
|
||||
'instructors': [
|
||||
{
|
||||
'name': 'test-instructor1',
|
||||
'organization': 'TextX',
|
||||
},
|
||||
{
|
||||
'name': 'test-instructor2',
|
||||
'organization': 'TextX',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super(TestProgramDataExtender, self).setUp()
|
||||
@@ -401,7 +390,6 @@ class TestProgramDataExtender(ModuleStoreTestCase):
|
||||
self.course = ModuleStoreCourseFactory()
|
||||
self.course.start = datetime.datetime.now(utc) - datetime.timedelta(days=1)
|
||||
self.course.end = datetime.datetime.now(utc) + datetime.timedelta(days=1)
|
||||
self.course.instructor_info = self.instructors
|
||||
self.course = self.update_course(self.course, self.user.id)
|
||||
|
||||
self.course_run = CourseRunFactory(key=unicode(self.course.id))
|
||||
@@ -569,8 +557,76 @@ class TestProgramDataExtender(ModuleStoreTestCase):
|
||||
|
||||
self._assert_supplemented(data, certificate_url=expected_url)
|
||||
|
||||
def test_instructors_retrieval(self):
|
||||
data = ProgramDataExtender(self.program, self.user).extend(include_instructors=True)
|
||||
|
||||
@ddt.ddt
|
||||
@override_settings(ECOMMERCE_PUBLIC_URL_ROOT=ECOMMERCE_URL_ROOT)
|
||||
@skip_unless_lms
|
||||
class TestProgramMarketingDataExtender(ModuleStoreTestCase):
|
||||
"""Tests of the program data extender utility class."""
|
||||
instructors = {
|
||||
'instructors': [
|
||||
{
|
||||
'name': 'test-instructor1',
|
||||
'organization': 'TextX',
|
||||
},
|
||||
{
|
||||
'name': 'test-instructor2',
|
||||
'organization': 'TextX',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super(TestProgramMarketingDataExtender, self).setUp()
|
||||
|
||||
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):
|
||||
"""
|
||||
Creates the course in mongo and update it with the instructor data.
|
||||
Also creates catalog course with respect to course run.
|
||||
|
||||
Returns:
|
||||
Catalog course dict.
|
||||
"""
|
||||
course = ModuleStoreCourseFactory()
|
||||
course.start = datetime.datetime.now(utc) - datetime.timedelta(days=1)
|
||||
course.end = datetime.datetime.now(utc) + datetime.timedelta(days=1)
|
||||
course.instructor_info = self.instructors
|
||||
course = self.update_course(course, self.user.id)
|
||||
|
||||
course_run = CourseRunFactory(
|
||||
key=unicode(course.id),
|
||||
seats=[SeatFactory(price=course_price)]
|
||||
)
|
||||
return CourseFactory(course_runs=[course_run])
|
||||
|
||||
def test_instructors(self):
|
||||
data = ProgramMarketingDataExtender(self.program, self.user).extend()
|
||||
|
||||
self.program.update(self.instructors['instructors'])
|
||||
self.assertEqual(data, self.program)
|
||||
|
||||
def test_course_pricing(self):
|
||||
data = ProgramMarketingDataExtender(self.program, self.user).extend()
|
||||
|
||||
program_full_price = self.course_price * self.number_of_courses
|
||||
self.assertEqual(data['number_of_courses'], self.number_of_courses)
|
||||
self.assertEqual(data['full_program_price'], program_full_price)
|
||||
self.assertEqual(data['avg_price_per_course'], program_full_price / self.number_of_courses)
|
||||
|
||||
@ddt.data(True, False)
|
||||
@mock.patch(UTILS_MODULE + '.has_access')
|
||||
def test_can_enroll(self, can_enroll, mock_has_access):
|
||||
"""
|
||||
Verify that the student's can_enroll status is included.
|
||||
"""
|
||||
mock_has_access.return_value = can_enroll
|
||||
|
||||
data = ProgramMarketingDataExtender(self.program, self.user).extend()
|
||||
|
||||
self.assertEqual(data['courses'][0]['course_runs'][0]['can_enroll'], can_enroll)
|
||||
|
||||
@@ -10,10 +10,12 @@ from django.core.urlresolvers import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from pytz import utc
|
||||
from itertools import chain
|
||||
|
||||
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.content.course_overviews.models import CourseOverview
|
||||
from student.models import CourseEnrollment
|
||||
@@ -248,12 +250,9 @@ class ProgramDataExtender(object):
|
||||
self.course_overview = None
|
||||
self.enrollment_start = None
|
||||
|
||||
def extend(self, include_instructors=False):
|
||||
def extend(self):
|
||||
"""Execute extension handlers, returning the extended data."""
|
||||
if include_instructors:
|
||||
self._execute('_extend')
|
||||
else:
|
||||
self._execute('_extend_course_runs')
|
||||
self._execute('_extend')
|
||||
return self.data
|
||||
|
||||
def _execute(self, prefix, *args):
|
||||
@@ -265,9 +264,6 @@ class ProgramDataExtender(object):
|
||||
"""Returns a generator yielding method names beginning with the given prefix."""
|
||||
return (name for name in cls.__dict__ if name.startswith(prefix))
|
||||
|
||||
def _extend_with_instructors(self):
|
||||
self._execute('_attach_instructors')
|
||||
|
||||
def _extend_course_runs(self):
|
||||
"""Execute course run data handlers."""
|
||||
for course in self.data['courses']:
|
||||
@@ -334,31 +330,99 @@ class ProgramDataExtender(object):
|
||||
else:
|
||||
run_mode['upgrade_url'] = None
|
||||
|
||||
def _attach_instructors(self):
|
||||
|
||||
# pylint: disable=missing-docstring
|
||||
class ProgramMarketingDataExtender(ProgramDataExtender):
|
||||
"""
|
||||
Utility for extending program data meant for the program marketing page which lives in the
|
||||
edx-platform git repository with user-specific (e.g., CourseEnrollment) data, pricing data,
|
||||
and program instructor data.
|
||||
|
||||
Arguments:
|
||||
program_data (dict): Representation of a program.
|
||||
user (User): The user whose enrollments to inspect.
|
||||
"""
|
||||
def __init__(self, program_data, user):
|
||||
super(ProgramMarketingDataExtender, self).__init__(program_data, user)
|
||||
|
||||
# Aggregate dict of instructors for the program keyed by name
|
||||
self.instructors = {}
|
||||
|
||||
# Values for programs' price calculation.
|
||||
self.data['avg_price_per_course'] = 0
|
||||
self.data['number_of_courses'] = 0
|
||||
self.data['full_program_price'] = 0
|
||||
|
||||
def _extend_program(self):
|
||||
"""Aggregates data from the program data structure."""
|
||||
cache_key = 'program.instructors.{uuid}'.format(
|
||||
uuid=self.data['uuid']
|
||||
)
|
||||
program_instructors = cache.get(cache_key)
|
||||
|
||||
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 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['instructors'] = program_instructors
|
||||
|
||||
@classmethod
|
||||
def _handlers(cls, prefix):
|
||||
"""Returns a generator yielding method names beginning with the given prefix."""
|
||||
# We use a set comprehension here to deduplicate the list of
|
||||
# function names given the fact that the subclass overrides
|
||||
# some functions on the parent class.
|
||||
return {name for name in chain(cls.__dict__, ProgramDataExtender.__dict__) if name.startswith(prefix)}
|
||||
|
||||
def _attach_course_run_can_enroll(self, run_mode):
|
||||
run_mode['can_enroll'] = bool(has_access(self.user, 'enroll', self.course_overview))
|
||||
|
||||
def _attach_course_run_certificate_url(self, run_mode):
|
||||
"""
|
||||
We override this function here and stub it out because
|
||||
the superclass (ProgramDataExtender) requires a non-anonymous
|
||||
User which we may or may not have when rendering marketing
|
||||
pages. The certificate URL is not needed when rendering
|
||||
the program marketing page.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _attach_course_run_upgrade_url(self, run_mode):
|
||||
if not self.user.is_anonymous():
|
||||
super(ProgramMarketingDataExtender, self)._attach_course_run_upgrade_url(run_mode)
|
||||
else:
|
||||
run_mode['upgrade_url'] = None
|
||||
|
||||
def _collect_course_pricing(self, course):
|
||||
self.data['number_of_courses'] += 1
|
||||
course_runs = course['course_runs']
|
||||
if course_runs:
|
||||
seats = course_runs[0]['seats']
|
||||
if seats:
|
||||
self.data['full_program_price'] += float(seats[0]['price'])
|
||||
self.data['avg_price_per_course'] = self.data['full_program_price'] / self.data['number_of_courses']
|
||||
|
||||
def _collect_instructors(self, course_run):
|
||||
"""
|
||||
Extend the program data with instructor data. The instructor data added here is persisted
|
||||
on each course in modulestore and can be edited in Studio. Once the course metadata publisher tool
|
||||
supports the authoring of course instructor data, we will be able to migrate course
|
||||
instructor data into the catalog, retrieve it via the catalog API, and remove this code.
|
||||
"""
|
||||
cache_key = 'program.instructors.{uuid}'.format(
|
||||
uuid=self.data['uuid']
|
||||
)
|
||||
program_instructors = cache.get(cache_key)
|
||||
if not program_instructors:
|
||||
instructors_by_name = {}
|
||||
module_store = modulestore()
|
||||
for course in self.data['courses']:
|
||||
for course_run in course['course_runs']:
|
||||
course_run_key = CourseKey.from_string(course_run['key'])
|
||||
course_descriptor = module_store.get_course(course_run_key)
|
||||
if course_descriptor:
|
||||
course_instructors = getattr(course_descriptor, 'instructor_info', {})
|
||||
# Deduplicate program instructors using instructor name
|
||||
instructors_by_name.update({instructor.get('name'): instructor for instructor
|
||||
in course_instructors.get('instructors', [])})
|
||||
module_store = modulestore()
|
||||
course_run_key = CourseKey.from_string(course_run['key'])
|
||||
course_descriptor = module_store.get_course(course_run_key)
|
||||
if course_descriptor:
|
||||
course_instructors = getattr(course_descriptor, 'instructor_info', {})
|
||||
|
||||
program_instructors = instructors_by_name.values()
|
||||
cache.set(cache_key, program_instructors, 3600)
|
||||
|
||||
self.data['instructors'] = program_instructors
|
||||
# Deduplicate program instructors using instructor name
|
||||
self.instructors.update(
|
||||
{instructor.get('name'): instructor for instructor in course_instructors.get('instructors', [])}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user