diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index cd51c4a5c7f55ab3692a0b3ca2dd046aa37d399b..f291d196e3c3f77cbac610f42ac5d675d59aab89 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -127,6 +127,7 @@ from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.user_api.preferences import api as preferences_api +from openedx.core.djangoapps.catalog.utils import get_programs_data log = logging.getLogger("edx.student") @@ -173,6 +174,7 @@ def index(request, extra_context=None, user=AnonymousUser()): if extra_context is None: extra_context = {} + programs_list = [] courses = get_courses(user) if configuration_helpers.get_value( @@ -206,6 +208,16 @@ def index(request, extra_context=None, user=AnonymousUser()): # Insert additional context for use in the template context.update(extra_context) + # Getting all the programs from course-catalog service. The programs_list is being added to the context but it's + # not being used currently in lms/templates/index.html. To use this list, you need to create a custom theme that + # overrides index.html. The modifications to index.html to display the programs will be done after the support + # for edx-pattern-library is added. + if configuration_helpers.get_value("DISPLAY_PROGRAMS_ON_MARKETING_PAGES", + settings.FEATURES.get("DISPLAY_PROGRAMS_ON_MARKETING_PAGES")): + programs_list = get_programs_data(user) + + context["programs_list"] = programs_list + return render_to_response('index.html', context) diff --git a/lms/djangoapps/branding/tests/test_page.py b/lms/djangoapps/branding/tests/test_page.py index 230eec9f694a398c6867df8109c6d653e61a2233..ef2b4cd658ea82de65ba936da6ca008640027c8b 100644 --- a/lms/djangoapps/branding/tests/test_page.py +++ b/lms/djangoapps/branding/tests/test_page.py @@ -2,6 +2,7 @@ Tests for branding page """ +import mock import datetime from django.conf import settings @@ -287,3 +288,37 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase): self.assertEqual(context['courses'][0].id, self.starting_later.id) self.assertEqual(context['courses'][1].id, self.starting_earlier.id) self.assertEqual(context['courses'][2].id, self.course_with_default_start_date.id) + + +@attr(shard=1) +class IndexPageProgramsTests(ModuleStoreTestCase): + """ + Tests for Programs List in Marketing Pages. + """ + @patch.dict('django.conf.settings.FEATURES', {'DISPLAY_PROGRAMS_ON_MARKETING_PAGES': False}) + def test_get_programs_not_called(self): + with mock.patch("student.views.get_programs_data") as patched_get_programs_data: + # check the /dashboard + response = self.client.get('/') + self.assertEqual(response.status_code, 200) + self.assertEqual(patched_get_programs_data.call_count, 0) + + with mock.patch("courseware.views.views.get_programs_data") as patched_get_programs_data: + # check the /courses view + response = self.client.get(reverse('branding.views.courses')) + self.assertEqual(response.status_code, 200) + self.assertEqual(patched_get_programs_data.call_count, 0) + + @patch.dict('django.conf.settings.FEATURES', {'DISPLAY_PROGRAMS_ON_MARKETING_PAGES': True}) + def test_get_programs_called(self): + with mock.patch("student.views.get_programs_data") as patched_get_programs_data: + # check the /dashboard + response = self.client.get('/') + self.assertEqual(response.status_code, 200) + self.assertEqual(patched_get_programs_data.call_count, 1) + + with mock.patch("courseware.views.views.get_programs_data") as patched_get_programs_data: + # check the /courses view + response = self.client.get(reverse('branding.views.courses')) + self.assertEqual(response.status_code, 200) + self.assertEqual(patched_get_programs_data.call_count, 1) diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index f4f4ba42b6f290f3946b10f13a9f386af2bcf73f..b28c2162c5eaf762af9e8a6e4a90f83a372cbbd7 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -34,18 +34,22 @@ from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locations import SlashSeparatedCourseKey from rest_framework import status from lms.djangoapps.instructor.views.api import require_global_staff +from lms.djangoapps.ccx.utils import prep_course_for_grading +from lms.djangoapps.grades.new.course_grade import CourseGradeFactory +from lms.djangoapps.instructor.enrollment import uses_shib +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException +from openedx.core.djangoapps.catalog.utils import get_programs_data import shoppingcart import survey.utils import survey.views -from lms.djangoapps.ccx.utils import prep_course_for_grading from certificates import api as certs_api from certificates.models import CertificateStatuses from openedx.core.djangoapps.models.course_details import CourseDetails from commerce.utils import EcommerceService from enrollment.api import add_enrollment from course_modes.models import CourseMode -from lms.djangoapps.grades.new.course_grade import CourseGradeFactory from courseware.access import has_access, has_ccx_coach_role, _adjust_start_date_for_beta_testers from courseware.access_response import StartDateError from courseware.access_utils import in_preview_mode @@ -67,8 +71,6 @@ 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 lms.djangoapps.instructor.enrollment import uses_shib -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification 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 ( @@ -91,11 +93,9 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem from xmodule.tabs import CourseTabList from xmodule.x_module import STUDENT_VIEW -from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException from ..entrance_exams import user_must_complete_entrance_exam from ..module_render import get_module_for_descriptor, get_module, get_module_by_usage_id - log = logging.getLogger("edx.courseware") @@ -136,21 +136,32 @@ def courses(request): Render "find courses" page. The course selection work is done in courseware.courses. """ courses_list = [] + programs_list = [] course_discovery_meanings = getattr(settings, 'COURSE_DISCOVERY_MEANINGS', {}) if not settings.FEATURES.get('ENABLE_COURSE_DISCOVERY'): courses_list = get_courses(request.user) - if configuration_helpers.get_value( - "ENABLE_COURSE_SORTING_BY_START_DATE", - settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"] - ): + if configuration_helpers.get_value("ENABLE_COURSE_SORTING_BY_START_DATE", + settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"]): courses_list = sort_by_start_date(courses_list) else: courses_list = sort_by_announcement(courses_list) + # Getting all the programs from course-catalog service. The programs_list is being added to the context but it's + # not being used currently in courseware/courses.html. To use this list, you need to create a custom theme that + # overrides courses.html. The modifications to courses.html to display the programs will be done after the support + # for edx-pattern-library is added. + if configuration_helpers.get_value("DISPLAY_PROGRAMS_ON_MARKETING_PAGES", + settings.FEATURES.get("DISPLAY_PROGRAMS_ON_MARKETING_PAGES")): + programs_list = get_programs_data(request.user) + return render_to_response( "courseware/courses.html", - {'courses': courses_list, 'course_discovery_meanings': course_discovery_meanings} + { + 'courses': courses_list, + 'course_discovery_meanings': course_discovery_meanings, + 'programs_list': programs_list + } ) diff --git a/lms/envs/common.py b/lms/envs/common.py index f380bb25774cb4983cfb10cd543807323e96976c..1d6eefae742723bd75896d790ebe78236765f62b 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -249,11 +249,15 @@ FEATURES = { # False to not redirect the user 'ALWAYS_REDIRECT_HOMEPAGE_TO_DASHBOARD_FOR_AUTHENTICATED_USER': True, - # When a user goes to the homepage ('/') the user see the + # When a user goes to the homepage ('/') the user sees the # courses listed in the announcement dates order - this is default Open edX behavior. # Set to True to change the course sorting behavior by their start dates, latest first. 'ENABLE_COURSE_SORTING_BY_START_DATE': True, + # When set to True, a list of programs is displayed along with the list of courses + # when the user visits the homepage or the find courses page. + 'DISPLAY_PROGRAMS_ON_MARKETING_PAGES': False, + # Expose Mobile REST API. Note that if you use this, you must also set # ENABLE_OAUTH2_PROVIDER to True 'ENABLE_MOBILE_REST_API': False, diff --git a/openedx/core/djangoapps/catalog/migrations/0002_catalogintegration_username.py b/openedx/core/djangoapps/catalog/migrations/0002_catalogintegration_username.py new file mode 100644 index 0000000000000000000000000000000000000000..26218c13bafe7ed126d4c6b9cf2c52e3a756645e --- /dev/null +++ b/openedx/core/djangoapps/catalog/migrations/0002_catalogintegration_username.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalog', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='catalogintegration', + name='service_username', + field=models.CharField(default=b'lms_catalog_service_user', help_text='Username created for Course Catalog Integration, e.g. lms_catalog_service_user.', max_length=100), + ), + ] diff --git a/openedx/core/djangoapps/catalog/models.py b/openedx/core/djangoapps/catalog/models.py index 1d01d90b7150c9d2cfb5e6d4a08dce8b8c1cc8a5..80e95a60d2ca1f95633bdc3993327aa0afd30349 100644 --- a/openedx/core/djangoapps/catalog/models.py +++ b/openedx/core/djangoapps/catalog/models.py @@ -25,6 +25,16 @@ class CatalogIntegration(ConfigurationModel): ) ) + service_username = models.CharField( + max_length=100, + default="lms_catalog_service_user", + null=False, + blank=False, + help_text=_( + 'Username created for Course Catalog Integration, e.g. lms_catalog_service_user.' + ) + ) + @property def is_cache_enabled(self): """Whether responses from the catalog API will be cached.""" diff --git a/openedx/core/djangoapps/catalog/tests/factories.py b/openedx/core/djangoapps/catalog/tests/factories.py index 257cd92d5053614cb0eb6abb1d6639aac3c7b68a..3246a5d422dccc97e16a4b1c9519526bae4c7fed 100644 --- a/openedx/core/djangoapps/catalog/tests/factories.py +++ b/openedx/core/djangoapps/catalog/tests/factories.py @@ -70,3 +70,14 @@ class Program(factory.Factory): banner_image = { size: BannerImage() for size in ['large', 'medium', 'small', 'x-small'] } + + +class ProgramType(factory.Factory): + """ + Factory for stubbing ProgramType resources from the catalog API. + """ + class Meta(object): + model = dict + + name = FuzzyText() + logo_image = FuzzyText(prefix='https://example.com/program/logo') diff --git a/openedx/core/djangoapps/catalog/tests/test_utils.py b/openedx/core/djangoapps/catalog/tests/test_utils.py index bb813ba277164e9148613d959a2d89641c3a08da..8638f8525b1a964525175501109866d2f38275a4 100644 --- a/openedx/core/djangoapps/catalog/tests/test_utils.py +++ b/openedx/core/djangoapps/catalog/tests/test_utils.py @@ -2,6 +2,7 @@ Tests covering utilities for integrating with the catalog service. """ import uuid +import copy from django.core.cache import cache from django.test import TestCase @@ -12,9 +13,8 @@ from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.catalog import utils from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.catalog.tests import factories, mixins +from student.tests.factories import UserFactory, AnonymousUserFactory from openedx.core.djangolib.testing.utils import CacheIsolationTestCase -from student.tests.factories import UserFactory - UTILS_MODULE = 'openedx.core.djangoapps.catalog.utils' @@ -76,6 +76,73 @@ class TestGetPrograms(mixins.CatalogIntegrationMixin, TestCase): self.assert_contract(mock_get_catalog_data.call_args) self.assertEqual(data, programs) + def test_get_programs_anonymous_user(self, _mock_cache, mock_get_catalog_data): + programs = [factories.Program() for __ in range(3)] + mock_get_catalog_data.return_value = programs + + anonymous_user = AnonymousUserFactory() + + # The user is an Anonymous user but the Catalog Service User has not been created yet. + data = utils.get_programs(anonymous_user) + # This should not return programs. + self.assertEqual(data, []) + + UserFactory(username='lms_catalog_service_user') + # After creating the service user above, + data = utils.get_programs(anonymous_user) + # the programs should be returned successfully. + self.assertEqual(data, programs) + + def test_get_program_types(self, _mock_cache, mock_get_catalog_data): + program_types = [factories.ProgramType() for __ in range(3)] + mock_get_catalog_data.return_value = program_types + + # Creating Anonymous user but the Catalog Service User has not been created yet. + anonymous_user = AnonymousUserFactory() + data = utils.get_program_types(anonymous_user) + # This should not return programs. + self.assertEqual(data, []) + + # Creating Catalog Service User user + UserFactory(username='lms_catalog_service_user') + data = utils.get_program_types(anonymous_user) + # the programs should be returned successfully. + self.assertEqual(data, program_types) + + # Catalog integration is disabled now. + self.catalog_integration = self.create_catalog_integration(enabled=False) + data = utils.get_program_types(anonymous_user) + # This should not return programs. + self.assertEqual(data, []) + + def test_get_programs_data(self, _mock_cache, mock_get_catalog_data): # pylint: disable=unused-argument + programs = [] + program_types = [] + programs_data = [] + + for index in range(3): + # Creating the Programs and their corresponding program types. + type_name = "type_name_{postfix}".format(postfix=index) + program = factories.Program(type=type_name) + program_type = factories.ProgramType(name=type_name) + + # Maintaining the programs, program types and program data(program+logo_image) lists. + programs.append(program) + program_types.append(program_type) + programs_data.append(copy.deepcopy(program)) + + # Adding the logo image in program data. + programs_data[-1]['logo_image'] = program_type["logo_image"] + + with mock.patch("openedx.core.djangoapps.catalog.utils.get_programs") as patched_get_programs: + with mock.patch("openedx.core.djangoapps.catalog.utils.get_program_types") as patched_get_program_types: + # Mocked the "get_programs" and "get_program_types" + patched_get_programs.return_value = programs + patched_get_program_types.return_value = program_types + + programs_data = utils.get_programs_data() + self.assertEqual(programs_data, programs) + def test_get_one_program(self, _mock_cache, mock_get_catalog_data): program = factories.Program() mock_get_catalog_data.return_value = program diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py index 44bb8f36cbc135abb258c8e57022bf9ab8b08f71..e902aa74e7e948ba2da140d818ece6b55dbf0aca 100644 --- a/openedx/core/djangoapps/catalog/utils.py +++ b/openedx/core/djangoapps/catalog/utils.py @@ -4,6 +4,7 @@ import logging from django.conf import settings from django.core.cache import cache +from django.contrib.auth.models import User from edx_rest_api_client.client import EdxRestApiClient from opaque_keys.edx.keys import CourseKey @@ -24,7 +25,20 @@ def create_catalog_api_client(user, catalog_integration): return EdxRestApiClient(catalog_integration.internal_api_url, jwt=jwt) -def get_programs(user, uuid=None, type=None): # pylint: disable=redefined-builtin +def _get_service_user(user, service_username): + """ + Retrieve and return the Catalog Integration Service User Object + if the passed user is None or anonymous + """ + if not user or user.is_anonymous(): + try: + user = User.objects.get(username=service_username) + except User.DoesNotExist: + user = None + return user + + +def get_programs(user=None, uuid=None, type=None): # pylint: disable=redefined-builtin """Retrieve marketable programs from the catalog service. Keyword Arguments: @@ -36,8 +50,11 @@ def get_programs(user, uuid=None, type=None): # pylint: disable=redefined-built dict, if a specific program is requested. """ catalog_integration = CatalogIntegration.current() - if catalog_integration.enabled: + user = _get_service_user(user, catalog_integration.service_username) + if not user: + return [] + api = create_catalog_api_client(user, catalog_integration) cache_key = '{base}.programs{type}'.format( @@ -66,6 +83,46 @@ def get_programs(user, uuid=None, type=None): # pylint: disable=redefined-built return [] +def get_program_types(user=None): # pylint: disable=redefined-builtin + """Retrieve all program types from the catalog service. + + Returns: + list of dict, representing program types. + """ + catalog_integration = CatalogIntegration.current() + if catalog_integration.enabled: + user = _get_service_user(user, catalog_integration.service_username) + if not user: + return [] + + api = create_catalog_api_client(user, catalog_integration) + cache_key = '{base}.program_types'.format(base=catalog_integration.CACHE_KEY) + + return get_edx_api_data( + catalog_integration, + user, + 'program_types', + cache_key=cache_key if catalog_integration.is_cache_enabled else None, + api=api + ) + else: + return [] + + +def get_programs_data(user=None): + """Return the list of Programs after adding the ProgramType Logo Image""" + + programs_list = get_programs(user) + program_types = get_program_types(user) + + program_types_lookup_dict = {program_type["name"]: program_type for program_type in program_types} + + for program in programs_list: + program["logo_image"] = program_types_lookup_dict[program["type"]]["logo_image"] + + return programs_list + + def munge_catalog_program(catalog_program): """Make a program from the catalog service look like it came from the programs service.