Commit d6d2b2d5 by Hasnain Committed by Douglas Hall

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
parent b2385424
......@@ -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")
......
"""
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)
......
<%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):
"""
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.
"""
# 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)
if not program_instructors:
instructors_by_name = {}
module_store = modulestore()
for course in self.data['courses']:
for course in self.data['courses']:
self._execute('_collect_course', course)
if not program_instructors:
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', [])})
program_instructors = instructors_by_name.values()
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.
"""
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', {})
# Deduplicate program instructors using instructor name
self.instructors.update(
{instructor.get('name'): instructor for instructor in course_instructors.get('instructors', [])}
)
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