diff --git a/.eslintrc.json b/.eslintrc.json index 36d4a7a626..c76fcbe5bf 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": "eslint-config-edx", + "extends": "eslint-config-edx-es5", "globals": { // Try to avoid adding any new globals. // Old compatibility things and hacks "edx": true, diff --git a/cms/celery.py b/cms/celery.py index e35bf4d7c1..49f31fbfa0 100644 --- a/cms/celery.py +++ b/cms/celery.py @@ -33,4 +33,5 @@ class Router(AlternateEnvironmentRouter): """ return { 'openedx.core.djangoapps.content.block_structure.tasks.update_course_in_cache': 'lms', + 'openedx.core.djangoapps.content.block_structure.tasks.update_course_in_cache_v2': 'lms', } diff --git a/cms/djangoapps/contentstore/tests/test_import.py b/cms/djangoapps/contentstore/tests/test_import.py index e6c3e152f2..6b271d7c82 100644 --- a/cms/djangoapps/contentstore/tests/test_import.py +++ b/cms/djangoapps/contentstore/tests/test_import.py @@ -41,7 +41,7 @@ class ContentStoreImportTest(SignalDisconnectTestMixin, ModuleStoreTestCase): self.client.login(username=self.user.username, password=self.user_password) # block_structure.update_course_in_cache cannot succeed in tests, as it needs to be run async on an lms worker - self.task_patcher = patch('openedx.core.djangoapps.content.block_structure.tasks.update_course_in_cache') + self.task_patcher = patch('openedx.core.djangoapps.content.block_structure.tasks.update_course_in_cache_v2') self._mock_lms_task = self.task_patcher.start() def tearDown(self): diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index 46e1ba2740..55da469595 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -6,6 +6,7 @@ from datetime import datetime import unittest import decimal import ddt +import httpretty import freezegun from mock import patch from nose.plugins.attrib import attr @@ -25,12 +26,14 @@ from student.models import CourseEnrollment from student.tests.factories import CourseEnrollmentFactory, UserFactory from util.testing import UrlResetMixin from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme +from util.tests.mixins.enterprise import EnterpriseServiceMockMixin +from util import organizations_helpers as organizations_api @attr(shard=3) @ddt.ddt @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): +class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase, EnterpriseServiceMockMixin): """ Course Mode View tests """ @@ -44,6 +47,7 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): self.client.login(username=self.user.username, password="edx") @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + @httpretty.activate @ddt.data( # is_active?, enrollment_mode, redirect? (True, 'verified', True), @@ -69,6 +73,14 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): user=self.user ) + self.mock_enterprise_learner_api() + # Create a service user and log in. + UserFactory.create( + username='enterprise_worker', + email="bob@example.com", + password="edx", + ) + # Configure whether we're upgrading or not url = reverse('course_modes_choose', args=[unicode(self.course.id)]) response = self.client.get(url) @@ -118,17 +130,101 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): self.assertRedirects(response, 'http://testserver/test_basket/?sku=TEST', fetch_redirect_response=False) ecomm_test_utils.update_commerce_config(enabled=False) + @httpretty.activate def test_no_enrollment(self): # Create the course modes for mode in ('audit', 'honor', 'verified'): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) + self.mock_enterprise_learner_api() + # Create a service user and log in. + UserFactory.create( + username='enterprise_worker', + email="bob@example.com", + password="edx", + ) + # User visits the track selection page directly without ever enrolling url = reverse('course_modes_choose', args=[unicode(self.course.id)]) response = self.client.get(url) self.assertEquals(response.status_code, 200) + @httpretty.activate + def test_enterprise_learner_context(self): + """ + Test: Track selection page should show the enterprise context message if user belongs to the Enterprise. + """ + # Create the course modes + for mode in ('audit', 'honor', 'verified'): + CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) + + self.mock_enterprise_learner_api() + # Create a service user and log in. + UserFactory.create( + username='enterprise_worker', + email="bob@example.com", + password="edx", + ) + + # User visits the track selection page directly without ever enrolling + url = reverse('course_modes_choose', args=[unicode(self.course.id)]) + response = self.client.get(url) + self.assertEquals(response.status_code, 200) + self.assertContains( + response, + 'Welcome, {username}! You are about to enroll in {course_name}, from {partner_names}, ' + 'sponsored by TestShib. Please select your enrollment information below.'.format( + username=self.user.username, + course_name=self.course.display_name_with_default_escaped, + partner_names=self.course.org + ) + ) + + @httpretty.activate + def test_enterprise_learner_context_with_multiple_organizations(self): + """ + Test: Track selection page should show the enterprise context message with multiple organization names + if user belongs to the Enterprise. + """ + # Create the course modes + for mode in ('audit', 'honor', 'verified'): + CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) + + self.mock_enterprise_learner_api() + # Create a service user and log in. + UserFactory.create( + username='enterprise_worker', + email="bob@example.com", + password="edx", + ) + + # Creating organization + for i in xrange(2): + test_organization_data = { + 'name': 'test organization ' + str(i), + 'short_name': 'test_organization_' + str(i), + 'description': 'Test Organization Description', + 'active': True, + 'logo': '/logo_test1.png/' + } + test_org = organizations_api.add_organization(organization_data=test_organization_data) + organizations_api.add_organization_course(organization_data=test_org, course_id=unicode(self.course.id)) + + # User visits the track selection page directly without ever enrolling + url = reverse('course_modes_choose', args=[unicode(self.course.id)]) + response = self.client.get(url) + self.assertEquals(response.status_code, 200) + self.assertContains( + response, + 'Welcome, {username}! You are about to enroll in {course_name}, from test organization 0 and ' + 'test organization 1, sponsored by TestShib. Please select your enrollment information below.'.format( + username=self.user.username, + course_name=self.course.display_name_with_default_escaped + ) + ) + + @httpretty.activate @ddt.data( '', '1,,2', @@ -155,6 +251,14 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): user=self.user ) + self.mock_enterprise_learner_api() + # Create a service user and log in. + UserFactory.create( + username='enterprise_worker', + email="bob@example.com", + password="edx", + ) + # Verify that the prices render correctly response = self.client.get( reverse('course_modes_choose', args=[unicode(self.course.id)]), @@ -165,6 +269,7 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): # TODO: Fix it so that response.templates works w/ mako templates, and then assert # that the right template rendered + @httpretty.activate @ddt.data( (['honor', 'verified', 'credit'], True), (['honor', 'verified'], False), @@ -175,6 +280,14 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): for mode in available_modes: CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) + self.mock_enterprise_learner_api() + # Create a service user and log in. + UserFactory.create( + username='enterprise_worker', + email="bob@example.com", + password="edx", + ) + # Check whether credit upsell is shown on the page # This should *only* be shown when a credit mode is available url = reverse('course_modes_choose', args=[unicode(self.course.id)]) @@ -375,11 +488,20 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @with_comprehensive_theme("edx.org") + @httpretty.activate def test_hide_nav(self): # Create the course modes for mode in ["honor", "verified"]: CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) + self.mock_enterprise_learner_api() + # Create a service user and log in. + UserFactory.create( + username='enterprise_worker', + email="bob@example.com", + password="edx", + ) + # Load the track selection page url = reverse('course_modes_choose', args=[unicode(self.course.id)]) response = self.client.get(url) @@ -406,7 +528,7 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase): +class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase, EnterpriseServiceMockMixin): """Test embargo restrictions on the track selection page. """ URLCONF_MODULES = ['openedx.core.djangoapps.embargo'] @@ -433,6 +555,15 @@ class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase): response = self.client.get(self.url) self.assertRedirects(response, redirect_url) + @httpretty.activate def test_embargo_allow(self): + + self.mock_enterprise_learner_api() + # Create a service user and log in. + UserFactory.create( + username='enterprise_worker', + email="bob@example.com", + password="edx", + ) response = self.client.get(self.url) self.assertEqual(response.status_code, 200) diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 65a1934391..6f2eba5204 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -26,6 +26,8 @@ from edxmako.shortcuts import render_to_response from openedx.core.djangoapps.embargo import api as embargo_api from student.models import CourseEnrollment from util.db import outer_atomic +from util import enterprise_helpers as enterprise_api +from util import organizations_helpers as organization_api class ChooseModeView(View): @@ -148,6 +150,20 @@ class ChooseModeView(View): "responsive": True, "nav_hidden": True, } + + enterprise_learner_data = enterprise_api.get_enterprise_learner_data(site=request.site, user=request.user) + if enterprise_learner_data: + context["show_enterprise_context"] = True + context["partner_names"] = partner_name = course.display_organization \ + if course.display_organization else course.org + context["enterprise_name"] = enterprise_learner_data[0]['enterprise_customer']['name'] + context["username"] = request.user.username + organizations = organization_api.get_course_organizations(course_id=course.id) + if organizations: + context["partner_names"] = ' and '.join([ + org.get('name', partner_name) for org in organizations + ]) + if "verified" in modes: verified_mode = modes["verified"] context["suggested_prices"] = [ diff --git a/common/djangoapps/newrelic_custom_metrics/middleware.py b/common/djangoapps/newrelic_custom_metrics/middleware.py index 3af8cb5ef1..3d6bcd89f9 100644 --- a/common/djangoapps/newrelic_custom_metrics/middleware.py +++ b/common/djangoapps/newrelic_custom_metrics/middleware.py @@ -6,7 +6,14 @@ This middleware will only call on the newrelic agent if there are any metrics to report for this request, so it will not incur any processing overhead for request handlers which do not record custom metrics. """ -import newrelic.agent +import logging +log = logging.getLogger(__name__) +try: + import newrelic.agent +except ImportError: + log.warning("Unable to load NewRelic agent module") + newrelic = None # pylint: disable=invalid-name + import request_cache REQUEST_CACHE_KEY = 'newrelic_custom_metrics' @@ -40,6 +47,8 @@ class NewRelicCustomMetrics(object): """ Report the collected custom metrics to New Relic. """ + if not newrelic: + return metrics_cache = cls._get_metrics_cache() for metric_name, metric_value in metrics_cache.iteritems(): newrelic.agent.add_custom_parameter(metric_name, metric_value) diff --git a/common/djangoapps/student/tests/test_recent_enrollments.py b/common/djangoapps/student/tests/test_recent_enrollments.py index 8dfd143cfe..209dd45796 100644 --- a/common/djangoapps/student/tests/test_recent_enrollments.py +++ b/common/djangoapps/student/tests/test_recent_enrollments.py @@ -18,6 +18,7 @@ from course_modes.tests.factories import CourseModeFactory from student.models import CourseEnrollment, DashboardConfiguration from student.views import get_course_enrollments, _get_recently_enrolled_courses from common.test.utils import XssTestMixin +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context @attr(shard=3) @@ -211,3 +212,29 @@ class TestRecentEnrollments(ModuleStoreTestCase, XssTestMixin): self.client.login(username=self.student.username, password=self.PASSWORD) response = self.client.get(reverse("dashboard")) self.assertNotContains(response, "donate-container") + + @ddt.data( + (True, False,), + (True, True,), + (False, False,), + (False, True,), + ) + @ddt.unpack + def test_donate_button_with_enabled_site_configuration(self, enable_donation_config, enable_donation_site_config): + # Enable the enrollment success message and donations + self._configure_message_timeout(10000) + + # DonationConfiguration has low precedence if 'ENABLE_DONATIONS' is enable in SiteConfiguration + DonationConfiguration(enabled=enable_donation_config).save() + + CourseModeFactory.create(mode_slug="audit", course_id=self.course.id, min_price=0) + self.enrollment.mode = "audit" + self.enrollment.save() + self.client.login(username=self.student.username, password=self.PASSWORD) + + with with_site_configuration_context(configuration={'ENABLE_DONATIONS': enable_donation_site_config}): + response = self.client.get(reverse("dashboard")) + if enable_donation_site_config: + self.assertContains(response, "donate-container") + else: + self.assertNotContains(response, "donate-container") diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 777d58d6ad..bc1c82d9f1 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -936,7 +936,10 @@ def _allow_donation(course_modes, course_id, enrollment): flat_unexpired_modes, flat_all_modes ) - donations_enabled = DonationConfiguration.current().enabled + donations_enabled = configuration_helpers.get_value( + 'ENABLE_DONATIONS', + DonationConfiguration.current().enabled + ) return ( donations_enabled and enrollment.mode in course_modes[course_id] and diff --git a/common/djangoapps/terrain/stubs/tests/test_video.py b/common/djangoapps/terrain/stubs/tests/test_video.py new file mode 100644 index 0000000000..de7324bf4d --- /dev/null +++ b/common/djangoapps/terrain/stubs/tests/test_video.py @@ -0,0 +1,44 @@ +""" +Unit tests for Video stub server implementation. +""" +import unittest +import requests +from terrain.stubs.video_source import VideoSourceHttpService +from django.conf import settings + +HLS_MANIFEST_TEXT = """ +#EXTM3U +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=264787,RESOLUTION=1280x720 +history_264kbit/history_264kbit.m3u8 +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=328415,RESOLUTION=1920x1080 +history_328kbit/history_328kbit.m3u8 +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=70750,RESOLUTION=640x360 +history_70kbit/history_70kbit.m3u8 +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=148269,RESOLUTION=960x540 +history_148kbit/history_148kbit.m3u8 +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=41276,RESOLUTION=640x360 +history_41kbit/history_41kbit.m3u8 +""" + + +class StubVideoServiceTest(unittest.TestCase): + """ + Test cases for the video stub service. + """ + def setUp(self): + """ + Start the stub server. + """ + super(StubVideoServiceTest, self).setUp() + self.server = VideoSourceHttpService() + self.server.config['root_dir'] = '{}/data/video'.format(settings.TEST_ROOT) + self.addCleanup(self.server.shutdown) + + def test_get_hls_manifest(self): + """ + Verify that correct hls manifest is received. + """ + response = requests.get("http://127.0.0.1:{port}/hls/history.m3u8".format(port=self.server.port)) + self.assertTrue(response.ok) + self.assertEqual(response.text, HLS_MANIFEST_TEXT.lstrip()) + self.assertEqual(response.headers['Access-Control-Allow-Origin'], '*') diff --git a/common/djangoapps/terrain/stubs/video_source.py b/common/djangoapps/terrain/stubs/video_source.py index fa48834546..1265ba286d 100644 --- a/common/djangoapps/terrain/stubs/video_source.py +++ b/common/djangoapps/terrain/stubs/video_source.py @@ -24,6 +24,13 @@ class VideoSourceRequestHandler(SimpleHTTPRequestHandler): path = '{}{}'.format(root_dir, path) return path.split('?')[0] + def end_headers(self): + """ + This is required by hls.js to play hls videos. + """ + self.send_header('Access-Control-Allow-Origin', '*') + SimpleHTTPRequestHandler.end_headers(self) + class VideoSourceHttpService(StubHttpService): """ diff --git a/common/djangoapps/util/enterprise_helpers.py b/common/djangoapps/util/enterprise_helpers.py index be3ff19751..6d5ff1788e 100644 --- a/common/djangoapps/util/enterprise_helpers.py +++ b/common/djangoapps/util/enterprise_helpers.py @@ -9,6 +9,7 @@ from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.shortcuts import redirect from django.utils.http import urlencode +from django.core.cache import cache from edx_rest_api_client.client import EdxRestApiClient try: from enterprise import utils as enterprise_utils @@ -18,6 +19,8 @@ except ImportError: from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.lib.token_utils import JwtBuilder from slumber.exceptions import HttpClientError, HttpServerError +import hashlib +import six ENTERPRISE_CUSTOMER_BRANDING_OVERRIDE_DETAILS = 'enterprise_customer_branding_override_details' @@ -71,6 +74,108 @@ class EnterpriseApiClient(object): LOGGER.exception(message) raise EnterpriseApiException(message) + def fetch_enterprise_learner_data(self, site, user): + """ + Fetch information related to enterprise from the Enterprise Service. + + Example: + fetch_enterprise_learner_data(site, user) + + Argument: + site: (Site) site instance + user: (User) django auth user + + Returns: + dict: { + "enterprise_api_response_for_learner": { + "count": 1, + "num_pages": 1, + "current_page": 1, + "results": [ + { + "enterprise_customer": { + "uuid": "cf246b88-d5f6-4908-a522-fc307e0b0c59", + "name": "TestShib", + "catalog": 2, + "active": true, + "site": { + "domain": "example.com", + "name": "example.com" + }, + "enable_data_sharing_consent": true, + "enforce_data_sharing_consent": "at_login", + "enterprise_customer_users": [ + 1 + ], + "branding_configuration": { + "enterprise_customer": "cf246b88-d5f6-4908-a522-fc307e0b0c59", + "logo": "https://open.edx.org/sites/all/themes/edx_open/logo.png" + }, + "enterprise_customer_entitlements": [ + { + "enterprise_customer": "cf246b88-d5f6-4908-a522-fc307e0b0c59", + "entitlement_id": 69 + } + ] + }, + "user_id": 5, + "user": { + "username": "staff", + "first_name": "", + "last_name": "", + "email": "staff@example.com", + "is_staff": true, + "is_active": true, + "date_joined": "2016-09-01T19:18:26.026495Z" + }, + "data_sharing_consent": [ + { + "user": 1, + "state": "enabled", + "enabled": true + } + ] + } + ], + "next": null, + "start": 0, + "previous": null + } + } + + Raises: + ConnectionError: requests exception "ConnectionError", raised if if ecommerce is unable to connect + to enterprise api server. + SlumberBaseException: base slumber exception "SlumberBaseException", raised if API response contains + http error status like 4xx, 5xx etc. + Timeout: requests exception "Timeout", raised if enterprise API is taking too long for returning + a response. This exception is raised for both connection timeout and read timeout. + + """ + api_resource_name = 'enterprise-learner' + + cache_key = get_cache_key( + site_domain=site.domain, + resource=api_resource_name, + username=user.username + ) + + response = cache.get(cache_key) + if not response: + try: + endpoint = getattr(self.client, api_resource_name) + querystring = {'username': user.username} + response = endpoint().get(**querystring) + cache.set(cache_key, response, settings.ENTERPRISE_API_CACHE_TIMEOUT) + except (HttpClientError, HttpServerError): + message = ("An error occurred while getting EnterpriseLearner data for user {username}".format( + username=user.username + )) + LOGGER.exception(message) + return None + + return response + def data_sharing_consent_required(view_func): """ @@ -225,3 +330,39 @@ def get_enterprise_branding_filter_param(request): """ return request.session.get(ENTERPRISE_CUSTOMER_BRANDING_OVERRIDE_DETAILS, None) + + +def get_cache_key(**kwargs): + """ + Get MD5 encoded cache key for given arguments. + + Here is the format of key before MD5 encryption. + key1:value1__key2:value2 ... + + Example: + >>> get_cache_key(site_domain="example.com", resource="enterprise-learner") + # Here is key format for above call + # "site_domain:example.com__resource:enterprise-learner" + a54349175618ff1659dee0978e3149ca + + Arguments: + **kwargs: Key word arguments that need to be present in cache key. + + Returns: + An MD5 encoded key uniquely identified by the key word arguments. + """ + key = '__'.join(['{}:{}'.format(item, value) for item, value in six.iteritems(kwargs)]) + + return hashlib.md5(key).hexdigest() + + +def get_enterprise_learner_data(site, user): + """ + Client API operation adapter/wrapper + """ + if not enterprise_enabled(): + return None + + enterprise_learner_data = EnterpriseApiClient().fetch_enterprise_learner_data(site=site, user=user) + if enterprise_learner_data: + return enterprise_learner_data['results'] diff --git a/common/djangoapps/util/help_context_processor.py b/common/djangoapps/util/help_context_processor.py index 5aa56e7bae..2f6fd134b3 100644 --- a/common/djangoapps/util/help_context_processor.py +++ b/common/djangoapps/util/help_context_processor.py @@ -4,9 +4,11 @@ Online Contextual Help. """ import ConfigParser -from django.conf import settings import logging +from django.conf import settings + +from openedx.core.release import doc_version log = logging.getLogger(__name__) @@ -76,7 +78,7 @@ def common_doc_url(request, config_file_object): # pylint: disable=unused-argum return "{url_base}/{language}/{version}/{page_path}".format( url_base=doc_base_url, language=get_config_value_with_default("locales", settings.LANGUAGE_CODE), - version=config_file_object.get("help_settings", "version"), + version=doc_version(), page_path=get_config_value_with_default("pages", page_token), ) @@ -102,7 +104,7 @@ def common_doc_url(request, config_file_object): # pylint: disable=unused-argum # Construct and return the URL for the PDF link. return "{pdf_base}/{version}/{pdf_file}".format( pdf_base=pdf_base_url, - version=config_file_object.get("help_settings", "version"), + version=doc_version(), pdf_file=config_file_object.get("pdf_settings", "pdf_file"), ) diff --git a/common/djangoapps/util/tests/mixins/enterprise.py b/common/djangoapps/util/tests/mixins/enterprise.py index 48285fb39d..7dcfc88363 100644 --- a/common/djangoapps/util/tests/mixins/enterprise.py +++ b/common/djangoapps/util/tests/mixins/enterprise.py @@ -57,6 +57,80 @@ class EnterpriseServiceMockMixin(object): status=500 ) + def mock_enterprise_learner_api( + self, + catalog_id=1, + entitlement_id=1, + learner_id=1, + enterprise_customer_uuid='cf246b88-d5f6-4908-a522-fc307e0b0c59' + ): + """ + Helper function to register enterprise learner API endpoint. + """ + enterprise_learner_api_response = { + 'count': 1, + 'num_pages': 1, + 'current_page': 1, + 'results': [ + { + 'id': learner_id, + 'enterprise_customer': { + 'uuid': enterprise_customer_uuid, + 'name': 'TestShib', + 'catalog': catalog_id, + 'active': True, + 'site': { + 'domain': 'example.com', + 'name': 'example.com' + }, + 'enable_data_sharing_consent': True, + 'enforce_data_sharing_consent': 'at_login', + 'enterprise_customer_users': [ + 1 + ], + 'branding_configuration': { + 'enterprise_customer': enterprise_customer_uuid, + 'logo': 'https://open.edx.org/sites/all/themes/edx_open/logo.png' + }, + 'enterprise_customer_entitlements': [ + { + 'enterprise_customer': enterprise_customer_uuid, + 'entitlement_id': entitlement_id + } + ] + }, + 'user_id': 5, + 'user': { + 'username': 'verified', + 'first_name': '', + 'last_name': '', + 'email': 'verified@example.com', + 'is_staff': True, + 'is_active': True, + 'date_joined': '2016-09-01T19:18:26.026495Z' + }, + 'data_sharing_consent': [ + { + 'user': 1, + 'state': 'enabled', + 'enabled': True + } + ] + } + ], + 'next': None, + 'start': 0, + 'previous': None + } + enterprise_learner_api_response_json = json.dumps(enterprise_learner_api_response) + + httpretty.register_uri( + method=httpretty.GET, + uri=self.get_enterprise_url('enterprise-learner'), + body=enterprise_learner_api_response_json, + content_type='application/json' + ) + class EnterpriseTestConsentRequired(object): """ diff --git a/common/djangoapps/util/tests/test_help_context_processor.py b/common/djangoapps/util/tests/test_help_context_processor.py index 3b388f3421..b4c2e26a5a 100644 --- a/common/djangoapps/util/tests/test_help_context_processor.py +++ b/common/djangoapps/util/tests/test_help_context_processor.py @@ -8,6 +8,7 @@ from mock import patch from django.conf import settings from django.test import TestCase +from openedx.core.release import doc_version from util.help_context_processor import common_doc_url @@ -33,34 +34,24 @@ class HelpContextProcessorTest(TestCase): def test_get_doc_url(self): # Test default values. - self.assertRegexpMatches( - self._get_doc_url(), - "http://edx.readthedocs.io/projects/open-edx-learner-guide/en/.*/index.html" - ) + doc = "http://edx.readthedocs.io/projects/open-edx-learner-guide/en/{}/index.html" + self.assertEqual(self._get_doc_url(), doc.format(doc_version())) # Provide a known page_token. - self.assertRegexpMatches( - self._get_doc_url('profile'), - "http://edx.readthedocs.io/projects/open-edx-learner-guide/en/.*/sfd_dashboard_profile/index.html" - ) + doc = "http://edx.readthedocs.io/projects/open-edx-learner-guide/en/{}/sfd_dashboard_profile/index.html" + self.assertEqual(self._get_doc_url('profile'), doc.format(doc_version())) # Use settings.DOC_LINK_BASE_URL to override default base_url. + doc = "settings_base_url/en/{}/SFD_instructor_dash_help.html" with patch('django.conf.settings.DOC_LINK_BASE_URL', 'settings_base_url'): - self.assertRegexpMatches( - self._get_doc_url('instructor'), - "settings_base_url/en/.*/SFD_instructor_dash_help.html" - ) + self.assertEqual(self._get_doc_url('instructor'), doc.format(doc_version())) def test_get_pdf_url(self): # Test default values. - self.assertRegexpMatches( - self._get_pdf_url(), - "https://media.readthedocs.org/pdf/open-edx-learner-guide/.*/open-edx-learner-guide.pdf" - ) + doc = "https://media.readthedocs.org/pdf/open-edx-learner-guide/{}/open-edx-learner-guide.pdf" + self.assertEqual(self._get_pdf_url(), doc.format(doc_version())) # Use settings.DOC_LINK_BASE_URL to override default base_url. + doc = "settings_base_url/{}/open-edx-learner-guide.pdf" with patch('django.conf.settings.DOC_LINK_BASE_URL', 'settings_base_url'): - self.assertRegexpMatches( - self._get_pdf_url(), - "settings_base_url/.*/open-edx-learner-guide.pdf" - ) + self.assertEqual(self._get_pdf_url(), doc.format(doc_version())) diff --git a/common/lib/capa/capa/xqueue_interface.py b/common/lib/capa/capa/xqueue_interface.py index aa327dc285..fe47e9b597 100644 --- a/common/lib/capa/capa/xqueue_interface.py +++ b/common/lib/capa/capa/xqueue_interface.py @@ -15,6 +15,8 @@ XQUEUE_METRIC_NAME = 'edxapp.xqueue' # Wait time for response from Xqueue. XQUEUE_TIMEOUT = 35 # seconds +CONNECT_TIMEOUT = 3.05 # seconds +READ_TIMEOUT = 10 # seconds def make_hashkey(seed): @@ -134,12 +136,18 @@ class XQueueInterface(object): def _http_post(self, url, data, files=None): try: - r = self.session.post(url, data=data, files=files) + response = self.session.post( + url, data=data, files=files, timeout=(CONNECT_TIMEOUT, READ_TIMEOUT) + ) except requests.exceptions.ConnectionError, err: log.error(err) return (1, 'cannot connect to server') - if r.status_code not in [200]: - return (1, 'unexpected HTTP status code [%d]' % r.status_code) + except requests.exceptions.ReadTimeout, err: + log.error(err) + return (1, 'failed to read from the server') - return parse_xreply(r.text) + if response.status_code not in [200]: + return (1, 'unexpected HTTP status code [%d]' % response.status_code) + + return parse_xreply(response.text) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 95af81a9b9..9c39c7b549 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -675,6 +675,9 @@ class CourseFields(object): scope=Scope.settings, ) + # Note: Although users enter the entrance exam minimum score + # as a percentage value, it is internally converted and stored + # as a decimal value less than 1. entrance_exam_minimum_score_pct = Float( display_name=_("Entrance Exam Minimum Score (%)"), help=_( diff --git a/common/lib/xmodule/xmodule/js/fixtures/jsinput_problem.html b/common/lib/xmodule/xmodule/js/fixtures/jsinput_problem.html index a0250c5685..bedbadf61f 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/jsinput_problem.html +++ b/common/lib/xmodule/xmodule/js/fixtures/jsinput_problem.html @@ -1,4 +1,4 @@ -

