Commit a43c507a by Renzo Lucioni

Use course run marketing URLs from the catalog service on program detail page

Part of ECOM-4566.
parent b1fcc51c
"""
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
......@@ -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
......
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')
"""
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()
"""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."""
......
......@@ -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
......
......@@ -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",
......
......@@ -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,
......
......@@ -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);
});
});
});
}
);
<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>
-
<% } %>
......
......@@ -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)
......
"""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/')
"""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)
"""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
......@@ -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,
),
......
......@@ -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,
})
......
......@@ -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
......
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