Commit 5a7ac441 by asadiqbal Committed by Matt Drayer

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
parent 57c38649
......@@ -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(
......
......@@ -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...
......
......@@ -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
......
......@@ -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:
......
......@@ -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)
......
......@@ -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');
},
isDraggable: function() {
return this.isActionRequired('draggable');
},
//hide/remove the delete trash icon if type is entrance exam.
if (_.has(type, 'is_entrance_exam') && type['is_entrance_exam']) {
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;
});
......@@ -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);
});
});
describe('XblockInfo actions state and header visibility ', function() {
it('cannot delete an entrance exam', function(){
expect(new XBlockInfo({'category': 'chapter', 'override_type': {'is_entrance_exam':true}})
.canBeDeleted()).toBe(false);
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);
});
});
}
);
......@@ -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 () {
......
......@@ -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();
}
......
......@@ -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;
}
}
......
......@@ -40,7 +40,7 @@ if (xblockInfo.get('graded')) {
data-parent="<%= parentInfo.get('id') %>" data-locator="<%= xblockInfo.get('id') %>">
<span class="draggable-drop-indicator draggable-drop-indicator-before"><i class="icon fa fa-caret-right"></i></span>
<% if (xblockInfo.isHeaderVisible()) { %>
<div class="<%= xblockType %>-header">
<% if (includesChildren) { %>
<h3 class="<%= xblockType %>-header-details expand-collapse <%= isCollapsed ? 'expand' : 'collapse' %> ui-toggle-expansion"
......@@ -78,7 +78,7 @@ if (xblockInfo.get('graded')) {
</a>
</li>
<% } %>
<% if (xblockInfo.canBeDeleted()) { %>
<% if (xblockInfo.isDeletable()) { %>
<li class="action-item action-delete">
<a href="#" data-tooltip="<%= gettext('Delete') %>" class="delete-button action-button">
<i class="icon fa fa-trash-o" aria-hidden="true"></i>
......@@ -86,18 +86,27 @@ if (xblockInfo.get('graded')) {
</a>
</li>
<% } %>
<% if (xblockInfo.isDraggable()) { %>
<li class="action-item action-drag">
<span data-tooltip="<%= gettext('Drag to reorder') %>"
class="drag-handle <%= xblockType %>-drag-handle action">
<span class="sr"><%= gettext('Drag to reorder') %></span>
</span>
</li>
<% } %>
</ul>
</div>
</div>
<div class="<%= xblockType %>-status">
<% if (!xblockInfo.isVertical()) { %>
<div class="status-release">
<% if (xblockInfo.get('explanatory_message') !=null) { %>
<div class="explanatory-message">
<span>
<%= xblockInfo.get('explanatory_message') %>
</span>
</div>
<% } else { %>
<div class="status-release">
<p>
<span class="sr status-release-label"><%= gettext('Release Status:') %></span>
<span class="status-release-value">
......@@ -116,7 +125,8 @@ if (xblockInfo.get('graded')) {
<% } %>
</span>
</p>
</div>
</div>
<% } %>
<% if (xblockInfo.get('due_date') || xblockInfo.get('graded')) { %>
<div class="status-grading">
<p>
......@@ -138,6 +148,7 @@ if (xblockInfo.get('graded')) {
</div>
<% } %>
</div>
<% } %>
<% } %>
<% if (!parentInfo && xblockInfo.get('child_info') && xblockInfo.get('child_info').children.length === 0) { %>
......@@ -159,6 +170,7 @@ if (xblockInfo.get('graded')) {
</ol>
<% if (childType) { %>
<% if (xblockInfo.isChildAddable()) { %>
<div class="add-<%= childType %> add-item">
<a href="#" class="button button-new" data-category="<%= childCategory %>"
data-parent="<%= xblockInfo.get('id') %>" data-default-name="<%= defaultNewChildName %>"
......@@ -166,6 +178,7 @@ if (xblockInfo.get('graded')) {
<i class="icon fa fa-plus"></i><%= addChildLabel %>
</a>
</div>
<% } %>
<% } %>
</div>
<% } %>
......
......@@ -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
......
......@@ -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'
))
......@@ -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
......@@ -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():
......
......@@ -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)
......
......@@ -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);
......
......@@ -234,6 +234,26 @@ ${fragment.foot_html()}
</div>
% endif
<section class="course-content" id="course-content">
% 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:
<p class="sequential-status-message">
${_('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)
)}
</p>
% else:
<p class="sequential-status-message">
${_('Your score is {current_score}%. You have passed the entrance exam.').format(
current_score=int(entrance_exam_current_score * 100)
)}
</p>
% endif
% endif
${fragment.body_html()}
</section>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment