From a43c507a00faf51c4ca57b0f6d64d81ff9a2b97d Mon Sep 17 00:00:00 2001 From: Renzo Lucioni <renzo@renzolucioni.com> Date: Tue, 5 Jul 2016 14:10:59 -0400 Subject: [PATCH] Use course run marketing URLs from the catalog service on program detail page Part of ECOM-4566. --- common/djangoapps/terrain/stubs/catalog.py | 43 +++++++ common/djangoapps/terrain/stubs/start.py | 2 + common/test/acceptance/fixtures/__init__.py | 4 + common/test/acceptance/fixtures/catalog.py | 34 +++++ .../acceptance/tests/lms/test_programs.py | 36 ++++-- .../learner_dashboard/tests/test_programs.py | 6 + lms/envs/bok_choy.env.json | 3 + .../models/course_card_model.js | 2 +- .../course_card_view_spec.js | 37 +++++- .../learner_dashboard/course_card.underscore | 18 ++- lms/urls.py | 2 + .../djangoapps/catalog/tests/factories.py | 14 ++ .../djangoapps/catalog/tests/test_utils.py | 120 ++++++++++++++++++ openedx/core/djangoapps/catalog/utils.py | 69 ++++++++++ .../djangoapps/programs/tests/test_utils.py | 6 +- openedx/core/djangoapps/programs/utils.py | 4 +- pavelib/utils/envs.py | 13 +- 17 files changed, 377 insertions(+), 36 deletions(-) create mode 100644 common/djangoapps/terrain/stubs/catalog.py create mode 100644 common/test/acceptance/fixtures/catalog.py create mode 100644 openedx/core/djangoapps/catalog/tests/factories.py create mode 100644 openedx/core/djangoapps/catalog/tests/test_utils.py create mode 100644 openedx/core/djangoapps/catalog/utils.py diff --git a/common/djangoapps/terrain/stubs/catalog.py b/common/djangoapps/terrain/stubs/catalog.py new file mode 100644 index 00000000000..6ba8f0ae32b --- /dev/null +++ b/common/djangoapps/terrain/stubs/catalog.py @@ -0,0 +1,43 @@ +""" +Stub implementation of catalog service for acceptance tests +""" +import re +import urlparse + +from .http import StubHttpRequestHandler, StubHttpService + + +class StubCatalogServiceHandler(StubHttpRequestHandler): # pylint: disable=missing-docstring + + def do_GET(self): # pylint: disable=invalid-name, missing-docstring + pattern_handlers = { + r'/api/v1/course_runs/(?P<course_id>[^/+]+(/|\+)[^/+]+(/|\+)[^/?]+)/$': self.get_course_run, + } + + if self.match_pattern(pattern_handlers): + return + + self.send_response(404, content="404 Not Found") + + def match_pattern(self, pattern_handlers): + """ + Find the correct handler method given the path info from the HTTP request. + """ + path = urlparse.urlparse(self.path).path + for pattern in pattern_handlers: + match = re.match(pattern, path) + if match: + pattern_handlers[pattern](*match.groups()) + return True + return None + + def get_course_run(self, course_id): + """ + Stubs a catalog course run endpoint. + """ + course_run = self.server.config.get('course_run.{}'.format(course_id), []) + self.send_json_response(course_run) + + +class StubCatalogService(StubHttpService): # pylint: disable=missing-docstring + HANDLER_CLASS = StubCatalogServiceHandler diff --git a/common/djangoapps/terrain/stubs/start.py b/common/djangoapps/terrain/stubs/start.py index ad6d826eac4..9bb35d70a33 100644 --- a/common/djangoapps/terrain/stubs/start.py +++ b/common/djangoapps/terrain/stubs/start.py @@ -13,6 +13,7 @@ from .lti import StubLtiService from .video_source import VideoSourceHttpService from .edxnotes import StubEdxNotesService from .programs import StubProgramsService +from .catalog import StubCatalogService USAGE = "USAGE: python -m stubs.start SERVICE_NAME PORT_NUM [CONFIG_KEY=CONFIG_VAL, ...]" @@ -26,6 +27,7 @@ SERVICES = { 'edxnotes': StubEdxNotesService, 'programs': StubProgramsService, 'ecommerce': StubEcommerceService, + 'catalog': StubCatalogService, } # Log to stdout, including debug messages diff --git a/common/test/acceptance/fixtures/__init__.py b/common/test/acceptance/fixtures/__init__.py index bbe146b4308..e0597518561 100644 --- a/common/test/acceptance/fixtures/__init__.py +++ b/common/test/acceptance/fixtures/__init__.py @@ -1,5 +1,6 @@ import os + # Get the URL of the Studio instance under test STUDIO_BASE_URL = os.environ.get('studio_url', 'http://localhost:8031') @@ -20,3 +21,6 @@ EDXNOTES_STUB_URL = os.environ.get('edxnotes_url', 'http://localhost:8042') # Get the URL of the Programs service stub used in the test PROGRAMS_STUB_URL = os.environ.get('programs_url', 'http://localhost:8090') + +# Get the URL of the Catalog service stub used in the test +CATALOG_STUB_URL = os.environ.get('catalog_url', 'http://localhost:8091') diff --git a/common/test/acceptance/fixtures/catalog.py b/common/test/acceptance/fixtures/catalog.py new file mode 100644 index 00000000000..a60af67a4cc --- /dev/null +++ b/common/test/acceptance/fixtures/catalog.py @@ -0,0 +1,34 @@ +""" +Tools to create catalog-related data for use in bok choy tests. +""" +import json + +import requests + +from common.test.acceptance.fixtures import CATALOG_STUB_URL +from common.test.acceptance.fixtures.config import ConfigModelFixture + + +class CatalogFixture(object): + """ + Interface to set up mock responses from the Catalog stub server. + """ + def install_course_run(self, course_run): + """Set response data for the catalog's course run API.""" + key = 'catalog.{}'.format(course_run['key']) + + requests.put( + '{}/set_config'.format(CATALOG_STUB_URL), + data={key: json.dumps(course_run)}, + ) + + +class CatalogConfigMixin(object): + """Mixin providing a method used to configure the catalog integration.""" + def set_catalog_configuration(self, is_enabled=False, service_url=CATALOG_STUB_URL): + """Dynamically adjusts the catalog config model during tests.""" + ConfigModelFixture('/config/catalog', { + 'enabled': is_enabled, + 'internal_api_url': '{}/api/v1/'.format(service_url), + 'cache_ttl': 0, + }).install() diff --git a/common/test/acceptance/tests/lms/test_programs.py b/common/test/acceptance/tests/lms/test_programs.py index 48154a176cc..b4f610d4493 100644 --- a/common/test/acceptance/tests/lms/test_programs.py +++ b/common/test/acceptance/tests/lms/test_programs.py @@ -1,38 +1,44 @@ """Acceptance tests for LMS-hosted Programs pages""" from nose.plugins.attrib import attr +from ...fixtures.catalog import CatalogFixture, CatalogConfigMixin from ...fixtures.programs import ProgramsFixture, ProgramsConfigMixin from ...fixtures.course import CourseFixture from ..helpers import UniqueCourseTest from ...pages.lms.auto_auth import AutoAuthPage from ...pages.lms.programs import ProgramListingPage, ProgramDetailsPage -from openedx.core.djangoapps.programs.tests import factories +from openedx.core.djangoapps.catalog.tests import factories as catalog_factories +from openedx.core.djangoapps.programs.tests import factories as program_factories -class ProgramPageBase(ProgramsConfigMixin, UniqueCourseTest): +class ProgramPageBase(ProgramsConfigMixin, CatalogConfigMixin, UniqueCourseTest): """Base class used for program listing page tests.""" def setUp(self): super(ProgramPageBase, self).setUp() self.set_programs_api_configuration(is_enabled=True) + self.set_catalog_configuration(is_enabled=True) + + self.course_run = catalog_factories.CourseRun(key=self.course_id) + self.stub_catalog_api() def create_program(self, program_id=None, course_id=None): """DRY helper for creating test program data.""" course_id = course_id if course_id else self.course_id - run_mode = factories.RunMode(course_key=course_id) - course_code = factories.CourseCode(run_modes=[run_mode]) - org = factories.Organization(key=self.course_info['org']) + run_mode = program_factories.RunMode(course_key=course_id) + course_code = program_factories.CourseCode(run_modes=[run_mode]) + org = program_factories.Organization(key=self.course_info['org']) if program_id: - program = factories.Program( + program = program_factories.Program( id=program_id, status='active', organizations=[org], course_codes=[course_code] ) else: - program = factories.Program( + program = program_factories.Program( status='active', organizations=[org], course_codes=[course_code] @@ -40,10 +46,14 @@ class ProgramPageBase(ProgramsConfigMixin, UniqueCourseTest): return program - def stub_api(self, programs, is_list=True): + def stub_programs_api(self, programs, is_list=True): """Stub out the programs API with fake data.""" ProgramsFixture().install_programs(programs, is_list=is_list) + def stub_catalog_api(self): + """Stub out the catalog API's course run endpoint.""" + CatalogFixture().install_course_run(self.course_run) + def auth(self, enroll=True): """Authenticate, enrolling the user in the configured course if requested.""" CourseFixture(**self.course_info).install() @@ -62,7 +72,7 @@ class ProgramListingPageTest(ProgramPageBase): def test_no_enrollments(self): """Verify that no cards appear when the user has no enrollments.""" program = self.create_program() - self.stub_api([program]) + self.stub_programs_api([program]) self.auth(enroll=False) self.listing_page.visit() @@ -81,7 +91,7 @@ class ProgramListingPageTest(ProgramPageBase): ) program = self.create_program(course_id=course_id) - self.stub_api([program]) + self.stub_programs_api([program]) self.auth() self.listing_page.visit() @@ -95,7 +105,7 @@ class ProgramListingPageTest(ProgramPageBase): which are included in at least one active program. """ program = self.create_program() - self.stub_api([program]) + self.stub_programs_api([program]) self.auth() self.listing_page.visit() @@ -113,7 +123,7 @@ class ProgramListingPageA11yTest(ProgramPageBase): self.listing_page = ProgramListingPage(self.browser) program = self.create_program() - self.stub_api([program]) + self.stub_programs_api([program]) def test_empty_a11y(self): """Test a11y of the page's empty state.""" @@ -143,7 +153,7 @@ class ProgramDetailsPageA11yTest(ProgramPageBase): self.details_page = ProgramDetailsPage(self.browser) program = self.create_program(program_id=self.details_page.program_id) - self.stub_api([program], is_list=False) + self.stub_programs_api([program], is_list=False) def test_a11y(self): """Test the page's a11y compliance.""" diff --git a/lms/djangoapps/learner_dashboard/tests/test_programs.py b/lms/djangoapps/learner_dashboard/tests/test_programs.py index 835cf2f45e1..5c96412937c 100644 --- a/lms/djangoapps/learner_dashboard/tests/test_programs.py +++ b/lms/djangoapps/learner_dashboard/tests/test_programs.py @@ -14,6 +14,7 @@ from django.test import override_settings from django.utils.text import slugify from edx_oauth2_provider.tests.factories import ClientFactory import httpretty +import mock from provider.constants import CONFIDENTIAL from openedx.core.djangoapps.credentials.models import CredentialsApiConfig @@ -28,6 +29,10 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' +MARKETING_URL = 'https://www.example.com/marketing/path' + + @httpretty.activate @override_settings(MKTG_URLS={'ROOT': 'https://www.example.com'}) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @@ -290,6 +295,7 @@ class TestProgramListing(ProgramsApiConfigMixin, CredentialsApiConfigMixin, Shar @httpretty.activate @override_settings(MKTG_URLS={'ROOT': 'https://www.example.com'}) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +@mock.patch(UTILS_MODULE + '.get_run_marketing_url', mock.Mock(return_value=MARKETING_URL)) class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase): """Unit tests for the program details page.""" program_id = 123 diff --git a/lms/envs/bok_choy.env.json b/lms/envs/bok_choy.env.json index 1f04c40e993..1454089e1b5 100644 --- a/lms/envs/bok_choy.env.json +++ b/lms/envs/bok_choy.env.json @@ -92,6 +92,9 @@ }, "FEEDBACK_SUBMISSION_EMAIL": "", "GITHUB_REPO_ROOT": "** OVERRIDDEN **", + "JWT_AUTH": { + "JWT_SECRET_KEY": "super-secret-key" + }, "LMS_BASE": "localhost:8003", "LOCAL_LOGLEVEL": "INFO", "LOGGING_ENV": "sandbox", diff --git a/lms/static/js/learner_dashboard/models/course_card_model.js b/lms/static/js/learner_dashboard/models/course_card_model.js index 51bc9b5c75c..2a0d6c36b37 100644 --- a/lms/static/js/learner_dashboard/models/course_card_model.js +++ b/lms/static/js/learner_dashboard/models/course_card_model.js @@ -72,7 +72,7 @@ is_enrolled: runMode.is_enrolled, is_enrollment_open: runMode.is_enrollment_open, key: this.context.key, - marketing_url: runMode.marketing_url || '', + marketing_url: runMode.marketing_url, mode_slug: runMode.mode_slug, run_key: runMode.run_key, start_date: runMode.start_date, diff --git a/lms/static/js/spec/learner_dashboard/course_card_view_spec.js b/lms/static/js/spec/learner_dashboard/course_card_view_spec.js index ef1ced7787e..15791e3653b 100644 --- a/lms/static/js/spec/learner_dashboard/course_card_view_spec.js +++ b/lms/static/js/spec/learner_dashboard/course_card_view_spec.js @@ -23,13 +23,13 @@ define([ course_image_url: 'http://test.com/image1', course_key: 'course-v1:ANUx+ANU-ASTRO1x+3T2015', course_started: true, - course_url: 'http://localhost:8000/courses/course-v1:edX+DemoX+Demo_Course/info', + course_url: 'https://courses.example.com/courses/course-v1:edX+DemoX+Demo_Course', end_date: 'Jun 13, 2019', enrollment_open_date: 'Mar 03, 2016', is_course_ended: false, is_enrolled: true, is_enrollment_open: true, - marketing_url: 'https://www.edx.org/course/astrophysics-exploring', + marketing_url: 'https://www.example.com/marketing/site', mode_slug: 'verified', run_key: '2T2016', start_date: 'Apr 25, 2016', @@ -53,7 +53,8 @@ define([ expect(view.$('.header-img').attr('src')).toEqual(context.run_modes[0].course_image_url); expect(view.$('.course-details .course-title-link').text().trim()).toEqual(context.display_name); expect(view.$('.course-details .course-title-link').attr('href')).toEqual( - context.run_modes[0].course_url); + context.run_modes[0].marketing_url + ); expect(view.$('.course-details .course-text .course-key').html()).toEqual(context.key); expect(view.$('.course-details .course-text .run-period').html()) .toEqual(context.run_modes[0].start_date + ' - ' + context.run_modes[0].end_date); @@ -140,7 +141,6 @@ define([ setupView(data, false); expect(view.$('.header-img').attr('src')).toEqual(data.run_modes[0].course_image_url); expect(view.$('.course-details .course-title').text().trim()).toEqual(data.display_name); - expect(view.$('.course-details .course-title-link').length).toBe(0); expect(view.$('.course-details .course-text .course-key').html()).toEqual(data.key); expect(view.$('.course-details .course-text .run-period').length).toBe(0); expect(view.$('.no-action-message').text().trim()).toBe('Coming Soon'); @@ -156,6 +156,35 @@ define([ setupView(data, false); validateCourseInfoDisplay(); }); + + it('should link to the marketing site when a URL is available', function(){ + $.each([ '.course-image-link', '.course-title-link' ], function( index, selector ) { + expect(view.$(selector).attr('href')).toEqual(context.run_modes[0].marketing_url); + }); + }); + + it('should link to the course home when no marketing URL is available', function(){ + var data = $.extend({}, context); + + data.run_modes[0].marketing_url = null; + setupView(data, false); + + $.each([ '.course-image-link', '.course-title-link' ], function( index, selector ) { + expect(view.$(selector).attr('href')).toEqual(context.run_modes[0].course_url); + }); + }); + + it('should not link to the marketing site or the course home if neither URL is available', function(){ + var data = $.extend({}, context); + + data.run_modes[0].marketing_url = null; + data.run_modes[0].course_url = null; + setupView(data, false); + + $.each([ '.course-image-link', '.course-title-link' ], function( index, selector ) { + expect(view.$(selector).length).toEqual(0); + }); + }); }); } ); diff --git a/lms/templates/learner_dashboard/course_card.underscore b/lms/templates/learner_dashboard/course_card.underscore index e50ec8e8c5d..fe035c459c7 100644 --- a/lms/templates/learner_dashboard/course_card.underscore +++ b/lms/templates/learner_dashboard/course_card.underscore @@ -1,33 +1,31 @@ <div class="section"> <div class="course-meta-container col-12 md-col-8 sm-col-12"> <div class="course-image-container"> - <% if (course_url){ %> - <a href="<%- course_url %>" class="course-image-link"> + <% if ( marketing_url || course_url ) { %> + <a href="<%- marketing_url || course_url %>" class="course-image-link"> <img class="header-img" src="<%- course_image_url %>" + <% // safe-lint: disable=underscore-not-escaped %> alt="<%= interpolate(gettext('%(courseName)s Home Page.'), {courseName: display_name}, true) %>"/> </a> <% } else { %> - <img - class="header-img" - src="<%- course_image_url %>" - alt="" /> + <img class="header-img" src="<%- course_image_url %>" alt=""/> <% } %> </div> <div class="course-details"> <h3 class="course-title"> - <% if (course_url){ %> - <a href="<%- course_url %>" class="course-title-link"> + <% if ( marketing_url || course_url ) { %> + <a href="<%- marketing_url || course_url %>" class="course-title-link"> <%- display_name %> </a> - <% }else{ %> + <% } else { %> <%- display_name %> <% } %> </h3> <div class="course-text"> - <% if (start_date && end_date){ %> + <% if (start_date && end_date) { %> <span class="run-period"><%- start_date %> - <%- end_date %></span> - <% } %> diff --git a/lms/urls.py b/lms/urls.py index 3a100daba52..d197b64d7dc 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -13,6 +13,7 @@ import auth_exchange.views from courseware.views.views import EnrollStaffView from config_models.views import ConfigurationModelCurrentAPIView from courseware.views.index import CoursewareIndex +from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from student.views import LogoutView @@ -969,6 +970,7 @@ if settings.FEATURES.get("ENABLE_LTI_PROVIDER"): urlpatterns += ( url(r'config/self_paced', ConfigurationModelCurrentAPIView.as_view(model=SelfPacedConfiguration)), url(r'config/programs', ConfigurationModelCurrentAPIView.as_view(model=ProgramsApiConfig)), + url(r'config/catalog', ConfigurationModelCurrentAPIView.as_view(model=CatalogIntegration)), ) urlpatterns = patterns(*urlpatterns) diff --git a/openedx/core/djangoapps/catalog/tests/factories.py b/openedx/core/djangoapps/catalog/tests/factories.py new file mode 100644 index 00000000000..4136f5d3a4e --- /dev/null +++ b/openedx/core/djangoapps/catalog/tests/factories.py @@ -0,0 +1,14 @@ +"""Factories for generating fake catalog data.""" +import factory +from factory.fuzzy import FuzzyText + + +class CourseRun(factory.Factory): + """ + Factory for stubbing CourseRun resources from the catalog API. + """ + class Meta(object): + model = dict + + key = FuzzyText(prefix='org/', suffix='/run') + marketing_url = FuzzyText(prefix='https://www.example.com/marketing/') diff --git a/openedx/core/djangoapps/catalog/tests/test_utils.py b/openedx/core/djangoapps/catalog/tests/test_utils.py new file mode 100644 index 00000000000..690c238427f --- /dev/null +++ b/openedx/core/djangoapps/catalog/tests/test_utils.py @@ -0,0 +1,120 @@ +"""Tests covering utilities for integrating with the catalog service.""" +import ddt +from django.test import TestCase +import mock +from opaque_keys.edx.keys import CourseKey + +from openedx.core.djangoapps.catalog import utils +from openedx.core.djangoapps.catalog.tests import factories, mixins +from student.tests.factories import UserFactory + + +UTILS_MODULE = 'openedx.core.djangoapps.catalog.utils' + + +@mock.patch(UTILS_MODULE + '.get_edx_api_data') +class TestGetCourseRun(mixins.CatalogIntegrationMixin, TestCase): + """Tests covering retrieval of course runs from the catalog service.""" + def setUp(self): + super(TestGetCourseRun, self).setUp() + + self.user = UserFactory() + self.course_key = CourseKey.from_string('foo/bar/baz') + self.catalog_integration = self.create_catalog_integration() + + def assert_contract(self, call_args): + """Verify that API data retrieval utility is used correctly.""" + args, kwargs = call_args + + for arg in (self.catalog_integration, self.user, 'course_runs'): + self.assertIn(arg, args) + + self.assertEqual(kwargs['resource_id'], unicode(self.course_key)) + self.assertEqual(kwargs['api']._store['base_url'], self.catalog_integration.internal_api_url) # pylint: disable=protected-access + + return args, kwargs + + def test_get_course_run(self, mock_get_catalog_data): + course_run = factories.CourseRun() + mock_get_catalog_data.return_value = course_run + + data = utils.get_course_run(self.course_key, self.user) + + self.assert_contract(mock_get_catalog_data.call_args) + self.assertEqual(data, course_run) + + def test_course_run_unavailable(self, mock_get_catalog_data): + mock_get_catalog_data.return_value = [] + + data = utils.get_course_run(self.course_key, self.user) + + self.assert_contract(mock_get_catalog_data.call_args) + self.assertEqual(data, {}) + + def test_cache_disabled(self, mock_get_catalog_data): + utils.get_course_run(self.course_key, self.user) + + _, kwargs = self.assert_contract(mock_get_catalog_data.call_args) + + self.assertIsNone(kwargs['cache_key']) + + def test_cache_enabled(self, mock_get_catalog_data): + catalog_integration = self.create_catalog_integration(cache_ttl=1) + + utils.get_course_run(self.course_key, self.user) + + _, kwargs = mock_get_catalog_data.call_args + + self.assertEqual(kwargs['cache_key'], catalog_integration.CACHE_KEY) + + +@mock.patch(UTILS_MODULE + '.get_course_run') +@mock.patch(UTILS_MODULE + '.strip_querystring') +class TestGetRunMarketingUrl(TestCase): + """Tests covering retrieval of course run marketing URLs.""" + def setUp(self): + super(TestGetRunMarketingUrl, self).setUp() + + self.course_key = CourseKey.from_string('foo/bar/baz') + self.user = UserFactory() + + def test_get_run_marketing_url(self, mock_strip, mock_get_course_run): + course_run = factories.CourseRun() + mock_get_course_run.return_value = course_run + mock_strip.return_value = course_run['marketing_url'] + + url = utils.get_run_marketing_url(self.course_key, self.user) + + self.assertTrue(mock_strip.called) + self.assertEqual(url, course_run['marketing_url']) + + def test_marketing_url_empty(self, mock_strip, mock_get_course_run): + course_run = factories.CourseRun() + course_run['marketing_url'] = '' + mock_get_course_run.return_value = course_run + + url = utils.get_run_marketing_url(self.course_key, self.user) + + self.assertFalse(mock_strip.called) + self.assertEqual(url, None) + + def test_marketing_url_missing(self, mock_strip, mock_get_course_run): + mock_get_course_run.return_value = {} + + url = utils.get_run_marketing_url(self.course_key, self.user) + + self.assertFalse(mock_strip.called) + self.assertEqual(url, None) + + +@ddt.ddt +class TestStripQuerystring(TestCase): + """Tests covering querystring stripping.""" + bare_url = 'https://www.example.com/path' + + @ddt.data( + bare_url, + bare_url + '?foo=bar&baz=qux', + ) + def test_strip_querystring(self, url): + self.assertEqual(utils.strip_querystring(url), self.bare_url) diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py new file mode 100644 index 00000000000..1c762f3faec --- /dev/null +++ b/openedx/core/djangoapps/catalog/utils.py @@ -0,0 +1,69 @@ +"""Helper functions for working with the catalog service.""" +from urlparse import urlparse + +from django.conf import settings +from edx_rest_api_client.client import EdxRestApiClient + +from openedx.core.djangoapps.catalog.models import CatalogIntegration +from openedx.core.lib.edx_api_utils import get_edx_api_data +from openedx.core.lib.token_utils import JwtBuilder + + +def get_course_run(course_key, user): + """Get a course run's data from the course catalog service. + + Arguments: + course_key (CourseKey): Course key object identifying the run whose data we want. + user (User): The user to authenticate as when making requests to the catalog service. + + Returns: + dict, empty if no data could be retrieved. + """ + catalog_integration = CatalogIntegration.current() + + scopes = ['email', 'profile'] + expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION + jwt = JwtBuilder(user).build_token(scopes, expires_in) + api = EdxRestApiClient(catalog_integration.internal_api_url, jwt=jwt) + + data = get_edx_api_data( + catalog_integration, + user, + 'course_runs', + resource_id=unicode(course_key), + cache_key=catalog_integration.CACHE_KEY if catalog_integration.is_cache_enabled else None, + api=api, + ) + + return data if data else {} + + +def get_run_marketing_url(course_key, user): + """Get a course run's marketing URL from the course catalog service. + + Arguments: + course_key (CourseKey): Course key object identifying the run whose marketing URL we want. + user (User): The user to authenticate as when making requests to the catalog service. + + Returns: + string, the marketing URL, or None if no URL is available. + """ + course_run = get_course_run(course_key, user) + marketing_url = course_run.get('marketing_url') + + if marketing_url: + # This URL may include unwanted UTM parameters in the querystring. + # For more, see https://en.wikipedia.org/wiki/UTM_parameters. + return strip_querystring(marketing_url) + else: + return None + + +def strip_querystring(url): + """Strip the querystring from the provided URL. + + urlparse's ParseResult is a subclass of namedtuple. _replace is part of namedtuple's + public API: https://docs.python.org/2/library/collections.html#collections.somenamedtuple._replace. + The name starts with an underscore to prevent conflicts with field names. + """ + return urlparse(url)._replace(query='').geturl() # pylint: disable=no-member diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index e0f4398db72..c49ff9c03e7 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -36,7 +36,8 @@ from xmodule.modulestore.tests.factories import CourseFactory UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' CERTIFICATES_API_MODULE = 'lms.djangoapps.certificates.api' -ECOMMERCE_URL_ROOT = 'http://example-ecommerce.com' +ECOMMERCE_URL_ROOT = 'https://example-ecommerce.com' +MARKETING_URL = 'https://www.example.com/marketing/path' @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @@ -677,6 +678,7 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): @ddt.ddt @override_settings(ECOMMERCE_PUBLIC_URL_ROOT=ECOMMERCE_URL_ROOT) @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +@mock.patch(UTILS_MODULE + '.get_run_marketing_url', mock.Mock(return_value=MARKETING_URL)) class TestSupplementProgramData(ProgramsApiConfigMixin, ModuleStoreTestCase): """Tests of the utility function used to supplement program data.""" maxDiff = None @@ -719,7 +721,7 @@ class TestSupplementProgramData(ProgramsApiConfigMixin, ModuleStoreTestCase): is_course_ended=self.course.end < timezone.now(), is_enrolled=False, is_enrollment_open=True, - marketing_url=None, + marketing_url=MARKETING_URL, start_date=strftime_localized(self.course.start, 'SHORT_DATE'), upgrade_url=None, ), diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 2f5006f4c38..3f9b8d9a7ab 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -13,6 +13,7 @@ import pytz from course_modes.models import CourseMode from lms.djangoapps.certificates import api as certificate_api from lms.djangoapps.commerce.utils import EcommerceService +from openedx.core.djangoapps.catalog.utils import get_run_marketing_url from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.lib.edx_api_utils import get_edx_api_data @@ -392,8 +393,7 @@ def supplement_program_data(program_data, user): 'is_course_ended': is_course_ended, 'is_enrolled': is_enrolled, 'is_enrollment_open': is_enrollment_open, - # TODO: Not currently available on LMS. - 'marketing_url': None, + 'marketing_url': get_run_marketing_url(course_key, user), 'start_date': start_date_string, 'upgrade_url': upgrade_url, }) diff --git a/pavelib/utils/envs.py b/pavelib/utils/envs.py index 11c27dc1fbf..9a16d986798 100644 --- a/pavelib/utils/envs.py +++ b/pavelib/utils/envs.py @@ -103,15 +103,20 @@ class Env(object): 'log': BOK_CHOY_LOG_DIR / "bok_choy_edxnotes.log", }, + 'ecommerce': { + 'port': 8043, + 'log': BOK_CHOY_LOG_DIR / "bok_choy_ecommerce.log", + }, + 'programs': { 'port': 8090, 'log': BOK_CHOY_LOG_DIR / "bok_choy_programs.log", }, - 'ecommerce': { - 'port': 8043, - 'log': BOK_CHOY_LOG_DIR / "bok_choy_ecommerce.log", - } + 'catalog': { + 'port': 8091, + 'log': BOK_CHOY_LOG_DIR / "bok_choy_catalog.log", + }, } # Mongo databases that will be dropped before/after the tests run -- GitLab