Commit 863782d0 by Renzo Lucioni Committed by GitHub

Merge pull request #12991 from edx/renzo/run-marketing-urls

Use course run marketing URLs from the catalog service on program detail page
parents ff247a68 a43c507a
"""
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