diff --git a/AUTHORS b/AUTHORS index 2037269816..59e6f1ab0d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -225,4 +225,5 @@ Alessandro Verdura Sven Marnach Richard Moch Albert Liang - +Pa Luo +Tyler Nickerson diff --git a/Gemfile b/Gemfile index b99a22b64e..cef97fbc1b 100644 --- a/Gemfile +++ b/Gemfile @@ -2,12 +2,3 @@ source 'https://rubygems.org' gem 'sass', '3.3.5' gem 'bourbon', '~> 4.0.2' gem 'neat', '~> 1.6.0' -gem 'colorize', '~> 0.5.8' -gem 'launchy', '~> 2.1.2' -gem 'sys-proctable', '~> 0.9.3' -gem 'dalli', '~> 2.6.4' -# These gems aren't actually required; they are used by Linux and Mac to -# detect when files change. If these gems are not installed, the system -# will fall back to polling files. -gem 'rb-inotify', '~> 0.9' -gem 'rb-fsevent', '~> 0.9.3' diff --git a/Gemfile.lock b/Gemfile.lock index 2d8e77cb9f..551990d98f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,23 +1,13 @@ GEM remote: https://rubygems.org/ specs: - addressable (2.3.5) bourbon (4.0.2) sass (~> 3.3) thor - colorize (0.5.8) - dalli (2.6.4) - ffi (1.9.0) - launchy (2.1.2) - addressable (~> 2.3) neat (1.6.0) bourbon (>= 3.1) sass (>= 3.3) - rb-fsevent (0.9.3) - rb-inotify (0.9.2) - ffi (>= 0.5.0) sass (3.3.5) - sys-proctable (0.9.3) thor (0.19.1) PLATFORMS @@ -25,11 +15,5 @@ PLATFORMS DEPENDENCIES bourbon (~> 4.0.2) - colorize (~> 0.5.8) - dalli (~> 2.6.4) - launchy (~> 2.1.2) neat (~> 1.6.0) - rb-fsevent (~> 0.9.3) - rb-inotify (~> 0.9) sass (= 3.3.5) - sys-proctable (~> 0.9.3) diff --git a/README.rst b/README.rst index 5ca8464cb3..9453c9ab70 100644 --- a/README.rst +++ b/README.rst @@ -35,7 +35,7 @@ The Open edX Portal See the `Open edX Portal`_ to learn more about Open edX. You can find information about the edX roadmap, as well as about hosting, extending, and contributing to Open edX. In addition, the Open edX Portal provides product -announcements, the Open edX blog, and other rich community resources. +announcements, the Open edX blog, and other rich community resources. To comment on blog posts or the edX roadmap, you must create an account and log in. If you do not have an account, follow these steps. @@ -55,9 +55,16 @@ Documentation is managed in the `edx-documentation`_ repository. Documentation is built using `Sphinx`_: you can `view the built documentation on ReadTheDocs`_. +You can also check out `Confluence`_, our wiki system. Once you sign up for +an account, you'll be able to create new pages and edit existing pages, just +like in any other wiki system. You only need one account for both Confluence +and `JIRA`_, our issue tracker. + .. _Sphinx: http://sphinx-doc.org/ .. _view the built documentation on ReadTheDocs: http://docs.edx.org/ .. _edx-documentation: https://github.com/edx/edx-documentation +.. _Confluence: http://openedx.atlassian.net/wiki/ +.. _JIRA: https://openedx.atlassian.net/ Getting Help diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index f295a3517d..743f54f829 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -59,12 +59,12 @@ def click_new_component_button(step, component_button_css): def _click_advanced(): - css = 'ul.problem-type-tabs a[href="#tab3"]' + css = 'ul.problem-type-tabs a[href="#tab2"]' world.css_click(css) # Wait for the advanced tab items to be displayed - tab3_css = 'div.ui-tabs-panel#tab3' - world.wait_for_visible(tab3_css) + tab2_css = 'div.ui-tabs-panel#tab2' + world.wait_for_visible(tab2_css) def _find_matching_link(category, component_type): diff --git a/cms/djangoapps/contentstore/management/commands/export_convert_format.py b/cms/djangoapps/contentstore/management/commands/export_convert_format.py index 5b1b1d7cfd..f97ff305fc 100644 --- a/cms/djangoapps/contentstore/management/commands/export_convert_format.py +++ b/cms/djangoapps/contentstore/management/commands/export_convert_format.py @@ -7,6 +7,7 @@ Sample invocation: ./manage.py export_convert_format mycourse.tar.gz ~/newformat import os from path import path from django.core.management.base import BaseCommand, CommandError +from django.conf import settings from tempfile import mkdtemp import tarfile @@ -32,8 +33,8 @@ class Command(BaseCommand): output_path = args[1] # Create temp directories to extract the source and create the target archive. - temp_source_dir = mkdtemp() - temp_target_dir = mkdtemp() + temp_source_dir = mkdtemp(dir=settings.DATA_DIR) + temp_target_dir = mkdtemp(dir=settings.DATA_DIR) try: extract_source(source_archive, temp_source_dir) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_export_convert_format.py b/cms/djangoapps/contentstore/management/commands/tests/test_export_convert_format.py index fd83d58f89..ddcdb725fb 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_export_convert_format.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_export_convert_format.py @@ -3,6 +3,7 @@ Test for export_convert_format. """ from unittest import TestCase from django.core.management import call_command, CommandError +from django.conf import settings from tempfile import mkdtemp import shutil from path import path @@ -18,7 +19,7 @@ class ConvertExportFormat(TestCase): """ Common setup. """ super(ConvertExportFormat, self).setUp() - self.temp_dir = mkdtemp() + self.temp_dir = mkdtemp(dir=settings.DATA_DIR) self.addCleanup(shutil.rmtree, self.temp_dir) self.data_dir = path(__file__).realpath().parent / 'data' self.version0 = self.data_dir / "Version0_drafts.tar.gz" @@ -52,8 +53,8 @@ class ConvertExportFormat(TestCase): """ Helper function for determining if 2 archives are equal. """ - temp_dir_1 = mkdtemp() - temp_dir_2 = mkdtemp() + temp_dir_1 = mkdtemp(dir=settings.DATA_DIR) + temp_dir_2 = mkdtemp(dir=settings.DATA_DIR) try: extract_source(file1, temp_dir_1) extract_source(file2, temp_dir_2) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 31afe010d5..2f720a14c2 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -52,6 +52,7 @@ class CourseDetailsTestCase(CourseTestCase): self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video)) self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort)) self.assertIsNone(details.language, "language somehow initialized" + str(details.language)) + self.assertIsNone(details.has_cert_config) def test_encoder(self): details = CourseDetails.fetch(self.course.id) @@ -1008,6 +1009,41 @@ class CourseMetadataEditingTest(CourseTestCase): tab_list.append(self.notes_tab) self.assertEqual(tab_list, course.tabs) + @override_settings(FEATURES={'CERTIFICATES_HTML_VIEW': True}) + def test_web_view_certifcate_configuration_settings(self): + """ + Test that has_cert_config is updated based on cert_html_view_enabled setting. + """ + test_model = CourseMetadata.update_from_json( + self.course, + { + "cert_html_view_enabled": {"value": "true"} + }, + user=self.user + ) + self.assertIn('cert_html_view_enabled', test_model) + url = get_url(self.course.id) + response = self.client.get_json(url) + course_detail_json = json.loads(response.content) + self.assertFalse(course_detail_json['has_cert_config']) + + # Now add a certificate configuration + certificates = [ + { + 'id': 1, + 'name': 'Certificate Config Name', + 'course_title': 'Title override', + 'org_logo_path': '/c4x/test/CSS101/asset/org_logo.png', + 'signatories': [], + 'is_active': True + } + ] + self.course.certificates = {'certificates': certificates} + modulestore().update_item(self.course, self.user.id) + response = self.client.get_json(url) + course_detail_json = json.loads(response.content) + self.assertTrue(course_detail_json['has_cert_config']) + class CourseGraderUpdatesTest(CourseTestCase): """ diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index 194e5859d2..69b8fc6a69 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -1,25 +1,25 @@ """ Tests for utils. """ import collections -import copy -import mock from datetime import datetime, timedelta -from pytz import UTC +import mock +import ddt +from pytz import UTC from django.test import TestCase from django.test.utils import override_settings - -from contentstore import utils -from contentstore.tests.utils import CourseTestCase from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from opaque_keys.edx.locations import SlashSeparatedCourseKey - from xmodule.modulestore.django import modulestore +from contentstore import utils +from contentstore.tests.utils import CourseTestCase + class LMSLinksTestCase(TestCase): """ Tests for LMS links. """ + def about_page_test(self): """ Get URL for about page, no marketing site """ # default for ENABLE_MKTG_SITE is False. @@ -109,6 +109,7 @@ class ExtraPanelTabTestCase(TestCase): return course +@ddt.ddt class CourseImageTestCase(ModuleStoreTestCase): """Tests for course image URLs.""" @@ -146,6 +147,16 @@ class CourseImageTestCase(ModuleStoreTestCase): utils.course_image_url(course) ) + @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo) + def test_empty_image_name(self, default_store): + """ Verify that empty image names are cleaned """ + course_image = u'' + course = CourseFactory.create(course_image=course_image, default_store=default_store) + self.assertEquals( + course_image, + utils.course_image_url(course), + ) + class XBlockVisibilityTestCase(ModuleStoreTestCase): """Tests for xblock visibility for students.""" @@ -386,6 +397,7 @@ class GroupVisibilityTest(CourseTestCase): """ Test content group access rules. """ + def setUp(self): super(GroupVisibilityTest, self).setUp() diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 5828d4e761..8e6a311f36 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -4,12 +4,12 @@ Common utility functions useful throughout the contentstore # pylint: disable=no-member import logging +from opaque_keys import InvalidKeyError import re from datetime import datetime from pytz import UTC from django.conf import settings -from django.utils.translation import ugettext as _ from django.core.urlresolvers import reverse from django_comment_common.models import assign_default_role from django_comment_common.utils import seed_permissions_roles @@ -160,7 +160,10 @@ def get_lms_link_for_certificate_web_view(user_id, course_key, mode): def course_image_url(course): """Returns the image url for the course.""" - loc = StaticContent.compute_location(course.location.course_key, course.course_image) + try: + loc = StaticContent.compute_location(course.location.course_key, course.course_image) + except InvalidKeyError: + return '' path = StaticContent.serialize_asset_key_with_slash(loc) return path @@ -310,3 +313,22 @@ def reverse_usage_url(handler_name, usage_key, kwargs=None): Creates the URL for handlers that use usage_keys as URL parameters. """ return reverse_url(handler_name, 'usage_key_string', usage_key, kwargs) + + +def has_active_web_certificate(course): + """ + Returns True if given course has active web certificate configuration. + If given course has no active web certificate configuration returns False. + Returns None If `CERTIFICATES_HTML_VIEW` is not enabled of course has not enabled + `cert_html_view_enabled` settings. + """ + cert_config = None + if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False) and course.cert_html_view_enabled: + cert_config = False + certificates = getattr(course, 'certificates', {}) + configurations = certificates.get('certificates', []) + for config in configurations: + if config.get('is_active'): + cert_config = True + break + return cert_config diff --git a/cms/djangoapps/contentstore/views/certificates.py b/cms/djangoapps/contentstore/views/certificates.py index 154605dfe5..96e2f522e6 100644 --- a/cms/djangoapps/contentstore/views/certificates.py +++ b/cms/djangoapps/contentstore/views/certificates.py @@ -33,7 +33,7 @@ from django.views.decorators.http import require_http_methods from contentstore.utils import reverse_course_url from edxmako.shortcuts import render_to_response from opaque_keys.edx.keys import CourseKey, AssetKey -from student.auth import has_studio_read_access +from student.auth import has_studio_write_access from util.db import generate_int_id, MYSQL_MAX_INT from util.json_request import JsonResponse from xmodule.modulestore import EdxJSONEncoder @@ -53,7 +53,7 @@ def _get_course_and_check_access(course_key, user, depth=0): Internal method used to calculate and return the locator and course module for the view functions in this file. """ - if not has_studio_read_access(user, course_key): + if not has_studio_write_access(user, course_key): raise PermissionDenied() course_module = modulestore().get_course(course_key, depth=depth) return course_module diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 391a62d6b7..994af7c1db 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -227,7 +227,7 @@ def get_component_templates(courselike, library=False): """ Returns the applicable component templates that can be used by the specified course or library. """ - def create_template_dict(name, cat, boilerplate_name=None, tab="common"): + def create_template_dict(name, cat, boilerplate_name=None, tab="common", hinted=False): """ Creates a component template dict. @@ -235,13 +235,15 @@ def get_component_templates(courselike, library=False): display_name: the user-visible name of the component category: the type of component (problem, html, etc.) boilerplate_name: name of boilerplate for filling in default values. May be None. - tab: common(default)/advanced/hint, which tab it goes in + hinted: True if hinted problem else False + tab: common(default)/advanced, which tab it goes in """ return { "display_name": name, "category": cat, "boilerplate_name": boilerplate_name, + "hinted": hinted, "tab": tab } @@ -277,20 +279,20 @@ def get_component_templates(courselike, library=False): for template in component_class.templates(): filter_templates = getattr(component_class, 'filter_templates', None) if not filter_templates or filter_templates(template, courselike): - # Tab can be 'common' 'advanced' 'hint' + # Tab can be 'common' 'advanced' # Default setting is common/advanced depending on the presence of markdown tab = 'common' if template['metadata'].get('markdown') is None: tab = 'advanced' - # Then the problem can override that with a tab: attribute (note: not nested in metadata) - tab = template.get('tab', tab) + hinted = template.get('hinted', False) templates_for_category.append( create_template_dict( _(template['metadata'].get('display_name')), # pylint: disable=translation-of-non-string category, template.get('template_id'), - tab + tab, + hinted, ) ) diff --git a/cms/djangoapps/contentstore/views/tests/test_certificates.py b/cms/djangoapps/contentstore/views/tests/test_certificates.py index 7997e9b575..ec3a8a8f87 100644 --- a/cms/djangoapps/contentstore/views/tests/test_certificates.py +++ b/cms/djangoapps/contentstore/views/tests/test_certificates.py @@ -5,6 +5,7 @@ Group Configuration Tests. """ import json import mock +import ddt from django.conf import settings from django.test.utils import override_settings @@ -19,6 +20,7 @@ from xmodule.contentstore.django import contentstore from xmodule.contentstore.content import StaticContent from xmodule.exceptions import NotFoundError from student.models import CourseEnrollment +from student.tests.factories import UserFactory from contentstore.views.certificates import CertificateManager from django.test.utils import override_settings from contentstore.utils import get_lms_link_for_certificate_web_view @@ -230,6 +232,19 @@ class CertificatesListHandlerTestCase(CourseTestCase, CertificatesBaseTestCase, self._remove_ids(content) # pylint: disable=unused-variable self.assertEqual(content, expected) + def test_cannot_create_certificate_if_user_has_no_write_permissions(self): + """ + Tests user without write permissions on course should not able to create certificate + """ + user = UserFactory() + self.client.login(username=user.username, password='test') + response = self.client.ajax_post( + self._url(), + data=CERTIFICATE_JSON + ) + + self.assertEqual(response.status_code, 403) + @override_settings(LMS_BASE=None) def test_no_lms_base_for_certificate_web_view_link(self): test_link = get_lms_link_for_certificate_web_view( @@ -330,6 +345,7 @@ class CertificatesListHandlerTestCase(CourseTestCase, CertificatesBaseTestCase, self.assertNotEqual(new_certificate.get('id'), prev_certificate.get('id')) +@ddt.ddt @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) class CertificatesDetailHandlerTestCase(CourseTestCase, CertificatesBaseTestCase, HelperMethods): """ @@ -433,6 +449,21 @@ class CertificatesDetailHandlerTestCase(CourseTestCase, CertificatesBaseTestCase self.assertEqual(certificates[0].get('name'), 'Name 0') self.assertEqual(certificates[0].get('description'), 'Description 0') + def test_delete_certificate_without_write_permissions(self): + """ + Tests certificate deletion without write permission on course. + """ + self._add_course_certificates(count=2, signatory_count=1) + user = UserFactory() + self.client.login(username=user.username, password='test') + response = self.client.delete( + self._url(cid=1), + content_type="application/json", + HTTP_ACCEPT="application/json", + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + self.assertEqual(response.status_code, 403) + def test_delete_non_existing_certificate(self): """ Try to delete a non existing certificate. It should return status code 404 Not found. @@ -523,6 +554,25 @@ class CertificatesDetailHandlerTestCase(CourseTestCase, CertificatesBaseTestCase certificates = course.certificates['certificates'] self.assertEqual(certificates[0].get('is_active'), is_active) + @ddt.data(True, False) + def test_certificate_activation_without_write_permissions(self, activate): + """ + Tests certificate Activate and Deactivate should not be allowed if user + does not have write permissions on course. + """ + test_url = reverse_course_url('certificates.certificate_activation_handler', self.course.id) + self._add_course_certificates(count=1, signatory_count=2) + user = UserFactory() + self.client.login(username=user.username, password='test') + response = self.client.post( + test_url, + data=json.dumps({"is_active": activate}), + content_type="application/json", + HTTP_ACCEPT="application/json", + HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + self.assertEquals(response.status_code, 403) + def test_certificate_activation_failure(self): """ Certificate activation should fail when user has not read access to course then permission denied exception diff --git a/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py b/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py index d24007129d..6ce0cfce51 100644 --- a/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py +++ b/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py @@ -29,8 +29,8 @@ class CreditEligibilityTest(CourseTestCase): """ response = self.client.get_html(self.course_details_url) self.assertEqual(response.status_code, 200) - self.assertNotContains(response, "Credit Eligibility Requirements") - self.assertNotContains(response, "Steps needed for credit eligibility") + self.assertNotContains(response, "Course Credit Requirements") + self.assertNotContains(response, "Steps required to earn course credit") @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_CREDIT_ELIGIBILITY': True}) def test_course_details_with_enabled_setting(self): @@ -41,8 +41,8 @@ class CreditEligibilityTest(CourseTestCase): # course is not set as credit course response = self.client.get_html(self.course_details_url) self.assertEqual(response.status_code, 200) - self.assertNotContains(response, "Credit Eligibility Requirements") - self.assertNotContains(response, "Steps needed for credit eligibility") + self.assertNotContains(response, "Course Credit Requirements") + self.assertNotContains(response, "Steps required to earn course credit") # verify that credit eligibility requirements block shows if the # course is set as credit course and it has eligibility requirements @@ -55,5 +55,5 @@ class CreditEligibilityTest(CourseTestCase): response = self.client.get_html(self.course_details_url) self.assertEqual(response.status_code, 200) - self.assertContains(response, "Credit Eligibility Requirements") - self.assertContains(response, "Steps needed for credit eligibility") + self.assertContains(response, "Course Credit Requirements") + self.assertContains(response, "Steps required to earn course credit") diff --git a/cms/djangoapps/contentstore/views/tests/test_import_export.py b/cms/djangoapps/contentstore/views/tests/test_import_export.py index 3375a30d09..f251d0a295 100644 --- a/cms/djangoapps/contentstore/views/tests/test_import_export.py +++ b/cms/djangoapps/contentstore/views/tests/test_import_export.py @@ -209,6 +209,19 @@ class ImportTestCase(CourseTestCase): return outside_tar + def _edx_platform_tar(self): + """ + Tarfile with file that extracts to edx-platform directory. + + Extracting this tarfile in directory will also put its contents + directly in (rather than ). + """ + outside_tar = self.unsafe_common_dir / "unsafe_file.tar.gz" + with tarfile.open(outside_tar, "w:gz") as tar: + tar.addfile(tarfile.TarInfo(os.path.join(os.path.abspath("."), "a_file"))) + + return outside_tar + def test_unsafe_tar(self): """ Check that safety measure work. @@ -233,6 +246,12 @@ class ImportTestCase(CourseTestCase): try_tar(self._symlink_tar()) try_tar(self._outside_tar()) try_tar(self._outside_tar2()) + try_tar(self._edx_platform_tar()) + + # test trying to open a tar outside of the normal data directory + with self.settings(DATA_DIR='/not/the/data/dir'): + try_tar(self._edx_platform_tar()) + # Check that `import_status` returns the appropriate stage (i.e., # either 3, indicating all previous steps are completed, or 0, # indicating no upload in progress) @@ -294,13 +313,19 @@ class ImportTestCase(CourseTestCase): self.assertIn(test_block3.url_name, children) self.assertIn(test_block4.url_name, children) - extract_dir = path(tempfile.mkdtemp()) + extract_dir = path(tempfile.mkdtemp(dir=settings.DATA_DIR)) + # the extract_dir needs to be passed as a relative dir to + # import_library_from_xml + extract_dir_relative = path.relpath(extract_dir, settings.DATA_DIR) + try: - tar = tarfile.open(path(TEST_DATA_DIR) / 'imports' / 'library.HhJfPD.tar.gz') - safetar_extractall(tar, extract_dir) + with tarfile.open(path(TEST_DATA_DIR) / 'imports' / 'library.HhJfPD.tar.gz') as tar: + safetar_extractall(tar, extract_dir) library_items = import_library_from_xml( - self.store, self.user.id, - settings.GITHUB_REPO_ROOT, [extract_dir / 'library'], + self.store, + self.user.id, + settings.GITHUB_REPO_ROOT, + [extract_dir_relative / 'library'], load_error_modules=False, static_content_store=contentstore(), target_id=lib_key diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 0b81444ab5..4483b1145f 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -8,7 +8,7 @@ from django.conf import settings from opaque_keys.edx.locations import Location from xmodule.modulestore.exceptions import ItemNotFoundError -from contentstore.utils import course_image_url +from contentstore.utils import course_image_url, has_active_web_certificate from models.settings import course_grading from xmodule.fields import Date from xmodule.modulestore.django import modulestore @@ -52,7 +52,8 @@ class CourseDetails(object): self.entrance_exam_minimum_score_pct = settings.FEATURES.get( 'ENTRANCE_EXAM_MIN_SCORE_PCT', '50' - ) # minimum passing score for entrance exam content module/tree + ) # minimum passing score for entrance exam content module/tree, + self.has_cert_config = None # course has active certificate configuration @classmethod def _fetch_about_attribute(cls, course_key, attribute): @@ -84,6 +85,7 @@ class CourseDetails(object): course_details.language = descriptor.language # Default course license is "All Rights Reserved" course_details.license = getattr(descriptor, "license", "all-rights-reserved") + course_details.has_cert_config = has_active_web_certificate(descriptor) for attribute in ABOUT_ATTRIBUTES: value = cls._fetch_about_attribute(course_key, attribute) diff --git a/cms/envs/bok_choy.py b/cms/envs/bok_choy.py index 8c7e7f8585..84916debe9 100644 --- a/cms/envs/bok_choy.py +++ b/cms/envs/bok_choy.py @@ -39,6 +39,7 @@ INSTALLED_APPS += ('django_extensions',) TEST_ROOT = REPO_ROOT / "test_root" # pylint: disable=no-value-for-parameter GITHUB_REPO_ROOT = (TEST_ROOT / "data").abspath() LOG_DIR = (TEST_ROOT / "log").abspath() +DATA_DIR = TEST_ROOT / "data" # Configure modulestore to use the test folder within the repo update_module_store_settings( diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 0260b5311f..f73b5f915d 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -97,6 +97,9 @@ FEATURES['ENABLE_COURSEWARE_INDEX'] = True FEATURES['ENABLE_LIBRARY_INDEX'] = True SEARCH_ENGINE = "search.elastic.ElasticSearchEngine" +########################## Certificates Web/HTML View ####################### +FEATURES['CERTIFICATES_HTML_VIEW'] = True + ################################# DJANGO-REQUIRE ############################### # Whether to run django-require in debug mode. @@ -115,6 +118,3 @@ MODULESTORE = convert_module_store_setting_if_needed(MODULESTORE) # Dummy secret key for dev SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' - -########################## Certificates Web/HTML View ####################### -FEATURES['CERTIFICATES_HTML_VIEW'] = True diff --git a/cms/envs/test.py b/cms/envs/test.py index ba6d9e7df1..289c3c67d3 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -65,6 +65,7 @@ TEST_ROOT = path('test_root') STATIC_ROOT = TEST_ROOT / "staticfiles" GITHUB_REPO_ROOT = TEST_ROOT / "data" +DATA_DIR = TEST_ROOT / "data" COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data" # For testing "push to lms" diff --git a/cms/static/coffee/spec/main_spec.coffee b/cms/static/coffee/spec/main_spec.coffee index 2de4869dbe..45cbd52bbc 100644 --- a/cms/static/coffee/spec/main_spec.coffee +++ b/cms/static/coffee/spec/main_spec.coffee @@ -28,7 +28,7 @@ require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_h appendSetFixtures(sandbox({id: "page-notification"})) it "successful AJAX request does not pop an error notification", -> - server = AjaxHelpers['server'](200, this) + server = AjaxHelpers.server(this, [200, {}, '']) expect($("#page-notification")).toBeEmpty() $.ajax("/test") @@ -37,7 +37,7 @@ require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_h expect($("#page-notification")).toBeEmpty() it "AJAX request with error should pop an error notification", -> - server = AjaxHelpers['server'](500, this) + server = AjaxHelpers.server(this, [500, {}, '']) $.ajax("/test") server.respond() @@ -45,7 +45,7 @@ require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_h expect($("#page-notification")).toContain('div.wrapper-notification-error') it "can override AJAX request with error so it does not pop an error notification", -> - server = AjaxHelpers['server'](500, this) + server = AjaxHelpers.server(this, [500, {}, '']) $.ajax url: "/test" diff --git a/cms/static/coffee/spec/models/section_spec.coffee b/cms/static/coffee/spec/models/section_spec.coffee index 95d26e43d4..536d3507d6 100644 --- a/cms/static/coffee/spec/models/section_spec.coffee +++ b/cms/static/coffee/spec/models/section_spec.coffee @@ -34,7 +34,7 @@ define ["js/models/section", "common/js/spec_helpers/ajax_helpers", "js/utils/mo }) it "show/hide a notification when it saves to the server", -> - server = AjaxHelpers['server'](200, this) + server = AjaxHelpers.server(this, [200, {}, '']) @model.save() expect(Section.prototype.showNotification).toHaveBeenCalled() @@ -43,7 +43,7 @@ define ["js/models/section", "common/js/spec_helpers/ajax_helpers", "js/utils/mo it "don't hide notification when saving fails", -> # this is handled by the global AJAX error handler - server = AjaxHelpers['server'](500, this) + server = AjaxHelpers.server(this, [500, {}, '']) @model.save() server.respond() diff --git a/cms/static/coffee/spec/views/course_info_spec.coffee b/cms/static/coffee/spec/views/course_info_spec.coffee index ebcb576e1b..81d213c4bc 100644 --- a/cms/static/coffee/spec/views/course_info_spec.coffee +++ b/cms/static/coffee/spec/views/course_info_spec.coffee @@ -167,22 +167,23 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model @courseInfoEdit.render() @event = {preventDefault : () -> 'no op'} @courseInfoEdit.onNew(@event) - @requests = AjaxHelpers["requests"](this) it "shows push notification checkbox as selected by default", -> expect(@courseInfoEdit.$el.find('.toggle-checkbox')).toBeChecked() it "sends correct default value for push_notification_selected", -> + requests = AjaxHelpers.requests(this); @courseInfoEdit.$el.find('.save-button').click() - requestSent = JSON.parse(@requests[@requests.length - 1].requestBody) + requestSent = JSON.parse(requests[requests.length - 1].requestBody) expect(requestSent.push_notification_selected).toEqual(true) it "sends correct value for push_notification_selected when it is unselected", -> + requests = AjaxHelpers.requests(this); # unselect push notification @courseInfoEdit.$el.find('.toggle-checkbox').attr('checked', false); @courseInfoEdit.$el.find('.save-button').click() - requestSent = JSON.parse(@requests[@requests.length - 1].requestBody) + requestSent = JSON.parse(requests[requests.length - 1].requestBody) expect(requestSent.push_notification_selected).toEqual(false) describe "Course Handouts", -> diff --git a/cms/static/js/models/component_template.js b/cms/static/js/models/component_template.js index 050ef2e0b1..3b55f559dc 100644 --- a/cms/static/js/models/component_template.js +++ b/cms/static/js/models/component_template.js @@ -32,6 +32,11 @@ define(["backbone"], function (Backbone) { return -1; } else if (isPrimaryBlankTemplate(b)) { return 1; + // Hinted problems should be shown at the end + } else if (a.hinted && !b.hinted) { + return 1; + } else if (!a.hinted && b.hinted) { + return -1; } else if (a.display_name > b.display_name) { return 1; } else if (a.display_name < b.display_name) { diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index a9f70eac7e..efb3e24dcd 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -1,5 +1,5 @@ -define(["backbone", "underscore", "gettext", "js/models/validation_helpers"], - function(Backbone, _, gettext, ValidationHelpers) { +define(["backbone", "underscore", "gettext", "js/models/validation_helpers", "js/utils/date_utils"], + function(Backbone, _, gettext, ValidationHelpers, DateUtils) { var CourseDetails = Backbone.Model.extend({ defaults: { @@ -28,14 +28,21 @@ var CourseDetails = Backbone.Model.extend({ // Returns either nothing (no return call) so that validate works or an object of {field: errorstring} pairs // A bit funny in that the video key validation is asynchronous; so, it won't stop the validation. var errors = {}; + newattrs = DateUtils.convertDateStringsToObjects( + newattrs, ["start_date", "end_date", "enrollment_start", "enrollment_end"] + ); + if (newattrs.start_date === null) { errors.start_date = gettext("The course must have an assigned start date."); } + if (this.hasChanged("start_date") && this.get("has_cert_config") === false){ + errors.start_date = gettext("The course must have at least one active certificate configuration before it can be started."); + } if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) { - errors.end_date = gettext("The course end date cannot be before the course start date."); + errors.end_date = gettext("The course end date must be later than the course start date."); } if (newattrs.start_date && newattrs.enrollment_start && newattrs.start_date < newattrs.enrollment_start) { - errors.enrollment_start = gettext("The course start date cannot be before the enrollment start date."); + errors.enrollment_start = gettext("The course start date must be later than the enrollment start date."); } if (newattrs.enrollment_start && newattrs.enrollment_end && newattrs.enrollment_start >= newattrs.enrollment_end) { errors.enrollment_end = gettext("The enrollment start date cannot be after the enrollment end date."); diff --git a/cms/static/js/spec/video/file_uploader_editor_spec.js b/cms/static/js/spec/video/file_uploader_editor_spec.js index a7eb90040f..c6d7f08a37 100644 --- a/cms/static/js/spec/video/file_uploader_editor_spec.js +++ b/cms/static/js/spec/video/file_uploader_editor_spec.js @@ -1,8 +1,8 @@ define( [ - 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers', 'squire' + 'jquery', 'underscore', 'squire' ], -function ($, _, AjaxHelpers, Squire) { +function ($, _, Squire) { 'use strict'; describe('FileUploader', function () { var FileUploaderTemplate = readFixtures( diff --git a/cms/static/js/spec/video/transcripts/videolist_spec.js b/cms/static/js/spec/video/transcripts/videolist_spec.js index b13e81040e..51a6e00306 100644 --- a/cms/static/js/spec/video/transcripts/videolist_spec.js +++ b/cms/static/js/spec/video/transcripts/videolist_spec.js @@ -4,9 +4,10 @@ define( 'js/views/video/transcripts/utils', 'js/views/video/transcripts/metadata_videolist', 'js/models/metadata', 'js/views/abstract_editor', - 'sinon', 'xmodule', 'jasmine-jquery' + 'common/js/spec_helpers/ajax_helpers', + 'xmodule', 'jasmine-jquery' ], -function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { +function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, AjaxHelpers) { 'use strict'; describe('CMS.Views.Metadata.VideoList', function () { var videoListEntryTemplate = readFixtures( @@ -50,24 +51,13 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { status: 'Success', subs: 'video_id' }), - view, sinonXhr, MessageManager, messenger; + MessageManager, messenger; beforeEach(function () { - sinonXhr = sinon.fakeServer.create(); - sinonXhr.respondWith([ - 200, - { 'Content-Type': 'application/json'}, - response - ]); - - sinonXhr.autoRespond = true; - var tpl = sandbox({ 'class': 'component', 'data-locator': component_locator - }), - model = new MetadataModel(modelStub), - $el; + }); setFixtures(tpl); @@ -99,14 +89,6 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { return messenger; }; - $el = $('.component'); - - view = new VideoList({ - el: $el, - model: model, - MessageManager: MessageManager - }); - this.addMatchers({ assertValueInView: function(expected) { var actualValue = this.actual.getValueFromEditor(); @@ -129,11 +111,29 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); }); - afterEach(function () { - sinonXhr.restore(); - }); + var createMockAjaxServer = function (test) { + var mockServer = AjaxHelpers.server( + test, + [ + 200, + { 'Content-Type': 'application/json'}, + response + ] + ); + mockServer.autoRespond = true; + return mockServer; + }; - var waitsForResponse = function (expectFunc, prep) { + var createVideoListView = function () { + var model = new MetadataModel(modelStub); + return new VideoList({ + el: $('.component'), + model: model, + MessageManager: MessageManager + }); + }; + + var waitsForResponse = function (mockServer, expectFunc, prep) { var flag = false; if (prep) { @@ -141,10 +141,10 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { } waitsFor(function() { - var req = sinonXhr.requests, - len = req.length; + var requests = mockServer.requests, + len = requests.length; - if (len && req[0].readyState === 4) { + if (len && requests[0].readyState === 4) { flag = true; } @@ -156,7 +156,9 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { it('Initialize', function () { - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + waitsForResponse(mockServer, function () { expect(abstractEditor.initialize).toHaveBeenCalled(); expect(messenger.initialize).toHaveBeenCalled(); expect(view.component_locator).toBe(component_locator); @@ -175,20 +177,24 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { expect(messenger.render).toHaveBeenCalled(); }, - resetSpies = function() { + resetSpies = function(mockServer) { abstractEditor.render.reset(); Utils.command.reset(); messenger.render.reset(); - sinonXhr.requests.length = 0; + mockServer.requests.length = 0; }; it('is rendered in correct way', function () { - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this); + createVideoListView(); + waitsForResponse(mockServer, function () { assertToHaveBeenRendered(videoList); }); }); it('is rendered with opened extra videos bar', function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); var videoListLength = [ { mode: 'youtube', @@ -213,24 +219,26 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { spyOn(view, 'openExtraVideosBar'); waitsForResponse( + mockServer, function () { assertToHaveBeenRendered(videoListLength); view.getVideoObjectsList.andReturn(videoListLength); expect(view.openExtraVideosBar).toHaveBeenCalled(); }, function () { - resetSpies(); + resetSpies(mockServer); view.render(); } ); waitsForResponse( + mockServer, function () { assertToHaveBeenRendered(videoListHtml5mode); expect(view.openExtraVideosBar).toHaveBeenCalled(); }, function () { - resetSpies(); + resetSpies(mockServer); view.openExtraVideosBar.reset(); view.getVideoObjectsList.andReturn(videoListHtml5mode); view.render(); @@ -240,7 +248,9 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); it('is rendered without opened extra videos bar', function () { - var videoList = [ + var mockServer = createMockAjaxServer(this), + view = createVideoListView(), + videoList = [ { mode: 'youtube', type: 'youtube', @@ -252,12 +262,13 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { spyOn(view, 'closeExtraVideosBar'); waitsForResponse( + mockServer, function () { assertToHaveBeenRendered(videoList); expect(view.closeExtraVideosBar).toHaveBeenCalled(); }, function () { - resetSpies(); + resetSpies(mockServer); view.render(); } ); @@ -267,13 +278,14 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { describe('isUniqOtherVideos', function () { it('Unique data - return true', function () { - var data = videoList.concat([{ - mode: 'html5', - type: 'other', - video: 'pxxZrg' - }]); - - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(), + data = videoList.concat([{ + mode: 'html5', + type: 'other', + video: 'pxxZrg' + }]); + waitsForResponse(mockServer, function () { var result = view.isUniqOtherVideos(data); expect(result).toBe(true); @@ -282,7 +294,9 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); it('Not Unique data - return false', function () { - var data = [ + var mockServer = createMockAjaxServer(this), + view = createVideoListView(), + data = [ { mode: 'html5', type: 'mp4', @@ -309,8 +323,7 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { video: '12345678901' } ]; - - waitsForResponse(function () { + waitsForResponse(mockServer, function () { var result = view.isUniqOtherVideos(data); expect(result).toBe(false); @@ -321,18 +334,20 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { describe('isUniqVideoTypes', function () { it('Unique data - return true', function () { - var data = videoList; - - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(), + data = videoList; + waitsForResponse(mockServer, function () { var result = view.isUniqVideoTypes(data); - expect(result).toBe(true); }); }); it('Not Unique data - return false', function () { - var data = [ + var mockServer = createMockAjaxServer(this), + view = createVideoListView(), + data = [ { mode: 'html5', type: 'mp4', @@ -354,8 +369,7 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { video: '12345678901' } ]; - - waitsForResponse(function () { + waitsForResponse(mockServer, function () { var result = view.isUniqVideoTypes(data); expect(result).toBe(false); @@ -365,7 +379,9 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { describe('checkIsUniqVideoTypes', function () { it('Error is shown', function () { - var data = [ + var mockServer = createMockAjaxServer(this), + view = createVideoListView(), + data = [ { mode: 'html5', type: 'mp4', @@ -388,7 +404,7 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { } ]; - waitsForResponse(function () { + waitsForResponse(mockServer, function () { var result = view.checkIsUniqVideoTypes(data); expect(messenger.showError).toHaveBeenCalled(); @@ -397,9 +413,10 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); it('All works okay if arguments are not passed', function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); spyOn(view, 'getVideoObjectsList').andReturn(videoList); - - waitsForResponse(function () { + waitsForResponse(mockServer, function () { var result = view.checkIsUniqVideoTypes(); expect(view.getVideoObjectsList).toHaveBeenCalled(); @@ -410,12 +427,11 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); describe('checkValidity', function () { - beforeEach(function () { - spyOn(view, 'checkIsUniqVideoTypes').andReturn(true); - }); - it('Error message is shown', function () { - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + spyOn(view, 'checkIsUniqVideoTypes').andReturn(true); + waitsForResponse(mockServer, function () { var data = { mode: 'incorrect' }, result = view.checkValidity(data, true); @@ -426,7 +442,10 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); it('Error message is shown when flag is not passed', function () { - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + spyOn(view, 'checkIsUniqVideoTypes').andReturn(true); + waitsForResponse(mockServer, function () { var data = { mode: 'incorrect' }, result = view.checkValidity(data); @@ -437,7 +456,10 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); it('All works okay if correct data is passed', function () { - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + spyOn(view, 'checkIsUniqVideoTypes').andReturn(true); + waitsForResponse(mockServer, function () { var data = videoList, result = view.checkValidity(data); @@ -449,7 +471,9 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); it('openExtraVideosBar', function () { - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + waitsForResponse(mockServer, function () { view.$extraVideosBar.removeClass('is-visible'); view.openExtraVideosBar(); @@ -458,7 +482,9 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); it('closeExtraVideosBar', function () { - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + waitsForResponse(mockServer, function () { view.$extraVideosBar.addClass('is-visible'); view.closeExtraVideosBar(); @@ -467,7 +493,9 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); it('toggleExtraVideosBar', function () { - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + waitsForResponse(mockServer, function () { view.$extraVideosBar.addClass('is-visible'); view.toggleExtraVideosBar(); expect(view.$extraVideosBar).not.toHaveClass('is-visible'); @@ -477,18 +505,24 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); it('getValueFromEditor', function () { - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + waitsForResponse(mockServer, function () { expect(view).assertValueInView(modelStub.value); }); }); it('setValueInEditor', function () { - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + waitsForResponse(mockServer, function () { expect(view).assertCanUpdateView(['abc.mp4']); }); }); it('getVideoObjectsList', function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); var value = [ { mode: 'youtube', @@ -507,7 +541,7 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { } ]; - waitsForResponse(function () { + waitsForResponse(mockServer, function () { view.setValueInEditor([ 'http://youtu.be/12345678901', 'video.mp4', @@ -519,14 +553,12 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); describe('getPlaceholders', function () { - var defaultPlaceholders; - - beforeEach(function () { - defaultPlaceholders = view.placeholders; - }); it('All works okay if empty values are passed', function () { - waitsForResponse(function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(), + defaultPlaceholders = view.placeholders; + waitsForResponse(mockServer, function () { var result = view.getPlaceholders([]), expectedResult = _.values(defaultPlaceholders).reverse(); @@ -534,10 +566,12 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { }); }); - it('On filling less than 3 fields, remaining fields should have ' + 'placeholders for video types that were not filled yet', function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(), + defaultPlaceholders = view.placeholders; var dataDict = { youtube: { value: [modelStub.value[0]], @@ -564,8 +598,8 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { ] } }; - - waitsForResponse(function () { + defaultPlaceholders = view.placeholders; + waitsForResponse(mockServer, function () { $.each(dataDict, function(index, val) { var result = view.getPlaceholders(val.value); @@ -579,13 +613,13 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { describe('inputHandler', function () { var eventObject; - var resetSpies = function () { + var resetSpies = function (view) { messenger.hideError.reset(); view.updateModel.reset(); view.closeExtraVideosBar.reset(); }; - beforeEach(function () { + var setUp = function (view) { eventObject = jQuery.Event('input'); spyOn(view, 'updateModel'); @@ -597,15 +631,18 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { spyOn($.fn, 'prop').andCallThrough(); spyOn(_, 'isEqual'); - resetSpies(); - }); + resetSpies(view); + }; it('Field has invalid value - nothing should happen', function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + setUp(view); $.fn.hasClass.andReturn(false); view.checkValidity.andReturn(false); - waitsForResponse(function () { + waitsForResponse(mockServer, function () { view.inputHandler(eventObject); expect(messenger.hideError).not.toHaveBeenCalled(); expect(view.updateModel).not.toHaveBeenCalled(); @@ -622,10 +659,13 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { it('Main field has invalid value - extra Videos Bar is closed', function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + setUp(view); $.fn.hasClass.andReturn(true); view.checkValidity.andReturn(false); - waitsForResponse(function () { + waitsForResponse(mockServer, function () { view.inputHandler(eventObject); expect(messenger.hideError).not.toHaveBeenCalled(); expect(view.updateModel).not.toHaveBeenCalled(); @@ -642,10 +682,13 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { it('Model is updated if value is valid', function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + setUp(view); view.checkValidity.andReturn(true); _.isEqual.andReturn(false); - waitsForResponse(function () { + waitsForResponse(mockServer, function () { view.inputHandler(eventObject); expect(messenger.hideError).not.toHaveBeenCalled(); expect(view.updateModel).toHaveBeenCalled(); @@ -662,10 +705,12 @@ function ($, _, Utils, VideoList, MetadataModel, AbstractEditor, sinon) { it('Corner case: Error is hided', function () { + var mockServer = createMockAjaxServer(this), + view = createVideoListView(); + setUp(view); view.checkValidity.andReturn(true); _.isEqual.andReturn(true); - - waitsForResponse(function () { + waitsForResponse(mockServer, function () { view.inputHandler(eventObject); expect(messenger.hideError).toHaveBeenCalled(); expect(view.updateModel).not.toHaveBeenCalled(); diff --git a/cms/static/js/spec/video/translations_editor_spec.js b/cms/static/js/spec/video/translations_editor_spec.js index 48c273a3b6..36879df06d 100644 --- a/cms/static/js/spec/video/translations_editor_spec.js +++ b/cms/static/js/spec/video/translations_editor_spec.js @@ -1,8 +1,8 @@ define( [ - 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers', 'squire' + 'jquery', 'underscore', 'squire' ], -function ($, _, AjaxHelpers, Squire) { +function ($, _, Squire) { 'use strict'; // TODO: fix BLD-1100 Disabled due to intermittent failure on master and in PR builds xdescribe('VideoTranslations', function () { diff --git a/cms/static/js/spec/views/pages/group_configurations_spec.js b/cms/static/js/spec/views/pages/group_configurations_spec.js index 63ac48bdd1..5b8d4257ec 100644 --- a/cms/static/js/spec/views/pages/group_configurations_spec.js +++ b/cms/static/js/spec/views/pages/group_configurations_spec.js @@ -47,7 +47,8 @@ define([ }); describe('Initial display', function() { - it('can render itself', function() { + // TODO fix this, see TNL-1475 + xit('can render itself', function() { var view = initializePage(); expect(view.$('.ui-loading')).toBeVisible(); view.render(); diff --git a/cms/static/js/spec/views/settings/main_spec.js b/cms/static/js/spec/views/settings/main_spec.js index f39d669c5d..636ed93c0a 100644 --- a/cms/static/js/spec/views/settings/main_spec.js +++ b/cms/static/js/spec/views/settings/main_spec.js @@ -31,7 +31,8 @@ define([ entrance_exam_enabled : '', entrance_exam_minimum_score_pct: '50', license: null, - language: '' + language: '', + has_cert_config: false }, mockSettingsPage = readFixtures('mock/mock-settings-page.underscore'); @@ -71,6 +72,13 @@ define([ ); }); + it('Changing course start date without active certificate configuration should result in error', function () { + this.view.$el.find('#course-start-date') + .val('10/06/2014') + .trigger('change'); + expect(this.view.$el.find('span.message-error').text()).toContain("course must have at least one active certificate configuration"); + }); + it('Selecting a course in pre-requisite drop down should save it as part of course details', function () { var pre_requisite_courses = ['test/CSS101/2012_T1']; var requests = AjaxHelpers.requests(this), diff --git a/cms/static/js/utils/date_utils.js b/cms/static/js/utils/date_utils.js index 8bb1c20884..c6ae76d00d 100644 --- a/cms/static/js/utils/date_utils.js +++ b/cms/static/js/utils/date_utils.js @@ -35,9 +35,29 @@ define(["jquery", "date", "jquery.ui", "jquery.timepicker"], function($, date) { ); }; + var parseDateFromString = function(stringDate){ + if (stringDate && typeof stringDate === "string"){ + return new Date(stringDate); + } + else { + return stringDate; + } + }; + + var convertDateStringsToObjects = function(obj, dateFields){ + for (var i = 0; i < dateFields.length; i++){ + if (obj[dateFields[i]]){ + obj[dateFields[i]] = parseDateFromString(obj[dateFields[i]]); + } + } + return obj; + }; + return { getDate: getDate, setDate: setDate, - renderDate: renderDate + renderDate: renderDate, + convertDateStringsToObjects: convertDateStringsToObjects, + parseDateFromString: parseDateFromString }; }); diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js index 441d12d875..c21a6c48a4 100644 --- a/cms/static/js/views/settings/main.js +++ b/cms/static/js/views/settings/main.js @@ -54,8 +54,8 @@ var DetailsView = ValidatingView.extend({ if (options.showMinGradeWarning || false) { new NotificationView.Warning({ - title: gettext("Credit Eligibility Requirements"), - message: gettext("Minimum passing grade for credit is not set."), + title: gettext("Course Credit Requirements"), + message: gettext("The minimum grade for course credit is not set."), closeIcon: true }).show(); } diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 889fbe8879..92ad1f86b6 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -124,11 +124,11 @@ // green button .add-xblock-component-button { @extend %t-action3; + @include margin-right($baseline*0.75); position: relative; display: inline-block; width: ($baseline*5); height: ($baseline*5); - margin-right: ($baseline*0.75); margin-bottom: ($baseline/2); border: 1px solid $green-d2; border-radius: ($baseline/4); @@ -164,7 +164,7 @@ .cancel-button { @include white-button; - margin: $baseline 0 ($baseline/2) ($baseline/2); + @include margin($baseline, 0, ($baseline/2), ($baseline/2)); } .problem-type-tabs { @@ -225,13 +225,13 @@ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 $shadow inset; li:first-child { - margin-left: $baseline; + @include margin-left($baseline); } li { @include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0)); opacity: 0.8; - float: left; + @include float(left); display: inline-block; width: auto; box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 $shadow inset; @@ -248,6 +248,11 @@ border: 0px; opacity: 1.0; } + + // reset to remove jquery-ui float + a.link-tab { + float: none; + } } a { @@ -286,7 +291,7 @@ $outline-indent-width: $baseline; .icon { display: inline-block; vertical-align: middle; - margin-right: ($baseline/2); + @include margin-right($baseline/2); } } } diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index 14fb5750e7..e579baa6ad 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -97,6 +97,10 @@ } // in form -UI hints/tips/messages + .header-help { + margin: 0 0 $baseline 0; + } + .instructions { @extend %t-copy-sub1; margin: 0 0 $baseline 0; diff --git a/cms/templates/js/add-xblock-component-menu-problem.underscore b/cms/templates/js/add-xblock-component-menu-problem.underscore index 301064935c..85bc58776b 100644 --- a/cms/templates/js/add-xblock-component-menu-problem.underscore +++ b/cms/templates/js/add-xblock-component-menu-problem.underscore @@ -4,10 +4,7 @@ <%= gettext("Common Problem Types") %>
  • - <%= gettext("Common Problems with Hints and Feedback") %> -
  • -
  • - <%= gettext("Advanced") %> + <%= gettext("Advanced") %>
  • @@ -33,20 +30,6 @@
    - -
    -
      <% for (var i = 0; i < templates.length; i++) { %> <% if (templates[i].tab == "advanced") { %> diff --git a/cms/templates/js/certificate-editor.underscore b/cms/templates/js/certificate-editor.underscore index 4c1bd3847b..29cf39cce8 100644 --- a/cms/templates/js/certificate-editor.underscore +++ b/cms/templates/js/certificate-editor.underscore @@ -22,7 +22,7 @@
      " value="<%= course_title %>" aria-describedby="certificate-course-title-<%=uniqueId %>-tip" /> - <%= gettext("Title of the course") %> + <%= gettext("Specify an alternative to the official course title to display on certificates. Leave blank to use the official course title.") %>