Custom Javascript Display and Grading

+

Custom Javascript Display and Grading

diff --git a/common/lib/xmodule/xmodule/js/fixtures/problem_content.html b/common/lib/xmodule/xmodule/js/fixtures/problem_content.html index 7cbfcb4339..e26a65b599 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/problem_content.html +++ b/common/lib/xmodule/xmodule/js/fixtures/problem_content.html @@ -1,4 +1,4 @@ -

Problem Header

+

Problem Header

diff --git a/common/lib/xmodule/xmodule/js/fixtures/problem_content_1240.html b/common/lib/xmodule/xmodule/js/fixtures/problem_content_1240.html index b87b5e857e..4e8a6f9028 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/problem_content_1240.html +++ b/common/lib/xmodule/xmodule/js/fixtures/problem_content_1240.html @@ -1,4 +1,4 @@ -

Problem Header

+

Problem Header

diff --git a/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js b/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js index 05a57b9295..a985c3ca21 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/html5_video_spec.js @@ -41,7 +41,8 @@ expect(state.videoPlayer.player.video.play).toHaveBeenCalled(); }); - it('player state was changed', function(done) { + // Failing on master. See TNL-6748. + xit('player state was changed', function(done) { jasmine.waitUntil(function() { return state.videoPlayer.player.getPlayerState() === STATUS.PLAYING; }).always(done); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js index 7588db9ba4..b2faa75a89 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js @@ -436,7 +436,7 @@ function(VideoPlayer) { state.speed = '2.0'; state.videoPlayer.onPlay(); expect(state.videoPlayer.setPlaybackRate) - .toHaveBeenCalledWith('2.0', true); + .toHaveBeenCalledWith('2.0'); state.videoPlayer.onPlay(); expect(state.videoPlayer.setPlaybackRate.calls.count()) .toEqual(1); @@ -943,9 +943,8 @@ function(VideoPlayer) { state.isHtml5Mode.and.returnValue(false); state.videoPlayer.isPlaying.and.returnValue(true); VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); - expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); - expect(state.videoPlayer.player.loadVideoById) - .toHaveBeenCalledWith('videoId', 60); + expect(state.videoPlayer.player.setPlaybackRate) + .toHaveBeenCalledWith('0.75'); }); it('in Flash mode and video not started', function() { @@ -953,15 +952,7 @@ function(VideoPlayer) { state.isHtml5Mode.and.returnValue(false); state.videoPlayer.isPlaying.and.returnValue(false); VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); - expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); - expect(state.videoPlayer.seekTo).toHaveBeenCalledWith(60); - expect(state.trigger).toHaveBeenCalledWith( - 'videoProgressSlider.updateStartEndTimeRegion', - { - duration: 60 - }); - expect(state.videoPlayer.player.cueVideoById) - .toHaveBeenCalledWith('videoId', 60); + expect(state.videoPlayer.player.setPlaybackRate).toHaveBeenCalledWith('0.75'); }); it('in HTML5 mode', function() { @@ -975,9 +966,7 @@ function(VideoPlayer) { state.videoPlayer.isPlaying.and.returnValue(false); VideoPlayer.prototype.setPlaybackRate.call(state, '1.0'); - expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); - expect(state.videoPlayer.player.cueVideoById) - .toHaveBeenCalledWith('videoId', 60); + expect(state.videoPlayer.player.setPlaybackRate).toHaveBeenCalledWith('1.0'); }); }); }); diff --git a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js index 8e85afe80c..6eecd3df86 100644 --- a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js +++ b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js @@ -109,16 +109,7 @@ function(HTML5Video, Resizer) { // starts playing. Just after that configurations can be applied. state.videoPlayer.ready = _.once(function() { if (!state.isFlashMode() && state.speed != '1.0') { - // Work around a bug in the Youtube API that causes videos to - // play at normal speed rather than at the configured speed in - // Safari. Setting the playback rate to 1.0 *after* playing - // started and then to the actual value tricks the player into - // picking up the speed setting. - if (state.browserIsSafari && state.isYoutubeType()) { - state.videoPlayer.setPlaybackRate(1.0, false); - } - - state.videoPlayer.setPlaybackRate(state.speed, true); + state.videoPlayer.setPlaybackRate(state.speed); } }); @@ -381,73 +372,8 @@ function(HTML5Video, Resizer) { } } - function setPlaybackRate(newSpeed, useCueVideoById) { - var duration = this.videoPlayer.duration(), - time = this.videoPlayer.currentTime, - methodName, youtubeId; - - // There is a bug which prevents YouTube API to correctly set the speed - // to 1.0 from another speed in Firefox when in HTML5 mode. There is a - // fix which basically reloads the video at speed 1.0 when this change - // is requested (instead of simply requesting a speed change to 1.0). - // This has to be done only when the video is being watched in Firefox. - // We need to figure out what browser is currently executing this code. - // - // TODO: Check the status of - // http://code.google.com/p/gdata-issues/issues/detail?id=4654 - // When the YouTube team fixes the API bug, we can remove this - // temporary bug fix. - - // If useCueVideoById is true it will reload video again. - // Used useCueVideoById to fix the issue video not playing if we change - // the speed before playing the video. - if ( - this.isHtml5Mode() && !(this.browserIsFirefox && - (useCueVideoById || newSpeed === '1.0') && this.isYoutubeType()) - ) { - this.videoPlayer.player.setPlaybackRate(newSpeed); - } else { - // We request the reloading of the video in the case when YouTube - // is in Flash player mode, or when we are in Firefox, and the new - // speed is 1.0. The second case is necessary to avoid the bug - // where in Firefox speed switching to 1.0 in HTML5 player mode is - // handled incorrectly by YouTube API. - methodName = 'cueVideoById'; - youtubeId = this.youtubeId(newSpeed); - - if (this.videoPlayer.isPlaying()) { - methodName = 'loadVideoById'; - } - - this.videoPlayer.player[methodName](youtubeId, time); - - // We need to call play() explicitly because after the call - // to functions cueVideoById() followed by seekTo() the video - // is in a PAUSED state. - // - // Why? This is how the YouTube API is implemented. - // sjson.search() only works if time is defined. - if (!_.isUndefined(time)) { - this.videoPlayer.updatePlayTime(time); - } - if (time > 0 && this.isFlashMode()) { - this.videoPlayer.seekTo(time); - this.trigger( - 'videoProgressSlider.updateStartEndTimeRegion', - { - duration: duration - } - ); - } - // In Html5 mode if video speed is changed before playing in firefox and - // changed speed is not '1.0' then manually trigger setPlaybackRate method. - // In browsers other than firefox like safari user can set speed to '1.0' - // if its not already set to '1.0' so in that case we don't have to - // call 'setPlaybackRate' - if (this.isHtml5Mode() && newSpeed != '1.0') { - this.videoPlayer.player.setPlaybackRate(newSpeed); - } - } + function setPlaybackRate(newSpeed) { + this.videoPlayer.player.setPlaybackRate(newSpeed); } function onSpeedChange(newSpeed) { diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 8233f2a669..ecda5cc450 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -14,7 +14,6 @@ from lxml import etree from xblock.core import XBlock from xblock.fields import Integer, Scope, Boolean, String from xblock.fragment import Fragment -import newrelic.agent from .exceptions import NotFoundError from .fields import Date @@ -25,6 +24,11 @@ from .xml_module import XmlDescriptor log = logging.getLogger(__name__) +try: + import newrelic.agent +except ImportError: + newrelic = None # pylint: disable=invalid-name + # HACK: This shouldn't be hard-coded to two types # OBSOLETE: This obsoletes 'type' class_priority = ['video', 'problem'] @@ -385,6 +389,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): """ Capture basic information about this sequence in New Relic. """ + if not newrelic: + return newrelic.agent.add_custom_parameter('seq.block_id', unicode(self.location)) newrelic.agent.add_custom_parameter('seq.display_name', self.display_name or '') newrelic.agent.add_custom_parameter('seq.position', self.position) @@ -396,6 +402,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): the sequence as a whole. We send this information to New Relic so that we can do better performance analysis of courseware. """ + if not newrelic: + return # Basic count of the number of Units (a.k.a. VerticalBlocks) we have in # this learning sequence newrelic.agent.add_custom_parameter('seq.num_units', len(display_items)) @@ -414,6 +422,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): """ Capture information about the current selected Unit within the Sequence. """ + if not newrelic: + return # Positions are stored with indexing starting at 1. If we get into a # weird state where the saved position is out of bounds (e.g. the # content was changed), avoid going into any details about this unit. diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index e7653ef81c..3af41a46fc 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -8,6 +8,7 @@ Tests of the Capa XModule import datetime import json import random +import requests import os import textwrap import unittest @@ -242,6 +243,22 @@ class CapaModuleTest(unittest.TestCase): problem = CapaFactory.create() self.assertFalse(problem.answer_available()) + @ddt.data( + (requests.exceptions.ReadTimeout, (1, 'failed to read from the server')), + (requests.exceptions.ConnectionError, (1, 'cannot connect to server')), + ) + @ddt.unpack + def test_xqueue_request_exception(self, exception, result): + """ + Makes sure that platform will raise appropriate exception in case of + connect/read timeout(s) to request to xqueue + """ + xqueue_interface = XQueueInterface("http://example.com/xqueue", Mock()) + with patch.object(xqueue_interface.session, 'post', side_effect=exception): + # pylint: disable = protected-access + response = xqueue_interface._http_post('http://some/fake/url', {}) + self.assertEqual(response, result) + def test_showanswer_attempted(self): problem = CapaFactory.create(showanswer='attempted') self.assertFalse(problem.answer_available()) diff --git a/common/static/common/js/discussion/views/discussion_inline_view.js b/common/static/common/js/discussion/views/discussion_inline_view.js index 39922f1274..03d8cd8634 100644 --- a/common/static/common/js/discussion/views/discussion_inline_view.js +++ b/common/static/common/js/discussion/views/discussion_inline_view.js @@ -24,7 +24,6 @@ initialize: function(options) { var match; - this.$el = options.el; this.readOnly = options.readOnly; this.showByDefault = options.showByDefault || false; @@ -32,6 +31,12 @@ this.listenTo(this.model, 'change', this.render); this.escKey = 27; + if (options.startHeader !== undefined) { + this.startHeader = options.startHeader; + } else { + this.startHeader = 4; // Start the header levels at H + } + match = this.page_re.exec(window.location.href); if (match) { this.page = parseInt(match[1], 10); @@ -73,7 +78,6 @@ var discussionHtml, user = new DiscussionUser(response.user_info), self = this; - $elem.focus(); window.user = user; @@ -120,6 +124,7 @@ collection: this.discussion, course_settings: this.courseSettings, topicId: discussionId, + startHeader: this.startHeader, is_commentable_cohorted: response.is_commentable_cohorted }); @@ -146,6 +151,7 @@ el: this.$('.forum-content'), model: thread, mode: 'inline', + startHeader: this.startHeader, courseSettings: this.courseSettings }); this.threadView.render(); diff --git a/common/static/common/js/discussion/views/discussion_thread_edit_view.js b/common/static/common/js/discussion/views/discussion_thread_edit_view.js index 2db0eed848..950b24db68 100644 --- a/common/static/common/js/discussion/views/discussion_thread_edit_view.js +++ b/common/static/common/js/discussion/views/discussion_thread_edit_view.js @@ -16,6 +16,7 @@ initialize: function(options) { this.container = options.container || $('.thread-content-wrapper'); this.mode = options.mode || 'inline'; + this.startHeader = options.startHeader; this.course_settings = options.course_settings; this.threadType = this.model.get('thread_type'); this.topicId = this.model.get('commentable_id'); @@ -28,8 +29,10 @@ var formId = _.uniqueId('form-'), threadTypeTemplate = edx.HtmlUtils.template($('#thread-type-template').html()), $threadTypeSelector = $(threadTypeTemplate({form_id: formId}).toString()), + context, mainTemplate = edx.HtmlUtils.template($('#thread-edit-template').html()); - edx.HtmlUtils.setHtml(this.$el, mainTemplate(this.model.toJSON())); + context = $.extend({mode: this.mode, startHeader: this.startHeader}, this.model.attributes); + edx.HtmlUtils.setHtml(this.$el, mainTemplate(context)); this.container.append(this.$el); this.$submitBtn = this.$('.post-update'); this.addField($threadTypeSelector); diff --git a/common/static/common/js/discussion/views/discussion_thread_show_view.js b/common/static/common/js/discussion/views/discussion_thread_show_view.js index 9e01c6326b..f26a2cb8a3 100644 --- a/common/static/common/js/discussion/views/discussion_thread_show_view.js +++ b/common/static/common/js/discussion/views/discussion_thread_show_view.js @@ -30,15 +30,16 @@ var _ref; DiscussionThreadShowView.__super__.initialize.call(this); this.mode = options.mode || 'inline'; + this.startHeader = options.startHeader; if ((_ref = this.mode) !== 'tab' && _ref !== 'inline') { throw new Error('invalid mode: ' + this.mode); } }; DiscussionThreadShowView.prototype.renderTemplate = function() { - var context; - context = $.extend({ + var context = $.extend({ mode: this.mode, + startHeader: this.startHeader, flagged: this.model.isFlagged(), author_display: this.getAuthorDisplay(), cid: this.model.cid, diff --git a/common/static/common/js/discussion/views/discussion_thread_view.js b/common/static/common/js/discussion/views/discussion_thread_view.js index 3e8838583d..ba8e9a6355 100644 --- a/common/static/common/js/discussion/views/discussion_thread_view.js +++ b/common/static/common/js/discussion/views/discussion_thread_view.js @@ -80,6 +80,7 @@ this.mode = options.mode || 'inline'; this.context = options.context || 'course'; this.options = _.extend({}, options); + this.startHeader = options.startHeader; if ((_ref = this.mode) !== 'tab' && _ref !== 'inline') { throw new Error('invalid mode: ' + this.mode); } @@ -91,6 +92,7 @@ self.model = collection.get(id); } }); + this.createShowView(); this.responses = new Comments(); this.loadedResponses = false; @@ -116,7 +118,8 @@ }; DiscussionThreadView.prototype.renderTemplate = function() { - var container, templateData; + var container, + templateData; this.template = _.template($('#thread-template').html()); container = $('#discussion-container'); if (!container.length) { @@ -124,6 +127,7 @@ } templateData = _.extend(this.model.toJSON(), { readOnly: this.readOnly, + startHeader: this.startHeader + 1, // this is a child so headers should be increased can_create_comment: container.data('user-create-comment') }); return this.template(templateData); @@ -299,7 +303,8 @@ var view; response.set('thread', this.model); view = new ThreadResponseView($.extend({ - model: response + model: response, + startHeader: this.startHeader + 1 // this is a child so headers should be increased }, options)); view.on('comment:add', this.addComment); view.on('comment:endorse', this.endorseThread); @@ -396,6 +401,7 @@ model: this.model, mode: this.mode, context: this.context, + startHeader: this.startHeader, course_settings: this.options.courseSettings }); this.editView.bind('thread:updated thread:cancel_edit', this.closeEditView); @@ -415,7 +421,8 @@ DiscussionThreadView.prototype.createShowView = function() { this.showView = new DiscussionThreadShowView({ model: this.model, - mode: this.mode + mode: this.mode, + startHeader: this.startHeader }); this.showView.bind('thread:_delete', this._delete); return this.showView.bind('thread:edit', this.edit); diff --git a/common/static/common/js/discussion/views/new_post_view.js b/common/static/common/js/discussion/views/new_post_view.js index 1585889b1e..d2537111c2 100644 --- a/common/static/common/js/discussion/views/new_post_view.js +++ b/common/static/common/js/discussion/views/new_post_view.js @@ -36,6 +36,7 @@ NewPostView.prototype.initialize = function(options) { var _ref; this.mode = options.mode || 'inline'; + this.startHeader = options.startHeader; if ((_ref = this.mode) !== 'tab' && _ref !== 'inline') { throw new Error('invalid mode: ' + this.mode); } @@ -45,12 +46,14 @@ }; NewPostView.prototype.render = function() { - var context, threadTypeTemplate; + var context, + threadTypeTemplate; context = _.clone(this.course_settings.attributes); _.extend(context, { cohort_options: this.getCohortOptions(), is_commentable_cohorted: this.is_commentable_cohorted, mode: this.mode, + startHeader: this.startHeader, form_id: this.mode + (this.topicId ? '-' + this.topicId : '') }); this.$el.html(_.template($('#new-post-template').html())(context)); diff --git a/common/static/common/js/discussion/views/response_comment_edit_view.js b/common/static/common/js/discussion/views/response_comment_edit_view.js index a7de68e051..7bd873122a 100644 --- a/common/static/common/js/discussion/views/response_comment_edit_view.js +++ b/common/static/common/js/discussion/views/response_comment_edit_view.js @@ -22,7 +22,8 @@ this.ResponseCommentEditView = (function(_super) { __extends(ResponseCommentEditView, _super); - function ResponseCommentEditView() { + function ResponseCommentEditView(options) { + this.options = options; return ResponseCommentEditView.__super__.constructor.apply(this, arguments); } @@ -40,8 +41,10 @@ }; ResponseCommentEditView.prototype.render = function() { + var context = $.extend({mode: this.options.mode, startHeader: this.options.startHeader}, + this.model.attributes); this.template = _.template($('#response-comment-edit-template').html()); - this.$el.html(this.template(this.model.toJSON())); + this.$el.html(this.template(context)); this.delegateEvents(); DiscussionUtil.makeWmdEditor(this.$el, $.proxy(this.$, this), 'edit-comment-body'); return this; diff --git a/common/static/common/js/discussion/views/response_comment_view.js b/common/static/common/js/discussion/views/response_comment_view.js index ad870fd877..09149a1860 100644 --- a/common/static/common/js/discussion/views/response_comment_view.js +++ b/common/static/common/js/discussion/views/response_comment_view.js @@ -45,7 +45,8 @@ return this.$el.find(selector); }; - ResponseCommentView.prototype.initialize = function() { + ResponseCommentView.prototype.initialize = function(options) { + this.startHeader = options.startHeader; return ResponseCommentView.__super__.initialize.call(this); }; @@ -84,7 +85,8 @@ this.showView = null; } this.editView = new ResponseCommentEditView({ - model: this.model + model: this.model, + startHeader: this.startHeader }); this.editView.bind('comment:update', this.update); this.editView.bind('comment:cancel_edit', this.cancelEdit); diff --git a/common/static/common/js/discussion/views/thread_response_edit_view.js b/common/static/common/js/discussion/views/thread_response_edit_view.js index e41a6daf9c..13d2308be1 100644 --- a/common/static/common/js/discussion/views/thread_response_edit_view.js +++ b/common/static/common/js/discussion/views/thread_response_edit_view.js @@ -35,13 +35,16 @@ return this.$el.find(selector); }; - ThreadResponseEditView.prototype.initialize = function() { + ThreadResponseEditView.prototype.initialize = function(options) { + this.options = options; return ThreadResponseEditView.__super__.initialize.call(this); }; ThreadResponseEditView.prototype.render = function() { + var context = $.extend({mode: this.options.mode, startHeader: this.options.startHeader}, + this.model.attributes); this.template = _.template($('#thread-response-edit-template').html()); - this.$el.html(this.template(this.model.toJSON())); + this.$el.html(this.template(context)); this.delegateEvents(); DiscussionUtil.makeWmdEditor(this.$el, $.proxy(this.$, this), 'edit-post-body'); return this; diff --git a/common/static/common/js/discussion/views/thread_response_view.js b/common/static/common/js/discussion/views/thread_response_view.js index 88f895665a..077c6cdefd 100644 --- a/common/static/common/js/discussion/views/thread_response_view.js +++ b/common/static/common/js/discussion/views/thread_response_view.js @@ -59,6 +59,7 @@ }; ThreadResponseView.prototype.initialize = function(options) { + this.startHeader = options.startHeader; this.collapseComments = options.collapseComments; this.createShowView(); this.readOnly = $('.discussion-module').data('read-only'); @@ -155,7 +156,8 @@ self = this; comment.set('thread', this.model.get('thread')); view = new ResponseCommentView({ - model: comment + model: comment, + startHeader: this.startHeader }); view.render(); if (this.readOnly) { @@ -246,7 +248,8 @@ this.editView.model = this.model; } else { this.editView = new ThreadResponseEditView({ - model: this.model + model: this.model, + startHeader: this.startHeader }); this.editView.bind('response:update', this.update); return this.editView.bind('response:cancel_edit', this.cancelEdit); diff --git a/common/static/common/templates/components/paging-header.underscore b/common/static/common/templates/components/paging-header.underscore index d41504c484..0c594d7ff8 100644 --- a/common/static/common/templates/components/paging-header.underscore +++ b/common/static/common/templates/components/paging-header.underscore @@ -1,5 +1,5 @@ <% if (!_.isUndefined(srInfo)) { %> -

<%- srInfo.text %>

+

<%- srInfo.text %>

<% } %>
diff --git a/common/static/common/templates/discussion/new-post.underscore b/common/static/common/templates/discussion/new-post.underscore index 8f6c8494ab..1376a2111c 100644 --- a/common/static/common/templates/discussion/new-post.underscore +++ b/common/static/common/templates/discussion/new-post.underscore @@ -1,5 +1,6 @@
-

<%- gettext("Add a Post") %>

+ class="thread-title"><%- gettext("Add a Post") %>> + <% if (mode === 'inline') { %>
diff --git a/lms/templates/learner_dashboard/_dashboard_navigation_courses.html b/lms/templates/learner_dashboard/_dashboard_navigation_courses.html new file mode 100644 index 0000000000..8dab520cfd --- /dev/null +++ b/lms/templates/learner_dashboard/_dashboard_navigation_courses.html @@ -0,0 +1,10 @@ +<%page expression_filter="h"/> +<%! +from django.utils.translation import ugettext as _ +%> + +
+

${_("My Courses")}

+
+ +

${_('My Courses')}

diff --git a/lms/templates/learner_dashboard/_dashboard_navigation_programs.html b/lms/templates/learner_dashboard/_dashboard_navigation_programs.html new file mode 100644 index 0000000000..f5c4a945f6 --- /dev/null +++ b/lms/templates/learner_dashboard/_dashboard_navigation_programs.html @@ -0,0 +1,2 @@ +<%page expression_filter="h"/> +## This file is left intentionally blank and can be overridden using theming diff --git a/lms/templates/learner_dashboard/program_card.underscore b/lms/templates/learner_dashboard/program_card.underscore index 5cb4c61849..8002a29deb 100644 --- a/lms/templates/learner_dashboard/program_card.underscore +++ b/lms/templates/learner_dashboard/program_card.underscore @@ -7,39 +7,48 @@
- <% if (progress) { %> -

- <%= interpolate( - ngettext( - '%(count)s course is in progress.', - '%(count)s courses are in progress.', - progress.in_progress - ), - {count: progress.in_progress}, true - ) %> - - <%= interpolate( - ngettext( - '%(count)s course has not been started.', - '%(count)s courses have not been started.', - progress.not_started - ), - {count: progress.not_started}, true - ) %> - - <%= interpolate( - gettext('You have earned certificates in %(completed_courses)s of the %(total_courses)s courses so far.'), - {completed_courses: progress.completed, total_courses: progress.total}, true - ) %> -

- <% } %> <% if (progress) { %> -
-
-
-
-
+
+
+
<%- progress.completed %>
+ + <%- ngettext('Course', 'Courses', progress.completed) %> + + <%- gettext('Completed') %> +
+ +
+
<%- progress.in_progress %>
+ + <%- ngettext('Course', 'Courses', progress.in_progress) %> + + <%- gettext('Enrolled') %> +
+ +
+
<%- progress.not_started %>
+ + <%- ngettext('Course', 'Courses', progress.not_started) %> + + <%- gettext('Not Enrolled') %> +
+
+<% } %> +<% if (progress) { %> +
+
+ <% _.times(progress.completed, function() { %> +
+ <% }) %> + <% _.times(progress.in_progress, function() { %> +
+ <% }) %> + <% _.times(progress.not_started, function() { %> +
+ <% }) %> +
+
<% } %>