diff --git a/cms/djangoapps/contentstore/features/course-export.py b/cms/djangoapps/contentstore/features/course-export.py index 5a0867565d..ae30100382 100644 --- a/cms/djangoapps/contentstore/features/course-export.py +++ b/cms/djangoapps/contentstore/features/course-export.py @@ -53,10 +53,12 @@ def i_click_on_error_dialog(step): problem_string = unicode(world.scenario_dict['COURSE'].id.make_usage_key("problem", 'ignore')) problem_string = u"Problem {}".format(problem_string[:problem_string.rfind('ignore')]) + css_selector = "span.inline-error" + world.wait_for_visible(css_selector) assert_true( - world.css_html("span.inline-error").startswith(problem_string), + world.css_html(css_selector).startswith(problem_string), u"{} does not start with {}".format( - world.css_html("span.inline-error"), problem_string + world.css_html(css_selector), problem_string )) # we don't know the actual ID of the vertical. So just check that we did go to a # vertical page in the course (there should only be one). diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py index c3fb3e178b..1e79a26b97 100644 --- a/cms/djangoapps/contentstore/features/course-updates.py +++ b/cms/djangoapps/contentstore/features/course-updates.py @@ -12,12 +12,14 @@ def go_to_updates(_step): updates_css = 'li.nav-course-courseware-updates a' world.css_click(menu_css) world.css_click(updates_css) + world.wait_for_visible('#course-handouts-view') @step(u'I add a new update with the text "([^"]*)"$') def add_update(_step, text): update_css = 'a.new-update-button' world.css_click(update_css) + world.wait_for_visible('.CodeMirror') change_text(text) diff --git a/cms/djangoapps/contentstore/proctoring.py b/cms/djangoapps/contentstore/proctoring.py index 644da6b177..f456ceb0ee 100644 --- a/cms/djangoapps/contentstore/proctoring.py +++ b/cms/djangoapps/contentstore/proctoring.py @@ -15,9 +15,13 @@ from edx_proctoring.api import ( update_exam, create_exam, get_all_exams_for_course, + update_review_policy, + create_exam_review_policy, + remove_review_policy, ) from edx_proctoring.exceptions import ( - ProctoredExamNotFoundException + ProctoredExamNotFoundException, + ProctoredExamReviewPolicyNotFoundException ) log = logging.getLogger(__name__) @@ -72,7 +76,7 @@ def register_special_exams(course_key): try: exam = get_exam_by_content_id(unicode(course_key), unicode(timed_exam.location)) # update case, make sure everything is synced - update_exam( + exam_id = update_exam( exam_id=exam['id'], exam_name=timed_exam.display_name, time_limit_mins=timed_exam.default_time_limit_minutes, @@ -83,6 +87,7 @@ def register_special_exams(course_key): ) msg = 'Updated timed exam {exam_id}'.format(exam_id=exam['id']) log.info(msg) + except ProctoredExamNotFoundException: exam_id = create_exam( course_id=unicode(course_key), @@ -97,6 +102,30 @@ def register_special_exams(course_key): msg = 'Created new timed exam {exam_id}'.format(exam_id=exam_id) log.info(msg) + # only create/update exam policy for the proctored exams + if timed_exam.is_proctored_exam and not timed_exam.is_practice_exam: + try: + update_review_policy( + exam_id=exam_id, + set_by_user_id=timed_exam.edited_by, + review_policy=timed_exam.exam_review_rules + ) + except ProctoredExamReviewPolicyNotFoundException: + if timed_exam.exam_review_rules: # won't save an empty rule. + create_exam_review_policy( + exam_id=exam_id, + set_by_user_id=timed_exam.edited_by, + review_policy=timed_exam.exam_review_rules + ) + msg = 'Created new exam review policy with exam_id {exam_id}'.format(exam_id=exam_id) + log.info(msg) + else: + try: + # remove any associated review policy + remove_review_policy(exam_id=exam_id) + except ProctoredExamReviewPolicyNotFoundException: + pass + # then see which exams we have in edx-proctoring that are not in # our current list. That means the the user has disabled it exams = get_all_exams_for_course(course_key) diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index 3970a8c1e8..08cc5cb29b 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -9,8 +9,11 @@ from mock import patch, Mock import ddt from django.test import RequestFactory +from xmodule.course_module import CourseSummary -from contentstore.views.course import _accessible_courses_list, _accessible_courses_list_from_groups, AccessListFallback +from contentstore.views.course import (_accessible_courses_list, _accessible_courses_list_from_groups, + AccessListFallback, get_courses_accessible_to_user, + _staff_accessible_course_list) from contentstore.utils import delete_course_and_groups from contentstore.tests.utils import AjaxEnabledTestClient from student.tests.factories import UserFactory @@ -19,7 +22,6 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls from xmodule.modulestore import ModuleStoreEnum from opaque_keys.edx.locations import CourseLocator -from xmodule.modulestore.django import modulestore from xmodule.error_module import ErrorDescriptor from course_action_state.models import CourseRerunState @@ -46,7 +48,7 @@ class TestCourseListing(ModuleStoreTestCase): self.client = AjaxEnabledTestClient() self.client.login(username=self.user.username, password='test') - def _create_course_with_access_groups(self, course_location, user=None): + def _create_course_with_access_groups(self, course_location, user=None, store=ModuleStoreEnum.Type.split): """ Create dummy course with 'CourseFactory' and role (instructor/staff) groups """ @@ -54,7 +56,7 @@ class TestCourseListing(ModuleStoreTestCase): org=course_location.org, number=course_location.course, run=course_location.run, - default_store=ModuleStoreEnum.Type.mongo + default_store=store ) if user is not None: @@ -87,54 +89,101 @@ class TestCourseListing(ModuleStoreTestCase): # check both course lists have same courses self.assertEqual(courses_list, courses_list_by_groups) - def test_errored_course_global_staff(self): + @ddt.data( + (ModuleStoreEnum.Type.split, 'xmodule.modulestore.split_mongo.split_mongo_kvs.SplitMongoKVS'), + (ModuleStoreEnum.Type.mongo, 'xmodule.modulestore.mongo.base.MongoKeyValueStore') + ) + @ddt.unpack + def test_errored_course_global_staff(self, store, path_to_patch): """ Test the course list for global staff when get_course returns an ErrorDescriptor """ GlobalStaff().add_users(self.user) - course_key = self.store.make_course_key('Org1', 'Course1', 'Run1') - self._create_course_with_access_groups(course_key, self.user) + with self.store.default_store(store): + course_key = self.store.make_course_key('Org1', 'Course1', 'Run1') + self._create_course_with_access_groups(course_key, self.user, store=store) - with patch('xmodule.modulestore.mongo.base.MongoKeyValueStore', Mock(side_effect=Exception)): - self.assertIsInstance(modulestore().get_course(course_key), ErrorDescriptor) + with patch(path_to_patch, Mock(side_effect=Exception)): + self.assertIsInstance(self.store.get_course(course_key), ErrorDescriptor) - # get courses through iterating all courses - courses_list, __ = _accessible_courses_list(self.request) - self.assertEqual(courses_list, []) + # get courses through iterating all courses + courses_list, __ = _accessible_courses_list(self.request) + self.assertEqual(courses_list, []) - # get courses by reversing group name formats - courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request) - self.assertEqual(courses_list_by_groups, []) + # get courses by reversing group name formats + courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request) + self.assertEqual(courses_list_by_groups, []) - def test_errored_course_regular_access(self): + @ddt.data( + (ModuleStoreEnum.Type.split, 5), + (ModuleStoreEnum.Type.mongo, 3) + ) + @ddt.unpack + def test_staff_course_listing(self, default_store, mongo_calls): + """ + Create courses and verify they take certain amount of mongo calls to call get_courses_accessible_to_user. + Also verify that fetch accessible courses list for staff user returns CourseSummary instances. + """ + + # Assign & verify staff role to the user + GlobalStaff().add_users(self.user) + self.assertTrue(GlobalStaff().has_user(self.user)) + + with self.store.default_store(default_store): + # Create few courses + for num in xrange(TOTAL_COURSES_COUNT): + course_location = self.store.make_course_key('Org', 'CreatedCourse' + str(num), 'Run') + self._create_course_with_access_groups(course_location, self.user, default_store) + + # Fetch accessible courses list & verify their count + courses_list_by_staff, __ = get_courses_accessible_to_user(self.request) + self.assertEqual(len(courses_list_by_staff), TOTAL_COURSES_COUNT) + + # Verify fetched accessible courses list is a list of CourseSummery instances + self.assertTrue(all(isinstance(course, CourseSummary) for course in courses_list_by_staff)) + + # Now count the db queries for staff + with check_mongo_calls(mongo_calls): + _staff_accessible_course_list(self.request) + + @ddt.data( + (ModuleStoreEnum.Type.split, 'xmodule.modulestore.split_mongo.split_mongo_kvs.SplitMongoKVS'), + (ModuleStoreEnum.Type.mongo, 'xmodule.modulestore.mongo.base.MongoKeyValueStore') + ) + @ddt.unpack + def test_errored_course_regular_access(self, store, path_to_patch): """ Test the course list for regular staff when get_course returns an ErrorDescriptor """ GlobalStaff().remove_users(self.user) - CourseStaffRole(self.store.make_course_key('Non', 'Existent', 'Course')).add_users(self.user) - course_key = self.store.make_course_key('Org1', 'Course1', 'Run1') - self._create_course_with_access_groups(course_key, self.user) + with self.store.default_store(store): + CourseStaffRole(self.store.make_course_key('Non', 'Existent', 'Course')).add_users(self.user) - with patch('xmodule.modulestore.mongo.base.MongoKeyValueStore', Mock(side_effect=Exception)): - self.assertIsInstance(modulestore().get_course(course_key), ErrorDescriptor) + course_key = self.store.make_course_key('Org1', 'Course1', 'Run1') + self._create_course_with_access_groups(course_key, self.user, store) - # get courses through iterating all courses - courses_list, __ = _accessible_courses_list(self.request) - self.assertEqual(courses_list, []) + with patch(path_to_patch, Mock(side_effect=Exception)): + self.assertIsInstance(self.store.get_course(course_key), ErrorDescriptor) - # get courses by reversing group name formats - courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request) - self.assertEqual(courses_list_by_groups, []) - self.assertEqual(courses_list, courses_list_by_groups) + # get courses through iterating all courses + courses_list, __ = _accessible_courses_list(self.request) + self.assertEqual(courses_list, []) - def test_get_course_list_with_invalid_course_location(self): + # get courses by reversing group name formats + courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request) + self.assertEqual(courses_list_by_groups, []) + self.assertEqual(courses_list, courses_list_by_groups) + + @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo) + def test_get_course_list_with_invalid_course_location(self, store): """ Test getting courses with invalid course location (course deleted from modulestore). """ - course_key = self.store.make_course_key('Org', 'Course', 'Run') - self._create_course_with_access_groups(course_key, self.user) + with self.store.default_store(store): + course_key = self.store.make_course_key('Org', 'Course', 'Run') + self._create_course_with_access_groups(course_key, self.user, store) # get courses through iterating all courses courses_list, __ = _accessible_courses_list(self.request) @@ -155,7 +204,12 @@ class TestCourseListing(ModuleStoreTestCase): courses_list, __ = _accessible_courses_list(self.request) self.assertEqual(len(courses_list), 0) - def test_course_listing_performance(self): + @ddt.data( + (ModuleStoreEnum.Type.split, 150, 505), + (ModuleStoreEnum.Type.mongo, USER_COURSES_COUNT, 3) + ) + @ddt.unpack + def test_course_listing_performance(self, store, courses_list_from_group_calls, courses_list_calls): """ Create large number of courses and give access of some of these courses to the user and compare the time to fetch accessible courses for the user through traversing all courses and @@ -165,15 +219,16 @@ class TestCourseListing(ModuleStoreTestCase): user_course_ids = random.sample(range(TOTAL_COURSES_COUNT), USER_COURSES_COUNT) # create courses and assign those to the user which have their number in user_course_ids - for number in range(TOTAL_COURSES_COUNT): - org = 'Org{0}'.format(number) - course = 'Course{0}'.format(number) - run = 'Run{0}'.format(number) - course_location = self.store.make_course_key(org, course, run) - if number in user_course_ids: - self._create_course_with_access_groups(course_location, self.user) - else: - self._create_course_with_access_groups(course_location) + with self.store.default_store(store): + for number in range(TOTAL_COURSES_COUNT): + org = 'Org{0}'.format(number) + course = 'Course{0}'.format(number) + run = 'Run{0}'.format(number) + course_location = self.store.make_course_key(org, course, run) + if number in user_course_ids: + self._create_course_with_access_groups(course_location, self.user, store=store) + else: + self._create_course_with_access_groups(course_location, store=store) # time the get courses by iterating through all courses with Timer() as iteration_over_courses_time_1: @@ -201,29 +256,29 @@ class TestCourseListing(ModuleStoreTestCase): self.assertGreaterEqual(iteration_over_courses_time_2.elapsed, iteration_over_groups_time_2.elapsed) # Now count the db queries - with check_mongo_calls(USER_COURSES_COUNT): + with check_mongo_calls(courses_list_from_group_calls): _accessible_courses_list_from_groups(self.request) + with check_mongo_calls(courses_list_calls): + _accessible_courses_list(self.request) # Calls: # 1) query old mongo # 2) get_more on old mongo # 3) query split (but no courses so no fetching of data) - with check_mongo_calls(3): - _accessible_courses_list(self.request) - def test_course_listing_errored_deleted_courses(self): + @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo) + def test_course_listing_errored_deleted_courses(self, store): """ Create good courses, courses that won't load, and deleted courses which still have roles. Test course listing. """ - store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) + with self.store.default_store(store): + course_location = self.store.make_course_key('testOrg', 'testCourse', 'RunBabyRun') + self._create_course_with_access_groups(course_location, self.user, store) - course_location = self.store.make_course_key('testOrg', 'testCourse', 'RunBabyRun') - self._create_course_with_access_groups(course_location, self.user) - - course_location = self.store.make_course_key('testOrg', 'doomedCourse', 'RunBabyRun') - self._create_course_with_access_groups(course_location, self.user) - store.delete_course(course_location, self.user.id) + course_location = self.store.make_course_key('testOrg', 'doomedCourse', 'RunBabyRun') + self._create_course_with_access_groups(course_location, self.user, store) + self.store.delete_course(course_location, self.user.id) # pylint: disable=no-member courses_list, __ = _accessible_courses_list_from_groups(self.request) self.assertEqual(len(courses_list), 1, courses_list) @@ -241,7 +296,7 @@ class TestCourseListing(ModuleStoreTestCase): run=org_course_one.run ) - org_course_two = self.store.make_course_key('AwesomeOrg', 'Course2', 'RunRunRun') + org_course_two = self.store.make_course_key('AwesomeOrg', 'Course2', 'RunBabyRun') CourseFactory.create( org=org_course_two.org, number=org_course_two.course, diff --git a/cms/djangoapps/contentstore/tests/test_proctoring.py b/cms/djangoapps/contentstore/tests/test_proctoring.py index 535245fc81..d7e08f85b2 100644 --- a/cms/djangoapps/contentstore/tests/test_proctoring.py +++ b/cms/djangoapps/contentstore/tests/test_proctoring.py @@ -11,7 +11,10 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from contentstore.signals import listen_for_course_publish -from edx_proctoring.api import get_all_exams_for_course +from edx_proctoring.api import ( + get_all_exams_for_course, + get_review_policy_by_exam_id +) @ddt.ddt @@ -44,21 +47,28 @@ class TestProctoredExams(ModuleStoreTestCase): self.assertEqual(len(exams), 1) exam = exams[0] + + if exam['is_proctored'] and not exam['is_practice_exam']: + # get the review policy object + exam_review_policy = get_review_policy_by_exam_id(exam['id']) + self.assertEqual(exam_review_policy['review_policy'], sequence.exam_review_rules) + self.assertEqual(exam['course_id'], unicode(self.course.id)) self.assertEqual(exam['content_id'], unicode(sequence.location)) self.assertEqual(exam['exam_name'], sequence.display_name) self.assertEqual(exam['time_limit_mins'], sequence.default_time_limit_minutes) self.assertEqual(exam['is_proctored'], sequence.is_proctored_exam) + self.assertEqual(exam['is_practice_exam'], sequence.is_practice_exam) self.assertEqual(exam['is_active'], expected_active) @ddt.data( - (True, 10, True, True, False), - (True, 10, False, True, False), - (True, 10, True, True, True), + (True, 10, True, False, True, False), + (True, 10, False, False, True, False), + (True, 10, True, True, True, True), ) @ddt.unpack def test_publishing_exam(self, is_time_limited, default_time_limit_minutes, - is_proctored_exam, expected_active, republish): + is_proctored_exam, is_practice_exam, expected_active, republish): """ Happy path testing to see that when a course is published which contains a proctored exam, it will also put an entry into the exam tables @@ -73,7 +83,9 @@ class TestProctoredExams(ModuleStoreTestCase): is_time_limited=is_time_limited, default_time_limit_minutes=default_time_limit_minutes, is_proctored_exam=is_proctored_exam, - due=datetime.now(UTC) + timedelta(minutes=default_time_limit_minutes + 1) + is_practice_exam=is_practice_exam, + due=datetime.now(UTC) + timedelta(minutes=default_time_limit_minutes + 1), + exam_review_rules="allow_use_of_paper" ) listen_for_course_publish(self, self.course.id) @@ -205,7 +217,8 @@ class TestProctoredExams(ModuleStoreTestCase): graded=True, is_time_limited=True, default_time_limit_minutes=10, - is_proctored_exam=True + is_proctored_exam=True, + exam_review_rules="allow_use_of_paper" ) listen_for_course_publish(self, self.course.id) diff --git a/cms/djangoapps/contentstore/views/certificates.py b/cms/djangoapps/contentstore/views/certificates.py index 614a690bb2..27a0382448 100644 --- a/cms/djangoapps/contentstore/views/certificates.py +++ b/cms/djangoapps/contentstore/views/certificates.py @@ -359,7 +359,10 @@ def certificates_list_handler(request, course_key_string): course_id=course.id, include_expired=True ) if mode.slug != 'audit' ] - if len(course_modes) > 0: + + has_certificate_modes = len(course_modes) > 0 + + if has_certificate_modes: certificate_web_view_url = get_lms_link_for_certificate_web_view( user_id=request.user.id, course_key=course_key, @@ -382,6 +385,7 @@ def certificates_list_handler(request, course_key_string): 'course_outline_url': course_outline_url, 'upload_asset_url': upload_asset_url, 'certificates': certificates, + 'has_certificate_modes': has_certificate_modes, 'course_modes': course_modes, 'certificate_web_view_url': certificate_web_view_url, 'is_active': is_active, diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 3e82680971..929da3e027 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -17,6 +17,7 @@ import django.utils from django.utils.translation import ugettext as _ from django.views.decorators.http import require_http_methods, require_GET from django.views.decorators.csrf import ensure_csrf_cookie + from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import Location @@ -345,6 +346,39 @@ def _course_outline_json(request, course_module): ) +def get_in_process_course_actions(request): + """ + Get all in-process course actions + """ + return [ + course for course in + CourseRerunState.objects.find_all( + exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED}, should_display=True + ) + if has_studio_read_access(request.user, course.course_key) + ] + + +def _staff_accessible_course_list(request): + """ + List all courses available to the logged in user by iterating through all the courses + """ + def course_filter(course_summary): + """ + Filter out unusable and inaccessible courses + """ + # pylint: disable=fixme + # TODO remove this condition when templates purged from db + if course_summary.location.course == 'templates': + return False + + return has_studio_read_access(request.user, course_summary.id) + + courses_summary = filter(course_filter, modulestore().get_course_summaries()) + in_process_course_actions = get_in_process_course_actions(request) + return courses_summary, in_process_course_actions + + def _accessible_courses_list(request): """ List all courses available to the logged in user by iterating through all the courses @@ -364,13 +398,8 @@ def _accessible_courses_list(request): return has_studio_read_access(request.user, course.id) courses = filter(course_filter, modulestore().get_courses()) - in_process_course_actions = [ - course for course in - CourseRerunState.objects.find_all( - exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED}, should_display=True - ) - if has_studio_read_access(request.user, course.course_key) - ] + + in_process_course_actions = get_in_process_course_actions(request) return courses, in_process_course_actions @@ -593,7 +622,7 @@ def get_courses_accessible_to_user(request): """ if GlobalStaff().has_user(request.user): # user has global access so no need to get courses from django groups - courses, in_process_course_actions = _accessible_courses_list(request) + courses, in_process_course_actions = _staff_accessible_course_list(request) else: try: courses, in_process_course_actions = _accessible_courses_list_from_groups(request) @@ -626,9 +655,9 @@ def _remove_in_process_courses(courses, in_process_course_actions): in_process_action_course_keys = [uca.course_key for uca in in_process_course_actions] courses = [ - format_course_for_view(c) - for c in courses - if not isinstance(c, ErrorDescriptor) and (c.id not in in_process_action_course_keys) + format_course_for_view(course) + for course in courses + if not isinstance(course, ErrorDescriptor) and (course.id not in in_process_action_course_keys) ] return courses diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 40f910cb0a..ca6a6160af 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -834,7 +834,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F xblock_info = { "id": unicode(xblock.location), - "display_name": xblock.display_name_with_default, + "display_name": xblock.display_name_with_default_escaped, "category": xblock.category, "edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None, "published": published, @@ -869,6 +869,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F "is_proctored_exam": xblock.is_proctored_exam, "is_practice_exam": xblock.is_practice_exam, "is_time_limited": xblock.is_time_limited, + "exam_review_rules": xblock.exam_review_rules, "default_time_limit_minutes": xblock.default_time_limit_minutes }) @@ -1097,4 +1098,4 @@ def _xblock_type_and_display_name(xblock): """ return _('{section_or_subsection} "{display_name}"').format( section_or_subsection=xblock_type_display_name(xblock), - display_name=xblock.display_name_with_default) + display_name=xblock.display_name_with_default_escaped) diff --git a/cms/djangoapps/contentstore/views/program.py b/cms/djangoapps/contentstore/views/program.py index c79117fb54..5772d6edaf 100644 --- a/cms/djangoapps/contentstore/views/program.py +++ b/cms/djangoapps/contentstore/views/program.py @@ -1,4 +1,5 @@ """Programs views for use with Studio.""" +from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.urlresolvers import reverse from django.http import Http404, JsonResponse @@ -27,6 +28,7 @@ class ProgramAuthoringView(View): return render_to_response('program_authoring.html', { 'show_programs_header': programs_config.is_studio_tab_enabled, 'authoring_app_config': programs_config.authoring_app_config, + 'lms_base_url': '//{}/'.format(settings.LMS_BASE), 'programs_api_url': programs_config.public_api_url, 'programs_token_url': reverse('programs_id_token'), 'studio_home_url': reverse('home'), diff --git a/cms/djangoapps/contentstore/views/tests/test_certificates.py b/cms/djangoapps/contentstore/views/tests/test_certificates.py index a62097bdf5..4c60792d2a 100644 --- a/cms/djangoapps/contentstore/views/tests/test_certificates.py +++ b/cms/djangoapps/contentstore/views/tests/test_certificates.py @@ -195,6 +195,7 @@ class CertificatesBaseTestCase(object): self.assertTrue('must have name of the certificate' in context.exception) +@ddt.ddt @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) class CertificatesListHandlerTestCase(EventTestMixin, CourseTestCase, CertificatesBaseTestCase, HelperMethods): """ @@ -340,6 +341,40 @@ class CertificatesListHandlerTestCase(EventTestMixin, CourseTestCase, Certificat self.assertContains(response, 'verified') self.assertNotContains(response, 'audit') + def test_audit_only_disables_cert(self): + """ + Tests audit course mode is skipped when rendering certificates page. + """ + CourseModeFactory.create(course_id=self.course.id, mode_slug='audit') + response = self.client.get_html( + self._url(), + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'This course does not use a mode that offers certificates.') + self.assertNotContains(response, 'This module is not enabled.') + self.assertNotContains(response, 'Loading') + + @ddt.data( + ['audit', 'verified'], + ['verified'], + ['audit', 'verified', 'credit'], + ['verified', 'credit'], + ['professional'] + ) + def test_non_audit_enables_cert(self, slugs): + """ + Tests audit course mode is skipped when rendering certificates page. + """ + for slug in slugs: + CourseModeFactory.create(course_id=self.course.id, mode_slug=slug) + response = self.client.get_html( + self._url(), + ) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'This course does not use a mode that offers certificates.') + self.assertNotContains(response, 'This module is not enabled.') + self.assertContains(response, 'Loading') + def test_assign_unique_identifier_to_certificates(self): """ Test certificates have unique ids diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 6ecc9f97eb..c17917f326 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -1374,15 +1374,6 @@ class TestComponentTemplates(CourseTestCase): self.assertNotEqual(only_template.get('category'), 'video') self.assertNotEqual(only_template.get('category'), 'openassessment') - def test_advanced_components_without_display_name(self): - """ - Test that advanced components without display names display their category instead. - """ - self.course.advanced_modules.append('graphical_slider_tool') - self.templates = get_component_templates(self.course) - template = self.get_templates_of_type('advanced')[0] - self.assertEqual(template.get('display_name'), 'graphical_slider_tool') - def test_advanced_problems(self): """ Test the handling of advanced problem templates. diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index e92dfcf756..d0af45138f 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -49,6 +49,7 @@ class CourseMetadata(object): 'is_proctored_enabled', 'is_time_limited', 'is_practice_exam', + 'exam_review_rules', 'self_paced' ] diff --git a/cms/envs/aws.py b/cms/envs/aws.py index bdb670eb66..9f4ab6aba9 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -306,7 +306,6 @@ EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update(AUTH_TOKE EVENT_TRACKING_BACKENDS['segmentio']['OPTIONS']['processors'][0]['OPTIONS']['whitelist'].extend( AUTH_TOKENS.get("EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST", [])) -SUBDOMAIN_BRANDING = ENV_TOKENS.get('SUBDOMAIN_BRANDING', {}) VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', []) ##### ACCOUNT LOCKOUT DEFAULT PARAMETERS ##### diff --git a/cms/envs/bok_choy.env.json b/cms/envs/bok_choy.env.json index 98dfbd0fd1..52fc884202 100644 --- a/cms/envs/bok_choy.env.json +++ b/cms/envs/bok_choy.env.json @@ -74,8 +74,6 @@ "ENTRANCE_EXAMS": true, "MILESTONES_APP": true, "PREVIEW_LMS_BASE": "localhost:8003", - "SUBDOMAIN_BRANDING": false, - "SUBDOMAIN_COURSE_LISTINGS": false, "ALLOW_ALL_ADVANCED_COMPONENTS": true, "ENABLE_CONTENT_LIBRARIES": true, "ENABLE_SPECIAL_EXAMS": true diff --git a/cms/envs/common.py b/cms/envs/common.py index af89b610c3..1b734c5433 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -803,6 +803,9 @@ INSTALLED_APPS = ( # edX Proctoring 'edx_proctoring', + # Bookmarks + 'openedx.core.djangoapps.bookmarks', + # programs support 'openedx.core.djangoapps.programs', @@ -997,7 +1000,6 @@ ADVANCED_COMPONENT_TYPES = [ 'videoannotation', # module for annotating video (with annotation table) 'imageannotation', # module for annotating image (with annotation table) 'word_cloud', - 'graphical_slider_tool', 'lti', 'lti_consumer', 'library_content', @@ -1115,7 +1117,11 @@ CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60 ################################ Deprecated Blocks Info ################################ -DEPRECATED_BLOCK_TYPES = ['peergrading', 'combinedopenended'] +DEPRECATED_BLOCK_TYPES = [ + 'peergrading', + 'combinedopenended', + 'graphical_slider_tool', +] #### PROCTORING CONFIGURATION DEFAULTS diff --git a/cms/static/cms/js/require-config.js b/cms/static/cms/js/require-config.js index 8139da6206..4ceb2635bd 100644 --- a/cms/static/cms/js/require-config.js +++ b/cms/static/cms/js/require-config.js @@ -24,7 +24,7 @@ require.config({ "jquery.ui": "js/vendor/jquery-ui.min", "jquery.form": "js/vendor/jquery.form", "jquery.markitup": "js/vendor/markitup/jquery.markitup", - "jquery.leanModal": "js/vendor/jquery.leanModal.min", + "jquery.leanModal": "js/vendor/jquery.leanModal", "jquery.ajaxQueue": "js/vendor/jquery.ajaxQueue", "jquery.smoothScroll": "js/vendor/jquery.smooth-scroll.min", "jquery.timepicker": "js/vendor/timepicker/jquery.timepicker", @@ -283,7 +283,7 @@ require.config({ "osda":{ exports: "osda", deps: ["annotator", "annotator-harvardx", "video.dev", "vjs.youtube", "rangeslider", "share-annotator", "richText-annotator", "reply-annotator", "tags-annotator", "flagging-annotator", "grouping-annotator", "diacritic-annotator", "openseadragon", "jquery-Watch", "catch", "handlebars", "URI"] - }, + } // end of annotation tool files } }); diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index a2a5cb3083..3aac8f84c6 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -7,7 +7,7 @@ requirejs.config({ "jquery.ui": "xmodule_js/common_static/js/vendor/jquery-ui.min", "jquery.form": "xmodule_js/common_static/js/vendor/jquery.form", "jquery.markitup": "xmodule_js/common_static/js/vendor/markitup/jquery.markitup", - "jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal.min", + "jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal", "jquery.ajaxQueue": "xmodule_js/common_static/js/vendor/jquery.ajaxQueue", "jquery.smoothScroll": "xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min", "jquery.scrollTo": "xmodule_js/common_static/js/vendor/jquery.scrollTo-1.4.2-min", @@ -253,6 +253,7 @@ define([ "js/spec/views/xblock_validation_spec", "js/spec/views/license_spec", "js/spec/views/paging_spec", + "js/spec/views/login_studio_spec", "js/spec/views/pages/container_spec", "js/spec/views/pages/container_subviews_spec", diff --git a/cms/static/coffee/spec/main_squire.coffee b/cms/static/coffee/spec/main_squire.coffee index 20cb7bc7a0..ebefb931a2 100644 --- a/cms/static/coffee/spec/main_squire.coffee +++ b/cms/static/coffee/spec/main_squire.coffee @@ -7,7 +7,7 @@ requirejs.config({ "jquery.ui": "xmodule_js/common_static/js/vendor/jquery-ui.min", "jquery.form": "xmodule_js/common_static/js/vendor/jquery.form", "jquery.markitup": "xmodule_js/common_static/js/vendor/markitup/jquery.markitup", - "jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal.min", + "jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal", "jquery.smoothScroll": "xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min", "jquery.scrollTo": "xmodule_js/common_static/js/vendor/jquery.scrollTo-1.4.2-min", "jquery.timepicker": "xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker", diff --git a/cms/static/coffee/spec/views/module_edit_spec.coffee b/cms/static/coffee/spec/views/module_edit_spec.coffee index 93a7b476d5..2c5ac486fe 100644 --- a/cms/static/coffee/spec/views/module_edit_spec.coffee +++ b/cms/static/coffee/spec/views/module_edit_spec.coffee @@ -1,4 +1,6 @@ -define ["jquery", "js/spec_helpers/edit_helpers", "coffee/src/views/module_edit", "js/models/module_info", "xmodule"], ($, edit_helpers, ModuleEdit, ModuleModel) -> +define ["jquery", "common/js/components/utils/view_utils", "js/spec_helpers/edit_helpers", + "coffee/src/views/module_edit", "js/models/module_info", "xmodule"], + ($, ViewUtils, edit_helpers, ModuleEdit, ModuleModel) -> describe "ModuleEdit", -> beforeEach -> @@ -60,7 +62,7 @@ define ["jquery", "js/spec_helpers/edit_helpers", "coffee/src/views/module_edit" spyOn(@moduleEdit, 'loadDisplay') spyOn(@moduleEdit, 'delegateEvents') spyOn($.fn, 'append') - spyOn($, 'getScript').andReturn($.Deferred().resolve().promise()) + spyOn(ViewUtils, 'loadJavaScript').andReturn($.Deferred().resolve().promise()); window.MockXBlock = (runtime, element) -> return { } @@ -150,7 +152,7 @@ define ["jquery", "js/spec_helpers/edit_helpers", "coffee/src/views/module_edit" expect($('head').append).toHaveBeenCalledWith("") it "loads js urls from fragments", -> - expect($.getScript).toHaveBeenCalledWith("js-url") + expect(ViewUtils.loadJavaScript).toHaveBeenCalledWith("js-url") it "loads head html", -> expect($('head').append).toHaveBeenCalledWith("head-html") diff --git a/cms/static/js/factories/login.js b/cms/static/js/factories/login.js index fcf8ee3069..39480917d8 100644 --- a/cms/static/js/factories/login.js +++ b/cms/static/js/factories/login.js @@ -1,4 +1,4 @@ -define(['jquery.cookie', 'utility'], function() { +define(['jquery.cookie', 'utility', 'common/js/components/utils/view_utils'], function(cookie, utility, ViewUtils) { 'use strict'; return function (homepageURL) { function postJSON(url, data, callback) { @@ -22,15 +22,19 @@ define(['jquery.cookie', 'utility'], function() { $('form#login_form').submit(function(event) { event.preventDefault(); + var submitButton = $('#submit'), + deferred = new $.Deferred(), + promise = deferred.promise(); + ViewUtils.disableElementWhileRunning(submitButton, function() { return promise; }); var submit_data = $('#login_form').serialize(); postJSON('/login_post', submit_data, function(json) { if(json.success) { var next = /next=([^&]*)/g.exec(decodeURIComponent(window.location.search)); if (next && next.length > 1 && !isExternal(next[1])) { - location.href = next[1]; + ViewUtils.redirect(next[1]); } else { - location.href = homepageURL; + ViewUtils.redirect(homepageURL); } } else if($('#login_error').length === 0) { $('#login_form').prepend( @@ -39,11 +43,13 @@ define(['jquery.cookie', 'utility'], function() { '' ); $('#login_error').addClass('is-shown'); + deferred.resolve(); } else { $('#login_error') .stop() .addClass('is-shown') .html(json.value); + deferred.resolve(); } }); }); diff --git a/cms/static/js/spec/views/login_studio_spec.js b/cms/static/js/spec/views/login_studio_spec.js new file mode 100644 index 0000000000..2fb44c2fe2 --- /dev/null +++ b/cms/static/js/spec/views/login_studio_spec.js @@ -0,0 +1,32 @@ +define(['jquery', 'js/factories/login', 'common/js/spec_helpers/ajax_helpers', 'common/js/components/utils/view_utils'], +function($, LoginFactory, AjaxHelpers, ViewUtils) { + 'use strict'; + describe("Studio Login Page", function() { + var submitButton; + + beforeEach(function() { + loadFixtures('mock/login.underscore'); + /*jshint unused: false*/ + var login_factory = new LoginFactory("/home/"); + submitButton = $('#submit'); + }); + + it('disable the submit button once it is clicked', function() { + spyOn(ViewUtils, 'redirect').andCallFake(function(){}); + var requests = AjaxHelpers.requests(this); + expect(submitButton).not.toHaveClass('is-disabled'); + submitButton.click(); + AjaxHelpers.respondWithJson(requests, {'success': true}); + expect(submitButton).toHaveClass('is-disabled'); + }); + + it('It will not disable the submit button if there are errors in ajax request', function() { + var requests = AjaxHelpers.requests(this); + expect(submitButton).not.toHaveClass('is-disabled'); + submitButton.click(); + expect(submitButton).toHaveClass('is-disabled'); + AjaxHelpers.respondWithError(requests, {}); + expect(submitButton).not.toHaveClass('is-disabled'); + }); + }); +}); diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index 56ee53b2a5..e617c9fbf3 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -216,7 +216,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u 'course-outline', 'xblock-string-field-editor', 'modal-button', 'basic-modal', 'course-outline-modal', 'release-date-editor', 'due-date-editor', 'grading-editor', 'publish-editor', - 'staff-lock-editor', 'timed-examination-preference-editor' + 'staff-lock-editor', 'settings-tab-section', 'timed-examination-preference-editor' ]); appendSetFixtures(mockOutlinePage); mockCourseJSON = createMockCourseJSON({}, [ @@ -580,7 +580,8 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u describe("Subsection", function() { var getDisplayNameWrapper, setEditModalValues, mockServerValuesJson, - selectDisableSpecialExams, selectTimedExam, selectProctoredExam, selectPracticeExam; + selectDisableSpecialExams, selectGeneralSettings, selectAdvancedSettings, + selectTimedExam, selectProctoredExam, selectPracticeExam; getDisplayNameWrapper = function() { return getItemHeaders('subsection').find('.wrapper-xblock-field'); @@ -597,6 +598,14 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u this.$("#id_not_timed").prop('checked', true).trigger('change'); }; + selectGeneralSettings = function() { + this.$(".modal-section .general-settings-button").click(); + }; + + selectAdvancedSettings = function() { + this.$(".modal-section .advanced-settings-button").click(); + }; + selectTimedExam = function(time_limit) { this.$("#id_timed_exam").prop('checked', true).trigger('change'); this.$("#id_time_limit").val(time_limit); @@ -701,6 +710,45 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u collapseItemsAndVerifyState('subsection'); expandItemsAndVerifyState('subsection'); }); + + it('can show general settings', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + selectGeneralSettings(); + expect($('.modal-section .general-settings-button')).toHaveClass('active'); + expect($('.modal-section .advanced-settings-button')).not.toHaveClass('active'); + }); + + it('can show advanced settings', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + selectAdvancedSettings(); + expect($('.modal-section .general-settings-button')).not.toHaveClass('active'); + expect($('.modal-section .advanced-settings-button')).toHaveClass('active'); + }); + + it('can select valid time', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + selectAdvancedSettings(); + + var default_time = "00:30"; + var valid_times = ["00:30", "23:00", "24:00", "99:00"]; + var invalid_times = ["00:00", "100:00", "01:60"]; + var time_limit, i; + + for (i = 0; i < valid_times.length; i++){ + time_limit = valid_times[i]; + selectTimedExam(time_limit); + expect($("#id_time_limit").val()).toEqual(time_limit); + } + for (i = 0; i < invalid_times.length; i++){ + time_limit = invalid_times[i]; + selectTimedExam(time_limit); + expect($("#id_time_limit").val()).not.toEqual(time_limit); + expect($("#id_time_limit").val()).toEqual(default_time); + } + }); it('can be edited', function() { createCourseOutlinePage(this, mockCourseJSON, false); @@ -715,6 +763,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u "visible_to_staff_only": true, "start":"2014-07-09T00:00:00.000Z", "due":"2014-07-10T00:00:00.000Z", + "exam_review_rules": "", "is_time_limited": true, "is_practice_exam": false, "is_proctored_enabled": true, diff --git a/cms/static/js/spec/views/xblock_spec.js b/cms/static/js/spec/views/xblock_spec.js index 6eb9753b63..755d7904a5 100644 --- a/cms/static/js/spec/views/xblock_spec.js +++ b/cms/static/js/spec/views/xblock_spec.js @@ -1,7 +1,7 @@ -define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/xblock", "js/models/xblock_info", - "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], - function ($, AjaxHelpers, URI, XBlockView, XBlockInfo) { - +define(["jquery", "URI", "common/js/spec_helpers/ajax_helpers", "common/js/components/utils/view_utils", + "js/views/xblock", "js/models/xblock_info", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], + function ($, URI, AjaxHelpers, ViewUtils, XBlockView, XBlockInfo) { + "use strict"; describe("XBlockView", function() { var model, xblockView, mockXBlockHtml; @@ -89,11 +89,11 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/xbloc it('aborts rendering when a dependent script fails to load', function() { var requests = AjaxHelpers.requests(this), - mockJavaScriptUrl = "mock.js", + missingJavaScriptUrl = "no_such_file.js", promise; - spyOn($, 'getScript').andReturn($.Deferred().reject().promise()); + spyOn(ViewUtils, 'loadJavaScript').andReturn($.Deferred().reject().promise()); promise = postXBlockRequest(requests, [ - ["hash5", { mimetype: "application/javascript", kind: "url", data: mockJavaScriptUrl }] + ["hash5", { mimetype: "application/javascript", kind: "url", data: missingJavaScriptUrl }] ]); expect(promise.isRejected()).toBe(true); }); @@ -104,7 +104,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/xbloc postXBlockRequest(AjaxHelpers.requests(this), []); xblockView.$el.find(".notification-action-button").click(); expect(notifySpy).toHaveBeenCalledWith("add-missing-groups", model.get("id")); - }) + }); }); }); }); diff --git a/cms/static/js/views/import.js b/cms/static/js/views/import.js index 2296df19ec..8c8354eb1f 100644 --- a/cms/static/js/views/import.js +++ b/cms/static/js/views/import.js @@ -98,7 +98,7 @@ define( file: file, date: moment().valueOf(), completed: completed || false - })); + }), {path: window.location.pathname}); }; /** diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index db3286ec82..a430607212 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -84,7 +84,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', getContext: function () { return $.extend({ xblockInfo: this.model, - introductionMessage: this.getIntroductionMessage() + introductionMessage: this.getIntroductionMessage(), + enable_proctored_exams: this.options.enable_proctored_exams, + enable_timed_exams: this.options.enable_timed_exams }); }, @@ -114,6 +116,78 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', gettext('Change the settings for %(display_name)s'), { display_name: this.model.get('display_name') }, true ); + }, + + initializeEditors: function () { + var special_exams_editors = this.options.special_exam_editors; + if (typeof special_exams_editors !== 'undefined' && special_exams_editors.length > 0) { + var tabs_html = this.loadTemplate('settings-tab-section'); + this.$('.modal-section').html(tabs_html); + this.options.editors = _.map(this.options.editors, function (Editor) { + return new Editor({ + parentElement: this.$('.modal-section .general-settings'), + model: this.model, + xblockType: this.options.xblockType, + enable_proctored_exams: this.options.enable_proctored_exams, + enable_timed_exams: this.options.enable_timed_exams + }); + }, this); + + this.options.special_exam_editors = _.map(special_exams_editors, function (Editor) { + return new Editor({ + parentElement: this.$('.modal-section .advanced-settings'), + model: this.model, + xblockType: this.options.xblockType, + enable_proctored_exams: this.options.enable_proctored_exams, + enable_timed_exams: this.options.enable_timed_exams + }); + }, this); + this.hideAdvancedSettings(); + } else { + CourseOutlineXBlockModal.prototype.initializeEditors.call(this); + } + }, + + events: { + 'click .action-save': 'save', + 'click .general-settings-button': 'showGeneralSettings', + 'click .advanced-settings-button': 'showAdvancedSettings' + }, + + /** + * Return request data. + * @return {Object} + */ + getRequestData: function () { + var combined_editors = this.options.editors.concat(this.options.special_exam_editors); + var requestData = _.map(combined_editors, function (editor) { + return editor.getRequestData(); + }); + return $.extend.apply(this, [true, {}].concat(requestData)); + }, + + hideAdvancedSettings: function() { + this.$('.modal-section .general-settings-button').addClass('active'); + this.$('.modal-section .advanced-settings-button').removeClass('active'); + this.$('.modal-section .general-settings').show(); + this.$('.modal-section .advanced-settings').hide(); + + }, + + hideGeneralSettings: function() { + this.$('.modal-section .general-settings-button').removeClass('active'); + this.$('.modal-section .advanced-settings-button').addClass('active'); + this.$('.modal-section .general-settings').hide(); + this.$('.modal-section .advanced-settings').show(); + }, + showGeneralSettings: function (event) { + event.preventDefault(); + this.hideAdvancedSettings(); + }, + + showAdvancedSettings: function (event) { + event.preventDefault(); + this.hideGeneralSettings(); } }); @@ -267,20 +341,40 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', className: 'edit-settings-timed-examination', events : { 'change #id_not_timed': 'notTimedExam', - 'change #id_timed_exam': 'showTimeLimit', - 'change #id_practice_exam': 'showTimeLimit', - 'change #id_proctored_exam': 'showTimeLimit', + 'change #id_timed_exam': 'setTimedExam', + 'change #id_practice_exam': 'setPracticeExam', + 'change #id_proctored_exam': 'setProctoredExam', 'focusout #id_time_limit': 'timeLimitFocusout' }, notTimedExam: function (event) { event.preventDefault(); this.$('#id_time_limit_div').hide(); + this.$('.exam-review-rules-list-fields').hide(); this.$('#id_time_limit').val('00:00'); }, - showTimeLimit: function (event) { - event.preventDefault(); + selectSpecialExam: function (showRulesField) { this.$('#id_time_limit_div').show(); - this.$('#id_time_limit').val("00:30"); + if (!this.isValidTimeLimit(this.$('#id_time_limit').val())) { + this.$('#id_time_limit').val('00:30'); + } + if (showRulesField) { + this.$('.exam-review-rules-list-fields').show(); + } + else { + this.$('.exam-review-rules-list-fields').hide(); + } + }, + setTimedExam: function (event) { + event.preventDefault(); + this.selectSpecialExam(false); + }, + setPracticeExam: function (event) { + event.preventDefault(); + this.selectSpecialExam(false); + }, + setProctoredExam: function (event) { + event.preventDefault(); + this.selectSpecialExam(true); }, timeLimitFocusout: function(event) { event.preventDefault(); @@ -294,13 +388,15 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', this.$('input.time').timepicker({ 'timeFormat' : 'H:i', 'minTime': '00:30', - 'maxTime': '05:00', + 'maxTime': '24:00', 'forceRoundTime': false }); this.setExamType(this.model.get('is_time_limited'), this.model.get('is_proctored_exam'), this.model.get('is_practice_exam')); this.setExamTime(this.model.get('default_time_limit_minutes')); + + this.setReviewRules(this.model.get('exam_review_rules')); }, setExamType: function(is_time_limited, is_proctored_exam, is_practice_exam) { if (!is_time_limited) { @@ -309,12 +405,14 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', } this.$('#id_time_limit_div').show(); + this.$('.exam-review-rules-list-fields').hide(); if (this.options.enable_proctored_exams && is_proctored_exam) { if (is_practice_exam) { this.$('#id_practice_exam').prop('checked', true); } else { this.$('#id_proctored_exam').prop('checked', true); + this.$('.exam-review-rules-list-fields').show(); } } else { // Since we have an early exit at the top of the method @@ -327,8 +425,11 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', var time = this.convertTimeLimitMinutesToString(value); this.$('#id_time_limit').val(time); }, + setReviewRules: function (value) { + this.$('#id_exam_review_rules').val(value); + }, isValidTimeLimit: function(time_limit) { - var pattern = new RegExp('^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$'); + var pattern = new RegExp('^\\d{1,2}:[0-5][0-9]$'); return pattern.test(time_limit) && time_limit !== "00:00"; }, getExamTimeLimit: function () { @@ -351,6 +452,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', var is_practice_exam; var is_proctored_exam; var time_limit = this.getExamTimeLimit(); + var exam_review_rules = this.$('#id_exam_review_rules').val(); if (this.$("#id_not_timed").is(':checked')){ is_time_limited = false; @@ -374,6 +476,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', metadata: { 'is_practice_exam': is_practice_exam, 'is_time_limited': is_time_limited, + 'exam_review_rules': exam_review_rules, // We have to use the legacy field name // as the Ajax handler directly populates // the xBlocks fields. We will have to @@ -584,6 +687,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', getEditModal: function (xblockInfo, options) { var editors = []; + var special_exam_editors = []; if (xblockInfo.isChapter()) { editors = [ReleaseDateEditor, StaffLockEditor]; @@ -592,7 +696,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', var enable_special_exams = (options.enable_proctored_exams || options.enable_timed_exams); if (enable_special_exams) { - editors.push(TimedExaminationPreferenceEditor); + special_exam_editors.push(TimedExaminationPreferenceEditor); } editors.push(StaffLockEditor); @@ -610,6 +714,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', } return new SettingsXBlockModal($.extend({ editors: editors, + special_exam_editors: special_exam_editors, model: xblockInfo }, options)); }, diff --git a/cms/static/js/views/xblock.js b/cms/static/js/views/xblock.js index 797cc8cb45..9a8643f58b 100644 --- a/cms/static/js/views/xblock.js +++ b/cms/static/js/views/xblock.js @@ -1,5 +1,6 @@ -define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], - function ($, _, BaseView, XBlock) { +define(["jquery", "underscore", "common/js/components/utils/view_utils", "js/views/baseview", "xblock/runtime.v1"], + function ($, _, ViewUtils, BaseView, XBlock) { + 'use strict'; var XBlockView = BaseView.extend({ // takes XBlockInfo as a model @@ -83,7 +84,7 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], * may have thrown JavaScript errors after rendering in which case the xblock parameter * will be null. */ - xblockReady: function(xblock) { + xblockReady: function(xblock) { // jshint ignore:line // Do nothing }, @@ -95,7 +96,7 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], * represents this process. * @param fragment The fragment returned from the xblock_handler * @param element The element into which to render the fragment (defaults to this.$el) - * @returns {jQuery promise} A promise representing the rendering process + * @returns {Promise} A promise representing the rendering process */ renderXBlockFragment: function(fragment, element) { var html = fragment.html, @@ -131,7 +132,7 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], * Dynamically loads all of an XBlock's dependent resources. This is an asynchronous * process so a promise is returned. * @param resources The resources to be rendered - * @returns {jQuery promise} A promise representing the rendering process + * @returns {Promise} A promise representing the rendering process */ addXBlockFragmentResources: function(resources) { var self = this, @@ -171,7 +172,7 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], /** * Loads the specified resource into the page. * @param resource The resource to be loaded. - * @returns {jQuery promise} A promise representing the loading of the resource. + * @returns {Promise} A promise representing the loading of the resource. */ loadResource: function(resource) { var head = $('head'), @@ -189,8 +190,7 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], if (kind === "text") { head.append(""); } else if (kind === "url") { - // Return a promise for the script resolution - return $.getScript(data); + return ViewUtils.loadJavaScript(data); } } else if (mimetype === "text/html") { if (placement === "head") { @@ -202,11 +202,11 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], }, fireNotificationActionEvent: function(event) { - var eventName = $(event.currentTarget).data("notification-action"); - if (eventName) { - event.preventDefault(); - this.notifyRuntime(eventName, this.model.get("id")); - } + var eventName = $(event.currentTarget).data("notification-action"); + if (eventName) { + event.preventDefault(); + this.notifyRuntime(eventName, this.model.get("id")); + } } }); diff --git a/cms/static/js_test.yml b/cms/static/js_test.yml index 37e229f4eb..ec2b3b257d 100644 --- a/cms/static/js_test.yml +++ b/cms/static/js_test.yml @@ -42,7 +42,7 @@ lib_paths: - xmodule_js/common_static/js/vendor/backbone.paginator.min.js - xmodule_js/common_static/js/vendor/backbone-relational.min.js - xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker.js - - xmodule_js/common_static/js/vendor/jquery.leanModal.min.js + - xmodule_js/common_static/js/vendor/jquery.leanModal.js - xmodule_js/common_static/js/vendor/jquery.ajaxQueue.js - xmodule_js/common_static/js/vendor/jquery.form.js - xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill.js diff --git a/cms/static/js_test_squire.yml b/cms/static/js_test_squire.yml index b3d6e13444..05175feb24 100644 --- a/cms/static/js_test_squire.yml +++ b/cms/static/js_test_squire.yml @@ -40,7 +40,7 @@ lib_paths: - xmodule_js/common_static/js/vendor/backbone-associations-min.js - xmodule_js/common_static/js/vendor/backbone.paginator.min.js - xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker.js - - xmodule_js/common_static/js/vendor/jquery.leanModal.min.js + - xmodule_js/common_static/js/vendor/jquery.leanModal.js - xmodule_js/common_static/js/vendor/jquery.form.js - xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill.js - xmodule_js/common_static/js/vendor/sinon-1.17.0.js diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index f57b2798c5..958080e4f9 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -99,6 +99,37 @@ &:last-child { margin-bottom: 0; } + .settings-tab { + margin-bottom: $baseline; + border-bottom: 1px solid $gray-l3; + + li.settings-section { + display: inline-block; + margin-right: $baseline; + + .general-settings-button, + .advanced-settings-button { + @extend %t-copy-sub1; + @extend %t-regular; + background-image: none; + background-color: $white; + color: $mediumGrey; + border-radius: 0; + box-shadow: none; + border: 0; + padding: ($baseline/4) ($baseline/2); + text-transform: uppercase; + &:hover { + background-color: $white; + color: $blue; + } + &.active { + border-bottom: 4px solid $blue-d2; + color: $offBlack; + } + } + } + } } .modal-section-title { @@ -528,7 +559,8 @@ .wrapper-modal-window-bulkpublish-subsection, .wrapper-modal-window-bulkpublish-unit, .course-outline-modal { - .exam-time-list-fields { + .exam-time-list-fields, + .exam-review-rules-list-fields { margin: 0 0 ($baseline/2) ($baseline/2); } .list-fields { diff --git a/cms/templates/base.html b/cms/templates/base.html index 6cd3eefd58..5d46a3c549 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -16,9 +16,9 @@ from openedx.core.lib.js_utils import ( <%block name="title">%block> | % if context_course: <% ctx_loc = context_course.location %> - ${context_course.display_name_with_default | h} | + ${context_course.display_name_with_default_escaped | h} | % elif context_library: - ${context_library.display_name_with_default | h} | + ${context_library.display_name_with_default_escaped | h} | % endif ${settings.STUDIO_NAME} @@ -81,11 +81,11 @@ from openedx.core.lib.js_utils import ( require(['js/factories/course'], function(CourseFactory) { CourseFactory({ id: "${escape_js_string(context_course.id) | n}", - name: "${context_course.display_name_with_default | h}", + name: "${context_course.display_name_with_default_escaped | h}", url_name: "${context_course.location.name | h}", org: "${context_course.location.org | h}", num: "${context_course.location.course | h}", - display_course_number: "${_(context_course.display_number_with_default)}", + display_course_number: "${_(context_course.display_coursenumber)}", revision: "${context_course.location.revision | h}", self_paced: ${escape_json_dumps(context_course.self_paced) | n} }); diff --git a/cms/templates/certificates.html b/cms/templates/certificates.html index 2b144c80d1..d038cfe37e 100644 --- a/cms/templates/certificates.html +++ b/cms/templates/certificates.html @@ -30,7 +30,9 @@ CMS.User.isGlobalStaff = '${is_global_staff}'=='True' ? true : false; <%block name="requirejs"> require(["js/certificates/factories/certificates_page_factory"], function(CertificatesPageFactory) { - CertificatesPageFactory(${escape_json_dumps(certificates) | n}, "${certificate_url}", "${course_outline_url}", ${escape_json_dumps(course_modes) | n}, ${escape_json_dumps(certificate_web_view_url) | n}, ${escape_json_dumps(is_active) | n}, ${escape_json_dumps(certificate_activation_handler_url) | n} ); + if(${escape_json_dumps(has_certificate_modes)}) { + CertificatesPageFactory(${escape_json_dumps(certificates) | n}, "${certificate_url}", "${course_outline_url}", ${escape_json_dumps(course_modes) | n}, ${escape_json_dumps(certificate_web_view_url) | n}, ${escape_json_dumps(is_active) | n}, ${escape_json_dumps(certificate_activation_handler_url) | n} ); + } }); %block> @@ -56,6 +58,12 @@ CMS.User.isGlobalStaff = '${is_global_staff}'=='True' ? true : false; ${_("This module is not enabled.")}
+ % elif not has_certificate_modes: ++ ${_("This course does not use a mode that offers certificates.")} +
+${_("Loading")}
diff --git a/cms/templates/container.html b/cms/templates/container.html index c44ac74b84..16bed1f970 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -9,10 +9,10 @@ else: %def> <%! from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name -from django.utils.translation import ugettext as _ from openedx.core.lib.js_utils import escape_json_dumps +from util.markup import HTML, ugettext as _ %> -<%block name="title">${xblock.display_name_with_default} ${xblock_type_display_name(xblock) | h}%block> +<%block name="title">${xblock.display_name_with_default_escaped} ${xblock_type_display_name(xblock) | h}%block> <%block name="bodyclass">is-signedin course container view-container%block> <%namespace name='static' file='static_content.html'/> @@ -55,15 +55,15 @@ from openedx.core.lib.js_utils import escape_json_dumps ancestor_url = xblock_studio_url(ancestor) %> % if ancestor_url: - ${ancestor.display_name_with_default | h} + ${ancestor.display_name_with_default_escaped | h} % else: - + % endif % endfor${_("Select a component type under {em_start}Add New Component{em_end}. Then select a template.").format(em_start='', em_end="") | h}
+${_("Select a component type under {strong_start}Add New Component{strong_end}. Then select a template.").format( + strong_start=HTML(''), + strong_end=HTML(""), + )}
${_("The new component is added at the bottom of the page or group. You can then edit and move the component.")}
${_("Click the {em_start}Edit{em_end} icon in a component to edit its content.").format(em_start='', em_end="") | h}
+${_("Click the {strong_start}Edit{strong_end} icon in a component to edit its content.").format( + strong_start=HTML(''), + strong_end=HTML(""), + )}
${_("Drag components to new locations within this component.")}
${_("For content experiments, you can drag components to other groups.")}
diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index 083950b824..bf360056c5 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -22,7 +22,7 @@ from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration <%block name="header_extras"> -% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'verification-access-editor', 'timed-examination-preference-editor']: +% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'settings-tab-section']: diff --git a/cms/templates/js/course-outline-modal.underscore b/cms/templates/js/course-outline-modal.underscore index 51a2d79f13..cda1793672 100644 --- a/cms/templates/js/course-outline-modal.underscore +++ b/cms/templates/js/course-outline-modal.underscore @@ -1,7 +1,14 @@ +<% +var enable_proctored_exams = enable_proctored_exams; +var enable_timed_exams = enable_timed_exams; +%> +${_("Hello, world!")}
+ + Or with formatting:: + + <% from util.markup import HTML, ugettext as _ %> + ${_("Write & send {start}email{end}").format( + start=HTML(""), + end=HTML(""), + )} + + """ + return markupsafe.escape(django_ugettext(text)) + + +def ungettext(text1, text2, num): + """Translate a number-sensitive string, and escape it as plain text.""" + return markupsafe.escape(django_ungettext(text1, text2, num)) + + +def HTML(html): # pylint: disable=invalid-name + """Mark a string as already HTML, so that it won't be escaped before output. + + Use this when formatting HTML into other strings:: + + <% from util.markup import HTML, ugettext as _ %> + ${_("Write & send {start}email{end}").format( + start=HTML(""), + end=HTML(""), + )} + + """ + return markupsafe.Markup(html) diff --git a/common/djangoapps/util/milestones_helpers.py b/common/djangoapps/util/milestones_helpers.py index 8c627b5a79..839b9519d9 100644 --- a/common/djangoapps/util/milestones_helpers.py +++ b/common/djangoapps/util/milestones_helpers.py @@ -186,7 +186,13 @@ def fulfill_course_milestone(course_key, user): if not settings.FEATURES.get('MILESTONES_APP', False): return None from milestones import api as milestones_api - course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship="fulfills") + from milestones.exceptions import InvalidMilestoneRelationshipTypeException + try: + course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship="fulfills") + except InvalidMilestoneRelationshipTypeException: + # we have not seeded milestone relationship types + seed_milestone_relationship_types() + course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship="fulfills") for milestone in course_milestones: milestones_api.add_user_milestone({'id': user.id}, milestone) diff --git a/common/djangoapps/util/tests/test_markup.py b/common/djangoapps/util/tests/test_markup.py new file mode 100644 index 0000000000..d700df57f8 --- /dev/null +++ b/common/djangoapps/util/tests/test_markup.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" +Tests for util.markup +""" + +import unittest + +import ddt + +from edxmako.template import Template +from util.markup import escape, HTML, ugettext as _, ungettext + + +@ddt.ddt +class FormatHtmlTest(unittest.TestCase): + """Test that we can format plain strings and HTML into them properly.""" + + @ddt.data( + (u"hello", u"hello"), + (u"You can make the range of the x axis (but not ticks of x axis) of - functions depend on a parameter value. This can be useful when the - function domain needs to be variable.
-Implicit functions like a circle can be plotted as 2 separate - functions of the same color.
-q)q=b;if(p<0){console.warn('Both arguements must be >= 0');return Number.NaN;}
-else if(p==0){return Number.POSITIVE_INFINITY;}
-else if(!jstat.isFinite(q)){return Number.NEGATIVE_INFINITY;}
-if(p>=10){corr=jstat.lgammacor(p)+jstat.lgammacor(q)-jstat.lgammacor(p+q);return Math.log(q)*-0.5+jstat.LN_SQRT_2PI+corr
-+(p-0.5)*Math.log(p/(p+q))+q*jstat.log1p(-p/(p+q));}
-else if(q>=10){corr=jstat.lgammacor(q)-jstat.lgammacor(p+q);return jstat.lgamma(p)+corr+p-p*Math.log(p+q)
-+(q-0.5)*jstat.log1p(-p/(p+q));}
-else
-return Math.log(jstat.gamma(p)*(jstat.gamma(q)/jstat.gamma(p+q)));}
-jstat.dbinom_raw=function(x,n,p,q,give_log){if(give_log==null)give_log=false;var lf,lc;if(p==0){if(x==0){return(give_log)?0.0:1.0;}else{return(give_log)?Number.NEGATIVE_INFINITY:0.0;}}
-if(q==0){if(x==n){return(give_log)?0.0:1.0;}else{return(give_log)?Number.NEGATIVE_INFINITY:0.0;}}
-if(x==0){if(n==0)return(give_log)?0.0:1.0;lc=(p<0.1)?-jstat.bd0(n,n*q)-n*p:n*Math.log(q);return(give_log)?lc:Math.exp(lc);}
-if(x==n){lc=(q<0.1)?-jstat.bd0(n,n*p)-n*q:n*Math.log(p);return(give_log)?lc:Math.exp(lc);}
-if(x<0||x>n)return(give_log)?Number.NEGATIVE_INFINITY:0.0;lc=jstat.stirlerr(n)-jstat.stirlerr(x)-jstat.stirlerr(n-x)-jstat.bd0(x,n*p)-jstat.bd0(n-x,n*q);lf=Math.log(jstat.TWO_PI)+Math.log(x)+jstat.log1p(-x/n);return(give_log)?lc-0.5*lf:Math.exp(lc-0.5*lf);}
-jstat.max=function(values){var max=Number.NEGATIVE_INFINITY;for(var i=0;i ' + _.template(message_tpl, {limit: MAX_SUM_KEY_LENGTH}) + ' ' + _.template(message_tpl, {limit: MAX_SUM_KEY_LENGTH}) + '