From 5a7ac441e51ac4e80e8703eb205e862639cf5fcc Mon Sep 17 00:00:00 2001 From: asadiqbal Date: Thu, 29 Jan 2015 18:18:38 +0500 Subject: [PATCH] Entrance Exam authoring and messaging updates Multi-commit history: - hide drag functionality for entrance exam section. - hide entrance exam subsection elements e.g. delete, drag, name etc. - show unit/verticals expanded in case of entrance exam - modify code in order to allow user to update entrance exam score from UI. - write down unit tests. - write down Jasmine tests. - add bok-choy test - updated bok-choy test - internationalize string - repositioned sequential block creatori - SOL-221 (entrance exam message) - SOL-199 LMS Part (show entrance exam content) and hide the course navigation bar. - redirect the view in case of entrance exam. - update code structure as per suggestions - write down unit tests - fix pep8 - instead of hiding the exam requirement message, now also showing the exam the completion message (success state). - write down unit test to show exam completion message. - Update code as per review suggestions - update doc string - addressed review suggestions - change sequential message text - css adjustments - added new css class for entrance exam score in studio - added Jasmine test for remaning coverage - sequential message should appear under the context of entrance exam subsection. - updated text in CMS and LMS as per suggestions. - added unit text to insure sequential message should not be present in other chapters rather then entrance exam. - skip setter if empty prerequisite course list - exclude logic from xblock_info.js that is specifically related to entrance exam. - added js tests and updated code as per suggestions - added tests - addressed several PR issues - Several small fixes (style, refactoring) - Fixed score update issue - added some more unit tests. - code suggested changes. - addressed PR feedback --- .../tests/test_course_settings.py | 16 +- cms/djangoapps/contentstore/views/course.py | 35 ++- .../contentstore/views/entrance_exam.py | 40 +++- cms/djangoapps/contentstore/views/item.py | 24 +- .../contentstore/views/tests/test_item.py | 64 +++++- cms/static/js/models/xblock_info.js | 66 ++++-- cms/static/js/spec/models/xblock_info_spec.js | 43 +++- .../spec/views/pages/course_outline_spec.js | 15 +- cms/static/js/views/xblock_outline.js | 11 + cms/static/sass/elements/_modules.scss | 6 +- cms/templates/js/course-outline.underscore | 21 +- common/djangoapps/util/milestones_helpers.py | 38 +++ .../studio/test_studio_settings_details.py | 29 +++ lms/djangoapps/courseware/courses.py | 50 +++- lms/djangoapps/courseware/module_render.py | 43 +--- .../courseware/tests/test_entrance_exam.py | 216 +++++++++++++++++- lms/djangoapps/courseware/views.py | 26 ++- .../sass/course/courseware/_courseware.scss | 8 + lms/templates/courseware/courseware.html | 20 ++ 19 files changed, 669 insertions(+), 102 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index c041f72f5b..8af9fbba65 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -150,7 +150,7 @@ class CourseDetailsTestCase(CourseTestCase): MilestoneRelationshipType.objects.create(name='fulfills') @patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True}) - def test_entrance_exam_created_and_deleted_successfully(self): + def test_entrance_exam_created_updated_and_deleted_successfully(self): self._seed_milestone_relationship_types() settings_details_url = get_url(self.course.id) data = { @@ -169,6 +169,20 @@ class CourseDetailsTestCase(CourseTestCase): self.assertTrue(course.entrance_exam_enabled) self.assertEquals(course.entrance_exam_minimum_score_pct, .60) + # Update the entrance exam + data['entrance_exam_enabled'] = "true" + data['entrance_exam_minimum_score_pct'] = "80" + response = self.client.post( + settings_details_url, + data=json.dumps(data), + content_type='application/json', + HTTP_ACCEPT='application/json' + ) + self.assertEquals(response.status_code, 200) + course = modulestore().get_course(self.course.id) + self.assertTrue(course.entrance_exam_enabled) + self.assertEquals(course.entrance_exam_minimum_score_pct, .80) + # Delete the entrance exam data['entrance_exam_enabled'] = "false" response = self.client.post( diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 6d01c3424c..7db80dc44f 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -61,7 +61,11 @@ from .component import ( ADVANCED_COMPONENT_TYPES, ) from contentstore.tasks import rerun_course -from contentstore.views.entrance_exam import create_entrance_exam, delete_entrance_exam +from contentstore.views.entrance_exam import ( + create_entrance_exam, + update_entrance_exam, + delete_entrance_exam +) from .library import LIBRARIES_ENABLED from .item import create_xblock_info @@ -896,9 +900,10 @@ def settings_handler(request, course_key_string): # if pre-requisite course feature is enabled set pre-requisite course if prerequisite_course_enabled: prerequisite_course_keys = request.json.get('pre_requisite_courses', []) - if not all(is_valid_course_key(course_key) for course_key in prerequisite_course_keys): - return JsonResponseBadRequest({"error": _("Invalid prerequisite course key")}) - set_prerequisite_courses(course_key, prerequisite_course_keys) + if prerequisite_course_keys: + if not all(is_valid_course_key(course_key) for course_key in prerequisite_course_keys): + return JsonResponseBadRequest({"error": _("Invalid prerequisite course key")}) + set_prerequisite_courses(course_key, prerequisite_course_keys) # If the entrance exams feature has been enabled, we'll need to check for some # feature-specific settings and handle them accordingly @@ -908,16 +913,24 @@ def settings_handler(request, course_key_string): course_entrance_exam_present = course_module.entrance_exam_enabled entrance_exam_enabled = request.json.get('entrance_exam_enabled', '') == 'true' ee_min_score_pct = request.json.get('entrance_exam_minimum_score_pct', None) - - # If the entrance exam box on the settings screen has been checked, - # and the course does not already have an entrance exam attached... - if entrance_exam_enabled and not course_entrance_exam_present: + # If the entrance exam box on the settings screen has been checked... + if entrance_exam_enabled: # Load the default minimum score threshold from settings, then try to override it entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT) - if ee_min_score_pct and ee_min_score_pct != '': + if ee_min_score_pct: entrance_exam_minimum_score_pct = float(ee_min_score_pct) - # Create the entrance exam - create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct) + if entrance_exam_minimum_score_pct.is_integer(): + entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100 + entrance_exam_minimum_score_pct = unicode(entrance_exam_minimum_score_pct) + # If there's already an entrance exam defined, we'll update the existing one + if course_entrance_exam_present: + exam_data = { + 'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct + } + update_entrance_exam(request, course_key, exam_data) + # If there's no entrance exam defined, we'll create a new one + else: + create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct) # If the entrance exam box on the settings screen has been unchecked, # and the course has an entrance exam attached... diff --git a/cms/djangoapps/contentstore/views/entrance_exam.py b/cms/djangoapps/contentstore/views/entrance_exam.py index 7c8658a4fe..08da658e76 100644 --- a/cms/djangoapps/contentstore/views/entrance_exam.py +++ b/cms/djangoapps/contentstore/views/entrance_exam.py @@ -21,12 +21,25 @@ from util.milestones_helpers import generate_milestone_namespace, NAMESPACE_CHOI from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from django.conf import settings +from django.utils.translation import ugettext as _ __all__ = ['entrance_exam', ] log = logging.getLogger(__name__) +# pylint: disable=invalid-name +def _get_default_entrance_exam_minimum_pct(): + """ + Helper method to return the default value from configuration + Converts integer values to decimals, since that what we use internally + """ + entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT) + if entrance_exam_minimum_score_pct.is_integer(): + entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100 + return entrance_exam_minimum_score_pct + + @login_required @ensure_csrf_cookie def entrance_exam(request, course_key_string): @@ -60,7 +73,7 @@ def entrance_exam(request, course_key_string): ee_min_score = request.POST.get('entrance_exam_minimum_score_pct', None) # if request contains empty value or none then save the default one. - entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT) + entrance_exam_minimum_score_pct = _get_default_entrance_exam_minimum_pct() if ee_min_score != '' and ee_min_score is not None: entrance_exam_minimum_score_pct = float(ee_min_score) return create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct) @@ -94,7 +107,7 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N """ # Provide a default value for the minimum score percent if nothing specified if entrance_exam_minimum_score_pct is None: - entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT) + entrance_exam_minimum_score_pct = _get_default_entrance_exam_minimum_pct() # Confirm the course exists course = modulestore().get_course(course_key) @@ -123,11 +136,19 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N course = modulestore().get_course(course_key) metadata = { 'entrance_exam_enabled': True, - 'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct / 100, + 'entrance_exam_minimum_score_pct': unicode(entrance_exam_minimum_score_pct), 'entrance_exam_id': unicode(created_block.location), } CourseMetadata.update_from_dict(metadata, course, request.user) + # Create the entrance exam section item. + create_xblock( + parent_locator=unicode(created_block.location), + user=request.user, + category='sequential', + display_name=_('Entrance Exam - Subsection') + ) + # Add an entrance exam milestone if one does not already exist milestone_namespace = generate_milestone_namespace( NAMESPACE_CHOICES['ENTRANCE_EXAM'], @@ -181,6 +202,19 @@ def _get_entrance_exam(request, course_key): # pylint: disable=W0613 return HttpResponse(status=404) +def update_entrance_exam(request, course_key, exam_data): + """ + Operation to update course fields pertaining to entrance exams + The update operation is not currently exposed directly via the API + Because the operation is not exposed directly, we do not return a 200 response + But we do return a 400 in the error case because the workflow is executed in a request context + """ + course = modulestore().get_course(course_key) + if course: + metadata = exam_data + CourseMetadata.update_from_dict(metadata, course, request.user) + + def delete_entrance_exam(request, course_key): """ api method to delete an entrance exam diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 86cb005f17..507e35962a 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -781,10 +781,18 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F visibility_state = None published = modulestore().has_published_version(xblock) if not is_library_block else None - #instead of adding a new feature directly into xblock-info, we should add them into override_type. - override_type = {} - if getattr(xblock, "is_entrance_exam", None): - override_type['is_entrance_exam'] = xblock.is_entrance_exam + # defining the default value 'True' for delete, drag and add new child actions in xblock_actions for each xblock. + xblock_actions = {'deletable': True, 'draggable': True, 'childAddable': True} + explanatory_message = None + # is_entrance_exam is inherited metadata. + if xblock.category == 'chapter' and getattr(xblock, "is_entrance_exam", None): + # Entrance exam section should not be deletable, draggable and not have 'New Subsection' button. + xblock_actions['deletable'] = xblock_actions['childAddable'] = xblock_actions['draggable'] = False + if parent_xblock is None: + parent_xblock = get_parent_xblock(xblock) + + explanatory_message = _('Students must score {score}% or higher to access course materials.').format( + score=int(parent_xblock.entrance_exam_minimum_score_pct * 100)) xblock_info = { "id": unicode(xblock.location), @@ -805,8 +813,14 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F "format": xblock.format, "course_graders": json.dumps([grader.get('type') for grader in graders]), "has_changes": has_changes, - "override_type": override_type, + "actions": xblock_actions, + "explanatory_message": explanatory_message } + + # Entrance exam subsection should be hidden. in_entrance_exam is inherited metadata, all children will have it. + if xblock.category == 'sequential' and getattr(xblock, "in_entrance_exam", False): + xblock_info["is_header_visible"] = False + if data is not None: xblock_info["data"] = data if metadata is not None: diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index bb55cb39aa..908fd784d2 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -1405,7 +1405,7 @@ class TestXBlockInfo(ItemTest): json_response = json.loads(resp.content) self.validate_course_xblock_info(json_response, course_outline=True) - def test_chapter_entrance_exam_xblock_info(self): + def test_entrance_exam_chapter_xblock_info(self): chapter = ItemFactory.create( parent_location=self.course.location, category='chapter', display_name="Entrance Exam", user_id=self.user.id, is_entrance_exam=True @@ -1416,8 +1416,68 @@ class TestXBlockInfo(ItemTest): include_child_info=True, include_children_predicate=ALWAYS, ) - self.assertEqual(xblock_info['override_type'], {'is_entrance_exam': True}) + # entrance exam chapter should not be deletable, draggable and childAddable. + actions = xblock_info['actions'] + self.assertEqual(actions['deletable'], False) + self.assertEqual(actions['draggable'], False) + self.assertEqual(actions['childAddable'], False) self.assertEqual(xblock_info['display_name'], 'Entrance Exam') + self.assertIsNone(xblock_info.get('is_header_visible', None)) + + def test_none_entrance_exam_chapter_xblock_info(self): + chapter = ItemFactory.create( + parent_location=self.course.location, category='chapter', display_name="Test Chapter", + user_id=self.user.id + ) + chapter = modulestore().get_item(chapter.location) + xblock_info = create_xblock_info( + chapter, + include_child_info=True, + include_children_predicate=ALWAYS, + ) + + # chapter should be deletable, draggable and childAddable if not an entrance exam. + actions = xblock_info['actions'] + self.assertEqual(actions['deletable'], True) + self.assertEqual(actions['draggable'], True) + self.assertEqual(actions['childAddable'], True) + # chapter xblock info should not contains the key of 'is_header_visible'. + self.assertIsNone(xblock_info.get('is_header_visible', None)) + + def test_entrance_exam_sequential_xblock_info(self): + chapter = ItemFactory.create( + parent_location=self.course.location, category='chapter', display_name="Entrance Exam", + user_id=self.user.id, is_entrance_exam=True, in_entrance_exam=True + ) + + subsection = ItemFactory.create( + parent_location=chapter.location, category='sequential', display_name="Subsection - Entrance Exam", + user_id=self.user.id, in_entrance_exam=True + ) + subsection = modulestore().get_item(subsection.location) + xblock_info = create_xblock_info( + subsection, + include_child_info=True, + include_children_predicate=ALWAYS + ) + # in case of entrance exam subsection, header should be hidden. + self.assertEqual(xblock_info['is_header_visible'], False) + self.assertEqual(xblock_info['display_name'], 'Subsection - Entrance Exam') + + def test_none_entrance_exam_sequential_xblock_info(self): + subsection = ItemFactory.create( + parent_location=self.chapter.location, category='sequential', display_name="Subsection - Exam", + user_id=self.user.id + ) + subsection = modulestore().get_item(subsection.location) + xblock_info = create_xblock_info( + subsection, + include_child_info=True, + include_children_predicate=ALWAYS, + parent_xblock=self.chapter + ) + # sequential xblock info should not contains the key of 'is_header_visible'. + self.assertIsNone(xblock_info.get('is_header_visible', None)) def test_chapter_xblock_info(self): chapter = modulestore().get_item(self.chapter.location) diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js index 34d82ebc1f..639ae1e2ed 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -133,9 +133,19 @@ function(Backbone, _, str, ModuleUtils) { */ 'has_content_group_components': null, /** - * Indicate the type of xblock + * actions defines the state of delete, drag and child add functionality for a xblock. + * currently, each xblock has default value of 'True' for keys: deletable, draggable and childAddable. */ - 'override_type': null + 'actions': null, + /** + * Header visible to UI. + */ + 'is_header_visible': null, + /** + * Optional explanatory message about the xblock. + */ + 'explanatory_message': null + }, initialize: function () { @@ -172,13 +182,33 @@ function(Backbone, _, str, ModuleUtils) { return !this.get('published') || this.get('has_changes'); }, - canBeDeleted: function(){ - //get the type of xblock - if(this.get('override_type') != null) { - var type = this.get('override_type'); + isDeletable: function() { + return this.isActionRequired('deletable'); + }, - //hide/remove the delete trash icon if type is entrance exam. - if (_.has(type, 'is_entrance_exam') && type['is_entrance_exam']) { + isDraggable: function() { + return this.isActionRequired('draggable'); + }, + + isChildAddable: function(){ + return this.isActionRequired('childAddable'); + }, + + isHeaderVisible: function(){ + if(this.get('is_header_visible') !== null) { + return this.get('is_header_visible'); + } + return true; + }, + + /** + * Return true if action is required e.g. delete, drag, add new child etc or if given key is not present. + * @return {boolean} + */ + isActionRequired: function(actionName) { + var actions = this.get('actions'); + if(actions !== null) { + if (_.has(actions, actionName) && !actions[actionName]) { return false; } } @@ -188,8 +218,8 @@ function(Backbone, _, str, ModuleUtils) { /** * Return a list of convenience methods to check affiliation to the category. * @return {Array} - */ - getCategoryHelpers: function () { + */ + getCategoryHelpers: function () { var categories = ['course', 'chapter', 'sequential', 'vertical'], helpers = {}; @@ -200,15 +230,15 @@ function(Backbone, _, str, ModuleUtils) { }, this); return helpers; - }, + }, - /** - * Check if we can edit current XBlock or not on Course Outline page. - * @return {Boolean} - */ - isEditableOnCourseOutline: function() { - return this.isSequential() || this.isChapter() || this.isVertical(); - } + /** + * Check if we can edit current XBlock or not on Course Outline page. + * @return {Boolean} + */ + isEditableOnCourseOutline: function() { + return this.isSequential() || this.isChapter() || this.isVertical(); + } }); return XBlockInfo; }); diff --git a/cms/static/js/spec/models/xblock_info_spec.js b/cms/static/js/spec/models/xblock_info_spec.js index c55c8baa7e..99a83f3f06 100644 --- a/cms/static/js/spec/models/xblock_info_spec.js +++ b/cms/static/js/spec/models/xblock_info_spec.js @@ -7,16 +7,47 @@ define(['backbone', 'js/models/xblock_info'], expect(new XBlockInfo({'category': 'sequential'}).isEditableOnCourseOutline()).toBe(true); expect(new XBlockInfo({'category': 'vertical'}).isEditableOnCourseOutline()).toBe(true); }); + }); - it('cannot delete an entrance exam', function(){ - expect(new XBlockInfo({'category': 'chapter', 'override_type': {'is_entrance_exam':true}}) - .canBeDeleted()).toBe(false); + describe('XblockInfo actions state and header visibility ', function() { + + it('works correct to hide icons e.g. trash icon, drag when actions are not required', function(){ + expect(new XBlockInfo({'category': 'chapter', 'actions': {'deletable':false}}) + .isDeletable()).toBe(false); + expect(new XBlockInfo({'category': 'chapter', 'actions': {'draggable':false}}) + .isDraggable()).toBe(false); + expect(new XBlockInfo({'category': 'chapter', 'actions': {'childAddable':false}}) + .isChildAddable()).toBe(false); }); - it('can delete module rather then entrance exam', function(){ - expect(new XBlockInfo({'category': 'chapter', 'override_type': {'is_entrance_exam':false}}).canBeDeleted()).toBe(true); - expect(new XBlockInfo({'category': 'chapter', 'override_type': {}}).canBeDeleted()).toBe(true); + it('works correct to show icons e.g. trash icon, drag when actions are required', function(){ + expect(new XBlockInfo({'category': 'chapter', 'actions': {'deletable':true}}) + .isDeletable()).toBe(true); + expect(new XBlockInfo({'category': 'chapter', 'actions': {'draggable':true}}) + .isDraggable()).toBe(true); + expect(new XBlockInfo({'category': 'chapter', 'actions': {'childAddable':true}}) + .isChildAddable()).toBe(true); }); + + it('displays icons e.g. trash icon, drag when actions are undefined', function(){ + expect(new XBlockInfo({'category': 'chapter', 'actions': {}}) + .isDeletable()).toBe(true); + expect(new XBlockInfo({'category': 'chapter', 'actions': {}}) + .isDraggable()).toBe(true); + expect(new XBlockInfo({'category': 'chapter', 'actions': {}}) + .isChildAddable()).toBe(true); + }); + + it('works correct to hide header content', function(){ + expect(new XBlockInfo({'category': 'sequential', 'is_header_visible': false}) + .isHeaderVisible()).toBe(false); + }); + + it('works correct to show header content when is_header_visible is not defined', function() { + expect(new XBlockInfo({'category': 'sequential', 'actions': {'deletable': true}}) + .isHeaderVisible()).toBe(true); + }); + }); } ); 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 4e0efc2d4d..98d55a0dd7 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -8,7 +8,7 @@ define(["jquery", "sinon", "js/common_helpers/ajax_helpers", "js/views/utils/vie getItemsOfType, getItemHeaders, verifyItemsExpanded, expandItemsAndVerifyState, collapseItemsAndVerifyState, createMockCourseJSON, createMockSectionJSON, createMockSubsectionJSON, verifyTypePublishable, mockCourseJSON, mockEmptyCourseJSON, mockSingleSectionCourseJSON, - createMockVerticalJSON, createMockIndexJSON, + createMockVerticalJSON, createMockIndexJSON, mockCourseEntranceExamJSON mockOutlinePage = readFixtures('mock/mock-course-outline-page.underscore'), mockRerunNotification = readFixtures('mock/mock-course-rerun-notification.underscore'); @@ -228,6 +228,14 @@ define(["jquery", "sinon", "js/common_helpers/ajax_helpers", "js/views/utils/vie mockSingleSectionCourseJSON = createMockCourseJSON({}, [ createMockSectionJSON() ]); + mockCourseEntranceExamJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({'is_header_visible': false}, [ + createMockVerticalJSON() + ]) + ]) + ]); + }); afterEach(function () { @@ -259,6 +267,11 @@ define(["jquery", "sinon", "js/common_helpers/ajax_helpers", "js/views/utils/vie verifyItemsExpanded('subsection', false); expect(getItemsOfType('unit')).not.toExist(); }); + + it('unit initially exist for entrance exam', function() { + createCourseOutlinePage(this, mockCourseEntranceExamJSON); + expect(getItemsOfType('unit')).toExist(); + }); }); describe("Rerun notification", function () { diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js index 7daaf3b34c..ade33c2bd0 100644 --- a/cms/static/js/views/xblock_outline.js +++ b/cms/static/js/views/xblock_outline.js @@ -44,6 +44,17 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ this.renderTemplate(); this.addButtonActions(this.$el); this.addNameEditor(); + + // For cases in which we need to suppress the header controls during rendering, we'll + // need to add the current model's id/locator to the set of expanded locators + if (this.model.get('is_header_visible') !== null && !this.model.get('is_header_visible')) { + var locator = this.model.get('id'); + if(!_.isUndefined(this.expandedLocators) && !this.expandedLocators.contains(locator)) { + this.expandedLocators.add(locator); + this.refresh(); + } + } + if (this.shouldRenderChildren() && this.shouldExpandChildren()) { this.renderChildren(); } diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index ef1eb3cdd3..889fbe8879 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -438,7 +438,8 @@ $outline-indent-width: $baseline; } // status - release - .status-release { + .status-release, + .explanatory-message { @include transition(opacity $tmg-f2 ease-in-out 0s); opacity: 0.65; } @@ -463,7 +464,8 @@ $outline-indent-width: $baseline; &:hover, &:active { // status - release - > .section-status .status-release { + > .section-status .status-release, + .section-status .explanatory-message{ opacity: 1.0; } } diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index 711b21e6ba..b9507426a2 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -40,7 +40,7 @@ if (xblockInfo.get('graded')) { data-parent="<%= parentInfo.get('id') %>" data-locator="<%= xblockInfo.get('id') %>"> - + <% if (xblockInfo.isHeaderVisible()) { %>
<% if (includesChildren) { %>

<% } %> - <% if (xblockInfo.canBeDeleted()) { %> + <% if (xblockInfo.isDeletable()) { %>
  • @@ -86,18 +86,27 @@ if (xblockInfo.get('graded')) {
  • <% } %> + <% if (xblockInfo.isDraggable()) { %>
  • <%= gettext('Drag to reorder') %>
  • + <% } %>

    <% if (!xblockInfo.isVertical()) { %> -
    + <% if (xblockInfo.get('explanatory_message') !=null) { %> +
    + + <%= xblockInfo.get('explanatory_message') %> + +
    + <% } else { %> +

    <%= gettext('Release Status:') %> @@ -116,7 +125,8 @@ if (xblockInfo.get('graded')) { <% } %>

    -
    +
    + <% } %> <% if (xblockInfo.get('due_date') || xblockInfo.get('graded')) { %>

    @@ -138,6 +148,7 @@ if (xblockInfo.get('graded')) {

    <% } %>
    + <% } %> <% } %> <% if (!parentInfo && xblockInfo.get('child_info') && xblockInfo.get('child_info').children.length === 0) { %> @@ -159,6 +170,7 @@ if (xblockInfo.get('graded')) { <% if (childType) { %> + <% if (xblockInfo.isChildAddable()) { %>
    <%= addChildLabel %>
    + <% } %> <% } %> <% } %> diff --git a/common/djangoapps/util/milestones_helpers.py b/common/djangoapps/util/milestones_helpers.py index ef5f635783..18e808a0c3 100644 --- a/common/djangoapps/util/milestones_helpers.py +++ b/common/djangoapps/util/milestones_helpers.py @@ -21,6 +21,8 @@ from milestones.api import ( get_user_milestones, ) from milestones.models import MilestoneRelationshipType +from milestones.exceptions import InvalidMilestoneRelationshipTypeException +from opaque_keys.edx.keys import UsageKey NAMESPACE_CHOICES = { 'ENTRANCE_EXAM': 'entrance_exams' @@ -150,6 +152,42 @@ def fulfill_course_milestone(course_key, user): add_user_milestone({'id': user.id}, milestone) +def get_required_content(course, user): + """ + Queries milestones subsystem to see if the specified course is gated on one or more milestones, + and if those milestones can be fulfilled via completion of a particular course content module + """ + required_content = [] + if settings.FEATURES.get('MILESTONES_APP', False): + # Get all of the outstanding milestones for this course, for this user + try: + milestone_paths = get_course_milestones_fulfillment_paths( + unicode(course.id), + serialize_user(user) + ) + except InvalidMilestoneRelationshipTypeException: + return required_content + + # For each outstanding milestone, see if this content is one of its fulfillment paths + for path_key in milestone_paths: + milestone_path = milestone_paths[path_key] + if milestone_path.get('content') and len(milestone_path['content']): + for content in milestone_path['content']: + required_content.append(content) + + #local imports to avoid circular reference + from student.models import EntranceExamConfiguration + can_skip_entrance_exam = EntranceExamConfiguration.user_can_skip_entrance_exam(user, course.id) + # check if required_content has any entrance exam and user is allowed to skip it + # then remove it from required content + if required_content and getattr(course, 'entrance_exam_enabled', False) and can_skip_entrance_exam: + descriptors = [modulestore().get_item(UsageKey.from_string(content)) for content in required_content] + entrance_exam_contents = [unicode(descriptor.location) + for descriptor in descriptors if descriptor.is_entrance_exam] + required_content = list(set(required_content) - set(entrance_exam_contents)) + return required_content + + def calculate_entrance_exam_score(user, course_descriptor, exam_modules): """ Calculates the score (percent) of the entrance exam using the provided modules diff --git a/common/test/acceptance/tests/studio/test_studio_settings_details.py b/common/test/acceptance/tests/studio/test_studio_settings_details.py index 1fc3f03561..b0f53858b7 100644 --- a/common/test/acceptance/tests/studio/test_studio_settings_details.py +++ b/common/test/acceptance/tests/studio/test_studio_settings_details.py @@ -162,3 +162,32 @@ class SettingsMilestonesTest(StudioCourseTest): css_selector='span.section-title', text='Entrance Exam' )) + + def test_entrance_exam_has_unit_button(self): + """ + Test that entrance exam should be created after checking the 'enable entrance exam' checkbox. + And user has option to add units only instead of any Subsection. + """ + self.settings_detail.require_entrance_exam(required=True) + self.settings_detail.save_changes() + + # getting the course outline page. + course_outline_page = CourseOutlinePage( + self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run'] + ) + course_outline_page.visit() + course_outline_page.wait_for_ajax() + + # button with text 'New Unit' should be present. + self.assertTrue(element_has_text( + page=course_outline_page, + css_selector='.add-item a.button-new', + text='New Unit' + )) + + # button with text 'New Subsection' should not be present. + self.assertFalse(element_has_text( + page=course_outline_page, + css_selector='.add-item a.button-new', + text='New Subsection' + )) diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 9a4f778c0a..bc6c64ca37 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -9,7 +9,7 @@ from django.conf import settings from edxmako.shortcuts import render_to_string from xmodule.modulestore import ModuleStoreEnum -from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.keys import CourseKey from xmodule.modulestore.django import modulestore from xmodule.contentstore.content import StaticContent from xmodule.modulestore.exceptions import ItemNotFoundError @@ -23,6 +23,10 @@ from courseware.model_data import FieldDataCache from courseware.module_render import get_module from student.models import CourseEnrollment import branding +from util.milestones_helpers import get_required_content, calculate_entrance_exam_score +from util.module_utils import yield_dynamic_descriptor_descendents +from opaque_keys.edx.keys import UsageKey +from .module_render import get_module_for_descriptor log = logging.getLogger(__name__) @@ -441,3 +445,47 @@ def get_problems_in_section(section): problem_descriptors[unicode(component.location)] = component return problem_descriptors + + +def get_entrance_exam_score(request, course): + """ + Get entrance exam score + """ + exam_key = UsageKey.from_string(course.entrance_exam_id) + exam_descriptor = modulestore().get_item(exam_key) + + def inner_get_module(descriptor): + """ + Delegate to get_module_for_descriptor. + """ + field_data_cache = FieldDataCache([descriptor], course.id, request.user) + return get_module_for_descriptor(request.user, request, descriptor, field_data_cache, course.id) + + exam_module_generators = yield_dynamic_descriptor_descendents( + exam_descriptor, + inner_get_module + ) + exam_modules = [module for module in exam_module_generators] + return calculate_entrance_exam_score(request.user, course, exam_modules) + + +def get_entrance_exam_content_info(request, course): + """ + Get the entrance exam content information e.g. chapter, exam passing state. + return exam chapter and its passing state. + """ + required_content = get_required_content(course, request.user) + exam_chapter = None + is_exam_passed = True + # Iterating the list of required content of this course. + for content in required_content: + # database lookup to required content pointer + usage_key = course.id.make_usage_key_from_deprecated_string(content) + module_item = modulestore().get_item(usage_key) + if not module_item.hide_from_toc and module_item.is_entrance_exam: + # Here we are looking for entrance exam module/chapter in required_content. + # If module_item is an entrance exam chapter then set and return its info e.g. exam chapter, exam state. + exam_chapter = module_item + is_exam_passed = False + break + return exam_chapter, is_exam_passed diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 3fe94592a2..f6dd10ad7a 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -36,7 +36,7 @@ from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig from edxmako.shortcuts import render_to_string from eventtracking import tracker from psychometrics.psychoanalyze import make_psychometrics_data_update_handler -from student.models import anonymous_id_for_user, user_by_anonymous_id, EntranceExamConfiguration +from student.models import anonymous_id_for_user, user_by_anonymous_id from xblock.core import XBlock from xblock.fields import Scope from xblock.runtime import KvsFieldData, KeyValueStore @@ -65,8 +65,7 @@ from util.json_request import JsonResponse from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip if settings.FEATURES.get('MILESTONES_APP', False): from milestones import api as milestones_api - from milestones.exceptions import InvalidMilestoneRelationshipTypeException - from util.milestones_helpers import serialize_user, calculate_entrance_exam_score + from util.milestones_helpers import calculate_entrance_exam_score, get_required_content from util.module_utils import yield_dynamic_descriptor_descendents log = logging.getLogger(__name__) @@ -107,40 +106,6 @@ def make_track_function(request): return function -def _get_required_content(course, user): - """ - Queries milestones subsystem to see if the specified course is gated on one or more milestones, - and if those milestones can be fulfilled via completion of a particular course content module - """ - required_content = [] - if settings.FEATURES.get('MILESTONES_APP', False): - # Get all of the outstanding milestones for this course, for this user - try: - milestone_paths = milestones_api.get_course_milestones_fulfillment_paths( - unicode(course.id), - serialize_user(user) - ) - except InvalidMilestoneRelationshipTypeException: - return required_content - - # For each outstanding milestone, see if this content is one of its fulfillment paths - for path_key in milestone_paths: - milestone_path = milestone_paths[path_key] - if milestone_path.get('content') and len(milestone_path['content']): - for content in milestone_path['content']: - required_content.append(content) - - can_skip_entrance_exam = EntranceExamConfiguration.user_can_skip_entrance_exam(user, course.id) - # check if required_content has any entrance exam and user is allowed to skip it - # then remove it from required content - if required_content and getattr(course, 'entrance_exam_enabled', False) and can_skip_entrance_exam: - descriptors = [modulestore().get_item(UsageKey.from_string(content)) for content in required_content] - entrance_exam_contents = [unicode(descriptor.location) - for descriptor in descriptors if descriptor.is_entrance_exam] - required_content = list(set(required_content) - set(entrance_exam_contents)) - return required_content - - def toc_for_course(request, course, active_chapter, active_section, field_data_cache): ''' Create a table of contents from the module store @@ -170,8 +135,8 @@ def toc_for_course(request, course, active_chapter, active_section, field_data_c if course_module is None: return None - # Check to see if the course is gated on required content (such as an Entrance Exam) - required_content = _get_required_content(course, request.user) + # Check to see if the course is gated on milestone-required content (such as an Entrance Exam) + required_content = get_required_content(course, request.user) chapters = list() for chapter in course_module.get_display_items(): diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py index ff4c7073ca..1bdb0ca278 100644 --- a/lms/djangoapps/courseware/tests/test_entrance_exam.py +++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py @@ -8,12 +8,16 @@ from django.core.urlresolvers import reverse from courseware.model_data import FieldDataCache from courseware.module_render import get_module, toc_for_course from courseware.tests.factories import UserFactory, InstructorFactory +from courseware.courses import get_entrance_exam_content_info, get_entrance_exam_score from milestones import api as milestones_api from milestones.models import MilestoneRelationshipType from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MOCK_MODULESTORE from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from util.milestones_helpers import generate_milestone_namespace, NAMESPACE_CHOICES +from student.models import CourseEnrollment +from mock import patch +import mock class EntranceExamTestCases(ModuleStoreTestCase): @@ -31,12 +35,12 @@ class EntranceExamTestCases(ModuleStoreTestCase): 'entrance_exam_enabled': True, } ) - chapter = ItemFactory.create( + self.chapter = ItemFactory.create( parent=self.course, display_name='Overview' ) ItemFactory.create( - parent=chapter, + parent=self.chapter, display_name='Welcome' ) ItemFactory.create( @@ -44,11 +48,27 @@ class EntranceExamTestCases(ModuleStoreTestCase): category='chapter', display_name="Week 1" ) - ItemFactory.create( - parent=chapter, + self.chapter_subsection = ItemFactory.create( + parent=self.chapter, category='sequential', display_name="Lesson 1" ) + chapter_vertical = ItemFactory.create( + parent=self.chapter_subsection, + category='vertical', + display_name='Lesson 1 Vertical - Unit 1' + ) + ItemFactory.create( + parent=chapter_vertical, + category="problem", + display_name="Problem - Unit 1 Problem 1" + ) + ItemFactory.create( + parent=chapter_vertical, + category="problem", + display_name="Problem - Unit 1 Problem 2" + ) + ItemFactory.create( category="instructor", parent=self.course, @@ -59,7 +79,8 @@ class EntranceExamTestCases(ModuleStoreTestCase): parent=self.course, category="chapter", display_name="Entrance Exam Section - Chapter 1", - is_entrance_exam=True + is_entrance_exam=True, + in_entrance_exam=True ) self.exam_1 = ItemFactory.create( parent=self.entrance_exam, @@ -125,13 +146,14 @@ class EntranceExamTestCases(ModuleStoreTestCase): user, self.entrance_exam ) - self.entrance_exam.is_entrance_exam = True - self.entrance_exam.in_entrance_exam = True self.course.entrance_exam_enabled = True self.course.entrance_exam_minimum_score_pct = 0.50 self.course.entrance_exam_id = unicode(self.entrance_exam.scope_ids.usage_id) modulestore().update_item(self.course, user.id) # pylint: disable=no-member + self.client.login(username=self.request.user.username, password="test") + CourseEnrollment.enroll(self.request.user, self.course.id) + self.expected_locked_toc = ( [ { @@ -206,6 +228,186 @@ class EntranceExamTestCases(ModuleStoreTestCase): ] ) + @mock.patch('xmodule.x_module.XModuleMixin.has_dynamic_children', mock.Mock(return_value='True')) + def test_view_redirect_if_entrance_exam_required(self): + """ + Unit Test: if entrance exam is required. Should return a redirect. + """ + url = reverse('courseware', kwargs={'course_id': unicode(self.course.id)}) + expected_url = reverse('courseware_section', + kwargs={ + 'course_id': unicode(self.course.id), + 'chapter': self.entrance_exam.location.name, + 'section': self.exam_1.location.name + }) + resp = self.client.get(url) + self.assertRedirects(resp, expected_url, status_code=302, target_status_code=200) + + @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': False}) + def test_entrance_exam_content_absence(self): + """ + Unit Test: If entrance exam is not enabled then page should be redirected with chapter contents. + """ + url = reverse('courseware', kwargs={'course_id': unicode(self.course.id)}) + expected_url = reverse('courseware_section', + kwargs={ + 'course_id': unicode(self.course.id), + 'chapter': self.chapter.location.name, + 'section': self.chapter_subsection.location.name + }) + resp = self.client.get(url) + self.assertRedirects(resp, expected_url, status_code=302, target_status_code=200) + resp = self.client.get(expected_url) + self.assertNotIn('Exam Problem - Problem 1', resp.content) + self.assertNotIn('Exam Problem - Problem 2', resp.content) + + @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True}) + def test_entrance_exam_content_presence(self): + """ + Unit Test: If entrance exam is enabled then its content e.g. problems should be loaded and redirection will + occur with entrance exam contents. + """ + url = reverse('courseware', kwargs={'course_id': unicode(self.course.id)}) + expected_url = reverse('courseware_section', + kwargs={ + 'course_id': unicode(self.course.id), + 'chapter': self.entrance_exam.location.name, + 'section': self.exam_1.location.name + }) + resp = self.client.get(url) + self.assertRedirects(resp, expected_url, status_code=302, target_status_code=200) + resp = self.client.get(expected_url) + self.assertIn('Exam Problem - Problem 1', resp.content) + self.assertIn('Exam Problem - Problem 2', resp.content) + + def test_entrance_exam_content_info(self): + """ + test entrance exam content info method + """ + exam_chapter, is_exam_passed = get_entrance_exam_content_info(self.request, self.course) + self.assertEqual(exam_chapter.url_name, self.entrance_exam.url_name) + self.assertEqual(is_exam_passed, False) + + # Pass the entrance exam + # pylint: disable=maybe-no-member,no-member + grade_dict = {'value': 1, 'max_value': 1, 'user_id': self.request.user.id} + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + self.course.id, + self.request.user, + self.course, + depth=2 + ) + # pylint: disable=protected-access + module = get_module( + self.request.user, + self.request, + self.problem_1.scope_ids.usage_id, + field_data_cache, + )._xmodule + module.system.publish(self.problem_1, 'grade', grade_dict) + + exam_chapter, is_exam_passed = get_entrance_exam_content_info(self.request, self.course) + self.assertEqual(exam_chapter, None) + self.assertEqual(is_exam_passed, True) + + def test_entrance_exam_score(self): + """ + test entrance exam score. we will hit the method get_entrance_exam_score to verify exam score. + """ + exam_score = get_entrance_exam_score(self.request, self.course) + self.assertEqual(exam_score, 0) + + # Pass the entrance exam + # pylint: disable=maybe-no-member,no-member + grade_dict = {'value': 1, 'max_value': 2, 'user_id': self.request.user.id} + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + self.course.id, + self.request.user, + self.course, + depth=2 + ) + # pylint: disable=protected-access + module = get_module( + self.request.user, + self.request, + self.problem_1.scope_ids.usage_id, + field_data_cache, + )._xmodule + module.system.publish(self.problem_1, 'grade', grade_dict) + + exam_score = get_entrance_exam_score(self.request, self.course) + # 50 percent exam score should be achieved. + self.assertEqual(exam_score * 100, 50) + + def test_entrance_exam_requirement_message(self): + """ + Unit Test: entrance exam requirement message should be present in response + """ + url = reverse( + 'courseware_section', + kwargs={ + 'course_id': unicode(self.course.id), + 'chapter': self.entrance_exam.location.name, + 'section': self.exam_1.location.name + } + ) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + self.assertIn('To access course materials, you must score', resp.content) + + def test_entrance_exam_requirement_message_hidden(self): + """ + Unit Test: entrance exam message should not be present outside the context of entrance exam subsection. + """ + url = reverse( + 'courseware_section', + kwargs={ + 'course_id': unicode(self.course.id), + 'chapter': self.chapter.location.name, + 'section': self.chapter_subsection.location.name + } + ) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + self.assertNotIn('To access course materials, you must score', resp.content) + self.assertNotIn('You have passed the entrance exam.', resp.content) + + def test_entrance_exam_passed_message_and_course_content(self): + """ + Unit Test: exam passing message and rest of the course section should be present + when user achieves the entrance exam milestone/pass the exam. + """ + url = reverse( + 'courseware_section', + kwargs={ + 'course_id': unicode(self.course.id), + 'chapter': self.entrance_exam.location.name, + 'section': self.exam_1.location.name + } + ) + + # pylint: disable=maybe-no-member,no-member + grade_dict = {'value': 1, 'max_value': 1, 'user_id': self.request.user.id} + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + self.course.id, + self.request.user, + self.course, + depth=2 + ) + # pylint: disable=protected-access + module = get_module( + self.request.user, + self.request, + self.problem_1.scope_ids.usage_id, + field_data_cache, + )._xmodule + module.system.publish(self.problem_1, 'grade', grade_dict) + + resp = self.client.get(url) + self.assertNotIn('To access course materials, you must score', resp.content) + self.assertIn('You have passed the entrance exam.', resp.content) + self.assertIn('Lesson 1', resp.content) + def test_entrance_exam_gating(self): """ Unit Test: test_entrance_exam_gating diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index e960177285..911283959f 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -33,8 +33,9 @@ from markupsafe import escape from courseware import grades from courseware.access import has_access, _adjust_start_date_for_beta_testers -from courseware.courses import get_courses, get_course, get_studio_url, get_course_with_access, sort_by_announcement -from courseware.courses import sort_by_start_date +from courseware.courses import get_courses, get_course, get_studio_url, get_course_with_access, sort_by_announcement,\ + get_entrance_exam_content_info +from courseware.courses import sort_by_start_date, get_entrance_exam_score from courseware.masquerade import setup_masquerade from courseware.model_data import FieldDataCache from .module_render import toc_for_course, get_module_for_descriptor, get_module @@ -411,6 +412,19 @@ def _index_bulk_op(request, course_key, chapter, section, position): # Show empty courseware for a course with no units return render_to_response('courseware/courseware.html', context) elif chapter is None: + # Check first to see if we should instead redirect the user to an Entrance Exam + if settings.FEATURES.get('ENTRANCE_EXAMS', False) and course.entrance_exam_enabled: + exam_chapter, __ = get_entrance_exam_content_info(request, course) + if exam_chapter is not None: + exam_section = None + if exam_chapter.get_children(): + exam_section = exam_chapter.get_children()[0] + if exam_section: + return redirect('courseware_section', + course_id=unicode(course_key), + chapter=exam_chapter.url_name, + section=exam_section.url_name) + # passing CONTENT_DEPTH avoids returning 404 for a course with an # empty first section and a second section with content return redirect_to_course_position(course_module, CONTENT_DEPTH) @@ -441,6 +455,14 @@ def _index_bulk_op(request, course_key, chapter, section, position): return redirect(reverse('courseware', args=[course.id.to_deprecated_string()])) raise Http404 + if settings.FEATURES.get('ENTRANCE_EXAMS', False) and course.entrance_exam_enabled: + # Message should not appear outside the context of entrance exam subsection. + # if section is none then we don't need to show message on welcome back screen also. + if getattr(chapter_module, 'is_entrance_exam', False) and section is not None: + __, is_exam_passed = get_entrance_exam_content_info(request, course) + context['entrance_exam_current_score'] = get_entrance_exam_score(request, course) + context['entrance_exam_passed'] = is_exam_passed + if section is not None: section_descriptor = chapter_descriptor.get_child_by(lambda m: m.location.name == section) diff --git a/lms/static/sass/course/courseware/_courseware.scss b/lms/static/sass/course/courseware/_courseware.scss index 4f8470891c..ddaad0875c 100644 --- a/lms/static/sass/course/courseware/_courseware.scss +++ b/lms/static/sass/course/courseware/_courseware.scss @@ -60,6 +60,14 @@ div.course-wrapper { } } + .sequential-status-message { + margin-bottom: $baseline; + background-color: $gray-l5; + padding: ($baseline * 0.75); + border-radius: 3px; + @include font-size(13); + } + ul { li { margin-bottom: lh(0.5); diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index cc122cc653..a4e81911e2 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -234,6 +234,26 @@ ${fragment.foot_html()} % endif
    + % if getattr(course, 'entrance_exam_enabled') and \ + getattr(course, 'entrance_exam_minimum_score_pct') and \ + entrance_exam_current_score is not UNDEFINED: + % if not entrance_exam_passed: +

    + ${_('To access course materials, you must score {required_score}% or higher on this \ + exam. Your current score is {current_score}%.').format( + required_score=int(course.entrance_exam_minimum_score_pct * 100), + current_score=int(entrance_exam_current_score * 100) + )} +

    + % else: +

    + ${_('Your score is {current_score}%. You have passed the entrance exam.').format( + current_score=int(entrance_exam_current_score * 100) + )} +

    + % endif + % endif + ${fragment.body_html()}
    % if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):