diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index bc2e358fc2..30ffb45327 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -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") diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 9dd6c4e184..1df67b99b5 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -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) diff --git a/lms/templates/courseware/program_marketing.html b/lms/templates/courseware/program_marketing.html new file mode 100644 index 0000000000..f146cfe02e --- /dev/null +++ b/lms/templates/courseware/program_marketing.html @@ -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). diff --git a/lms/urls.py b/lms/urls.py index 13ad85f5ec..e5b6736bdd 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -426,6 +426,14 @@ urlpatterns += ( name='student_progress', ), + url( + r'^programs/{}/about'.format( + r'(?P[0-9a-f-]+)', + ), + 'courseware.views.views.program_marketing', + name='program_marketing_view', + ), + # rest api for grades url( r'^api/grades/', diff --git a/openedx/core/djangoapps/catalog/tests/factories.py b/openedx/core/djangoapps/catalog/tests/factories.py index e212facf7c..f365f1b9e2 100644 --- a/openedx/core/djangoapps/catalog/tests/factories.py +++ b/openedx/core/djangoapps/catalog/tests/factories.py @@ -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) diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index 72c29ac363..3a0ad99d87 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -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) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 7aa6f1fd5d..7102a50802 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -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', [])} + )