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:
@@ -8,6 +8,7 @@ import ddt
|
||||
import json
|
||||
import itertools
|
||||
import unittest
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timedelta
|
||||
from HTMLParser import HTMLParser
|
||||
from nose.plugins.attrib import attr
|
||||
@@ -61,8 +62,12 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
|
||||
from openedx.core.djangoapps.catalog.tests.factories import CourseFactory as CatalogCourseFactory
|
||||
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory, CourseRunFactory
|
||||
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
|
||||
from openedx.core.djangoapps.credit.api import set_credit_requirements
|
||||
from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider
|
||||
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
|
||||
|
||||
|
||||
@attr(shard=1)
|
||||
@@ -970,6 +975,47 @@ class ViewsTestCase(ModuleStoreTestCase):
|
||||
self.assertContains(response, test)
|
||||
|
||||
|
||||
@attr(shard=2)
|
||||
@patch('openedx.core.djangoapps.catalog.utils.get_edx_api_data')
|
||||
class TestProgramMarketingView(ProgramsApiConfigMixin, CatalogIntegrationMixin, SharedModuleStoreTestCase):
|
||||
"""Unit tests for the program marketing page."""
|
||||
program_uuid = str(uuid4())
|
||||
url = reverse('program_marketing_view', kwargs={'program_uuid': program_uuid})
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestProgramMarketingView, cls).setUpClass()
|
||||
|
||||
modulestore_course = CourseFactory()
|
||||
course_run = CourseRunFactory(key=unicode(modulestore_course.id)) # pylint: disable=no-member
|
||||
course = CatalogCourseFactory(course_runs=[course_run])
|
||||
|
||||
cls.data = ProgramFactory(uuid=cls.program_uuid, courses=[course])
|
||||
|
||||
def test_404_if_no_data(self, _mock_get_edx_api_data):
|
||||
"""
|
||||
Verify that the page 404s if no program data is found.
|
||||
"""
|
||||
self.create_programs_config()
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_200(self, mock_get_edx_api_data):
|
||||
"""
|
||||
Verify the view returns a 200.
|
||||
"""
|
||||
self.create_programs_config()
|
||||
|
||||
catalog_integration = self.create_catalog_integration()
|
||||
UserFactory(username=catalog_integration.service_username)
|
||||
|
||||
mock_get_edx_api_data.return_value = self.data
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
@attr(shard=1)
|
||||
# setting TIME_ZONE_DISPLAYED_FOR_DEADLINES explicitly
|
||||
@override_settings(TIME_ZONE_DISPLAYED_FOR_DEADLINES="UTC")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Courseware views functions
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib
|
||||
@@ -77,7 +76,7 @@ from courseware.models import StudentModule, BaseStudentModuleHistory
|
||||
from courseware.url_helpers import get_redirect_url, get_redirect_url_for_global_staff
|
||||
from courseware.user_state_client import DjangoXBlockUserStateClient
|
||||
from edxmako.shortcuts import render_to_response, render_to_string, marketing_link
|
||||
from openedx.core.djangoapps.catalog.utils import get_programs_with_type
|
||||
from openedx.core.djangoapps.catalog.utils import get_programs, get_programs_with_type
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.coursetalk.helpers import inject_coursetalk_keys_into_context
|
||||
from openedx.core.djangoapps.credit.api import (
|
||||
@@ -85,6 +84,7 @@ from openedx.core.djangoapps.credit.api import (
|
||||
is_user_eligible_for_credit,
|
||||
is_credit_course
|
||||
)
|
||||
from openedx.core.djangoapps.programs.utils import ProgramMarketingDataExtender
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from shoppingcart.utils import is_shopping_cart_enabled
|
||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
@@ -769,6 +769,22 @@ def course_about(request, course_id):
|
||||
return render_to_response('courseware/course_about.html', context)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_if_anonymous()
|
||||
def program_marketing(request, program_uuid):
|
||||
"""
|
||||
Display the program marketing page.
|
||||
"""
|
||||
program_data = get_programs(uuid=program_uuid)
|
||||
|
||||
if not program_data:
|
||||
raise Http404
|
||||
|
||||
return render_to_response('courseware/program_marketing.html', {
|
||||
'program': ProgramMarketingDataExtender(program_data, request.user).extend()
|
||||
})
|
||||
|
||||
|
||||
@transaction.non_atomic_requests
|
||||
@login_required
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
|
||||
3
lms/templates/courseware/program_marketing.html
Normal file
3
lms/templates/courseware/program_marketing.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<%page expression_filter="h"/>
|
||||
|
||||
## This page is intentionally left blank. You can add your own program marketing page using comprehensive theming (http://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/changing_appearance/theming/enable_themes.html?highlight=theming).
|
||||
@@ -426,6 +426,14 @@ urlpatterns += (
|
||||
name='student_progress',
|
||||
),
|
||||
|
||||
url(
|
||||
r'^programs/{}/about'.format(
|
||||
r'(?P<program_uuid>[0-9a-f-]+)',
|
||||
),
|
||||
'courseware.views.views.program_marketing',
|
||||
name='program_marketing_view',
|
||||
),
|
||||
|
||||
# rest api for grades
|
||||
url(
|
||||
r'^api/grades/',
|
||||
|
||||
@@ -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