Commit 1c839cc0 by Matt Drayer

Addressed several outstanding issues related to initial entrance exams feature delivery

parent 495583ac
...@@ -10,7 +10,8 @@ from django_future.csrf import ensure_csrf_cookie ...@@ -10,7 +10,8 @@ from django_future.csrf import ensure_csrf_cookie
from django.http import HttpResponse from django.http import HttpResponse
from django.test import RequestFactory from django.test import RequestFactory
from contentstore.views.item import create_item, delete_item from contentstore.views.helpers import create_xblock
from contentstore.views.item import delete_item
from milestones import api as milestones_api from milestones import api as milestones_api
from models.settings.course_metadata import CourseMetadata from models.settings.course_metadata import CourseMetadata
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
...@@ -108,10 +109,14 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N ...@@ -108,10 +109,14 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N
'is_entrance_exam': True, 'is_entrance_exam': True,
'in_entrance_exam': True, 'in_entrance_exam': True,
} }
factory = RequestFactory() parent_locator = unicode(course.location)
internal_request = factory.post('/', json.dumps(payload), content_type="application/json") created_block = create_xblock(
internal_request.user = request.user parent_locator=parent_locator,
created_item = json.loads(create_item(internal_request).content) user=request.user,
category='chapter',
display_name='Entrance Exam',
is_entrance_exam=True
)
# Set the entrance exam metadata flags for this course # Set the entrance exam metadata flags for this course
# Reload the course so we don't overwrite the new child reference # Reload the course so we don't overwrite the new child reference
...@@ -119,7 +124,7 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N ...@@ -119,7 +124,7 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N
metadata = { metadata = {
'entrance_exam_enabled': True, 'entrance_exam_enabled': True,
'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct / 100, 'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct / 100,
'entrance_exam_id': created_item['locator'], 'entrance_exam_id': unicode(created_block.location),
} }
CourseMetadata.update_from_dict(metadata, course, request.user) CourseMetadata.update_from_dict(metadata, course, request.user)
...@@ -146,7 +151,7 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N ...@@ -146,7 +151,7 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N
) )
milestones_api.add_course_content_milestone( milestones_api.add_course_content_milestone(
unicode(course.id), unicode(course.id),
created_item['locator'], unicode(created_block.location),
relationship_types['FULFILLS'], relationship_types['FULFILLS'],
milestone milestone
) )
......
...@@ -4,16 +4,22 @@ Helper methods for Studio views. ...@@ -4,16 +4,22 @@ Helper methods for Studio views.
from __future__ import absolute_import from __future__ import absolute_import
from uuid import uuid4
import urllib import urllib
from django.conf import settings from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from edxmako.shortcuts import render_to_string, render_to_response from edxmako.shortcuts import render_to_string, render_to_response
from opaque_keys.edx.keys import UsageKey
from xblock.core import XBlock from xblock.core import XBlock
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.tabs import StaticTab
from contentstore.utils import reverse_course_url, reverse_library_url, reverse_usage_url from contentstore.utils import reverse_course_url, reverse_library_url, reverse_usage_url
from models.settings.course_grading import CourseGradingModel
__all__ = ['edge', 'event', 'landing'] __all__ = ['edge', 'event', 'landing']
...@@ -154,3 +160,105 @@ def xblock_primary_child_category(xblock): ...@@ -154,3 +160,105 @@ def xblock_primary_child_category(xblock):
elif category == 'sequential': elif category == 'sequential':
return 'vertical' return 'vertical'
return None return None
def usage_key_with_run(usage_key_string):
"""
Converts usage_key_string to a UsageKey, adding a course run if necessary
"""
usage_key = UsageKey.from_string(usage_key_string)
usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key))
return usage_key
def create_xblock(parent_locator, user, category, display_name, boilerplate=None, is_entrance_exam=False):
"""
Performs the actual grunt work of creating items/xblocks -- knows nothing about requests, views, etc.
"""
store = modulestore()
usage_key = usage_key_with_run(parent_locator)
with store.bulk_operations(usage_key.course_key):
parent = store.get_item(usage_key)
dest_usage_key = usage_key.replace(category=category, name=uuid4().hex)
# get the metadata, display_name, and definition from the caller
metadata = {}
data = None
template_id = boilerplate
if template_id:
clz = parent.runtime.load_block_type(category)
if clz is not None:
template = clz.get_template(template_id)
if template is not None:
metadata = template.get('metadata', {})
data = template.get('data')
if display_name is not None:
metadata['display_name'] = display_name
# We should use the 'fields' kwarg for newer module settings/values (vs. metadata or data)
fields = {}
# Entrance Exams: Chapter module positioning
child_position = None
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
if category == 'chapter' and is_entrance_exam:
fields['is_entrance_exam'] = is_entrance_exam
fields['in_entrance_exam'] = True # Inherited metadata, all children will have it
child_position = 0
# TODO need to fix components that are sending definition_data as strings, instead of as dicts
# For now, migrate them into dicts here.
if isinstance(data, basestring):
data = {'data': data}
created_block = store.create_child(
user.id,
usage_key,
dest_usage_key.block_type,
block_id=dest_usage_key.block_id,
fields=fields,
definition_data=data,
metadata=metadata,
runtime=parent.runtime,
position=child_position,
)
# Entrance Exams: Grader assignment
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
course = store.get_course(usage_key.course_key)
if hasattr(course, 'entrance_exam_enabled') and course.entrance_exam_enabled:
if category == 'sequential' and parent_locator == course.entrance_exam_id:
grader = {
"type": "Entrance Exam",
"min_count": 0,
"drop_count": 0,
"short_label": "Entrance",
"weight": 0
}
grading_model = CourseGradingModel.update_grader_from_json(
course.id,
grader,
user
)
CourseGradingModel.update_section_grader_type(
created_block,
grading_model['type'],
user
)
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
# if we add one then we need to also add it to the policy information (i.e. metadata)
# we should remove this once we can break this reference from the course to static tabs
if category == 'static_tab':
display_name = display_name or _("Empty") # Prevent name being None
course = store.get_course(dest_usage_key.course_key)
course.tabs.append(
StaticTab(
name=display_name,
url_slug=dest_usage_key.name,
)
)
store.update_item(course, user.id)
return created_block
...@@ -42,7 +42,7 @@ from student.auth import has_studio_write_access, has_studio_read_access ...@@ -42,7 +42,7 @@ from student.auth import has_studio_write_access, has_studio_read_access
from contentstore.utils import find_release_date_source, find_staff_lock_source, is_currently_visible_to_students, \ from contentstore.utils import find_release_date_source, find_staff_lock_source, is_currently_visible_to_students, \
ancestor_has_staff_lock, has_children_visible_to_specific_content_groups ancestor_has_staff_lock, has_children_visible_to_specific_content_groups
from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \ from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \
xblock_type_display_name, get_parent_xblock xblock_type_display_name, get_parent_xblock, create_xblock, usage_key_with_run
from contentstore.views.preview import get_preview_fragment from contentstore.views.preview import get_preview_fragment
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from models.settings.course_grading import CourseGradingModel from models.settings.course_grading import CourseGradingModel
...@@ -79,15 +79,6 @@ def hash_resource(resource): ...@@ -79,15 +79,6 @@ def hash_resource(resource):
return md5.hexdigest() return md5.hexdigest()
def usage_key_with_run(usage_key_string):
"""
Converts usage_key_string to a UsageKey, adding a course run if necessary
"""
usage_key = UsageKey.from_string(usage_key_string)
usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key))
return usage_key
def _filter_entrance_exam_grader(graders): def _filter_entrance_exam_grader(graders):
""" """
If the entrance exams feature is enabled we need to hide away the grader from If the entrance exams feature is enabled we need to hide away the grader from
...@@ -536,13 +527,12 @@ def create_item(request): ...@@ -536,13 +527,12 @@ def create_item(request):
@expect_json @expect_json
def _create_item(request): def _create_item(request):
"""View for create items.""" """View for create items."""
usage_key = usage_key_with_run(request.json['parent_locator']) parent_locator = request.json['parent_locator']
usage_key = usage_key_with_run(parent_locator)
if not has_studio_write_access(request.user, usage_key.course_key): if not has_studio_write_access(request.user, usage_key.course_key):
raise PermissionDenied() raise PermissionDenied()
category = request.json['category'] category = request.json['category']
display_name = request.json.get('display_name')
if isinstance(usage_key, LibraryUsageLocator): if isinstance(usage_key, LibraryUsageLocator):
# Only these categories are supported at this time. # Only these categories are supported at this time.
if category not in ['html', 'problem', 'video']: if category not in ['html', 'problem', 'video']:
...@@ -550,87 +540,13 @@ def _create_item(request): ...@@ -550,87 +540,13 @@ def _create_item(request):
"Category '%s' not supported for Libraries" % category, content_type='text/plain' "Category '%s' not supported for Libraries" % category, content_type='text/plain'
) )
store = modulestore() created_block = create_xblock(
with store.bulk_operations(usage_key.course_key): parent_locator=parent_locator,
parent = store.get_item(usage_key) user=request.user,
dest_usage_key = usage_key.replace(category=category, name=uuid4().hex) category=category,
display_name=request.json.get('display_name'),
# get the metadata, display_name, and definition from the request boilerplate=request.json.get('boilerplate')
metadata = {}
data = None
template_id = request.json.get('boilerplate')
if template_id:
clz = parent.runtime.load_block_type(category)
if clz is not None:
template = clz.get_template(template_id)
if template is not None:
metadata = template.get('metadata', {})
data = template.get('data')
if display_name is not None:
metadata['display_name'] = display_name
# Entrance Exams: Chapter module positioning
child_position = None
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
is_entrance_exam = request.json.get('is_entrance_exam', False)
if category == 'chapter' and is_entrance_exam:
metadata['is_entrance_exam'] = is_entrance_exam
metadata['in_entrance_exam'] = True # Inherited metadata, all children will have it
child_position = 0
# TODO need to fix components that are sending definition_data as strings, instead of as dicts
# For now, migrate them into dicts here.
if isinstance(data, basestring):
data = {'data': data}
created_block = store.create_child(
request.user.id,
usage_key,
dest_usage_key.block_type,
block_id=dest_usage_key.block_id,
definition_data=data,
metadata=metadata,
runtime=parent.runtime,
position=child_position
)
# Entrance Exams: Grader assignment
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
course = store.get_course(usage_key.course_key)
if hasattr(course, 'entrance_exam_enabled') and course.entrance_exam_enabled:
if category == 'sequential' and request.json.get('parent_locator') == course.entrance_exam_id:
grader = {
"type": "Entrance Exam",
"min_count": 0,
"drop_count": 0,
"short_label": "Entrance",
"weight": 0
}
grading_model = CourseGradingModel.update_grader_from_json(
course.id,
grader,
request.user
)
CourseGradingModel.update_section_grader_type(
created_block,
grading_model['type'],
request.user
)
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
# if we add one then we need to also add it to the policy information (i.e. metadata)
# we should remove this once we can break this reference from the course to static tabs
if category == 'static_tab':
display_name = display_name or _("Empty") # Prevent name being None
course = store.get_course(dest_usage_key.course_key)
course.tabs.append(
StaticTab(
name=display_name,
url_slug=dest_usage_key.name,
)
) )
store.update_item(course, request.user.id)
return JsonResponse( return JsonResponse(
{"locator": unicode(created_block.location), "courseKey": unicode(created_block.location.course_key)} {"locator": unicode(created_block.location), "courseKey": unicode(created_block.location.course_key)}
......
...@@ -1378,6 +1378,20 @@ class TestXBlockInfo(ItemTest): ...@@ -1378,6 +1378,20 @@ class TestXBlockInfo(ItemTest):
json_response = json.loads(resp.content) json_response = json.loads(resp.content)
self.validate_course_xblock_info(json_response, course_outline=True) self.validate_course_xblock_info(json_response, course_outline=True)
def test_chapter_entrance_exam_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
)
chapter = modulestore().get_item(chapter.location)
xblock_info = create_xblock_info(
chapter,
include_child_info=True,
include_children_predicate=ALWAYS,
)
self.assertEqual(xblock_info['override_type'], {'is_entrance_exam': True})
self.assertEqual(xblock_info['display_name'], 'Entrance Exam')
def test_chapter_xblock_info(self): def test_chapter_xblock_info(self):
chapter = modulestore().get_item(self.chapter.location) chapter = modulestore().get_item(self.chapter.location)
xblock_info = create_xblock_info( xblock_info = create_xblock_info(
......
...@@ -7,6 +7,16 @@ define(['backbone', 'js/models/xblock_info'], ...@@ -7,6 +7,16 @@ define(['backbone', 'js/models/xblock_info'],
expect(new XBlockInfo({'category': 'sequential'}).isEditableOnCourseOutline()).toBe(true); expect(new XBlockInfo({'category': 'sequential'}).isEditableOnCourseOutline()).toBe(true);
expect(new XBlockInfo({'category': 'vertical'}).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);
});
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);
});
}); });
} }
); );
...@@ -3,6 +3,13 @@ define([ ...@@ -3,6 +3,13 @@ define([
'js/common_helpers/ajax_helpers' 'js/common_helpers/ajax_helpers'
], function($, CourseDetailsModel, MainView, AjaxHelpers) { ], function($, CourseDetailsModel, MainView, AjaxHelpers) {
'use strict'; 'use strict';
var SELECTORS = {
entrance_exam_min_score: '#entrance-exam-minimum-score-pct',
entrance_exam_enabled_field: '#entrance-exam-enabled',
grade_requirement_div: '.div-grade-requirements div'
};
describe('Settings/Main', function () { describe('Settings/Main', function () {
var urlRoot = '/course/settings/org/DemoX/Demo_Course', var urlRoot = '/course/settings/org/DemoX/Demo_Course',
modelData = { modelData = {
...@@ -79,35 +86,63 @@ define([ ...@@ -79,35 +86,63 @@ define([
AjaxHelpers.respondWithJson(requests, expectedJson); AjaxHelpers.respondWithJson(requests, expectedJson);
}); });
it('should save entrance exam course details information correctly', function () { it('should disallow save with an invalid minimum score percentage', function(){
var entrance_exam_minimum_score_pct = '60'; var entrance_exam_enabled_field = this.view.$(SELECTORS.entrance_exam_enabled_field),
var entrance_exam_enabled = 'true'; entrance_exam_min_score = this.view.$(SELECTORS.entrance_exam_min_score);
var entrance_exam_min_score = this.view.$('#entrance-exam-minimum-score-pct'); //input some invalid values.
var entrance_exam_enabled_field = this.view.$('#entrance-exam-enabled'); expect(entrance_exam_min_score.val('101').trigger('input')).toHaveClass("error");
expect(entrance_exam_min_score.val('invalidVal').trigger('input')).toHaveClass("error");
var requests = AjaxHelpers.requests(this),
expectedJson = $.extend(true, {}, modelData, {
entrance_exam_enabled: entrance_exam_enabled,
entrance_exam_minimum_score_pct: entrance_exam_minimum_score_pct
}); });
expect(this.view.$('.div-grade-requirements div')).toBeHidden(); it('should provide a default value for the minimum score percentage', function(){
// select the entrance-exam-enabled checkbox. grade requirement section should be visible var entrance_exam_min_score = this.view.$(SELECTORS.entrance_exam_min_score);
entrance_exam_enabled_field
.attr('checked', entrance_exam_enabled)
.trigger('change');
expect(this.view.$('.div-grade-requirements div')).toBeVisible();
//input some invalid values.
expect(entrance_exam_min_score.val('101').trigger('input')).toHaveClass("error");
expect(entrance_exam_min_score.val('invalidVal').trigger('input')).toHaveClass("error");
//if input an empty value, model should be populated with the default value. //if input an empty value, model should be populated with the default value.
entrance_exam_min_score.val('').trigger('input'); entrance_exam_min_score.val('').trigger('input');
expect(this.model.get('entrance_exam_minimum_score_pct')) expect(this.model.get('entrance_exam_minimum_score_pct'))
.toEqual(this.model.defaults.entrance_exam_minimum_score_pct); .toEqual(this.model.defaults.entrance_exam_minimum_score_pct);
});
it('show and hide the grade requirement section when the check box is selected and deselected respectively', function(){
var entrance_exam_enabled_field = this.view.$(SELECTORS.entrance_exam_enabled_field);
// select the entrance-exam-enabled checkbox. grade requirement section should be visible.
entrance_exam_enabled_field
.attr('checked', 'true')
.trigger('change');
this.view.render();
expect(this.view.$(SELECTORS.grade_requirement_div)).toBeVisible();
// deselect the entrance-exam-enabled checkbox. grade requirement section should be hidden.
entrance_exam_enabled_field
.removeAttr('checked')
.trigger('change');
expect(this.view.$(SELECTORS.grade_requirement_div)).toBeHidden();
});
it('should save entrance exam course details information correctly', function () {
var entrance_exam_minimum_score_pct = '60',
entrance_exam_enabled = 'true',
entrance_exam_min_score = this.view.$(SELECTORS.entrance_exam_min_score),
entrance_exam_enabled_field = this.view.$(SELECTORS.entrance_exam_enabled_field);
var requests = AjaxHelpers.requests(this),
expectedJson = $.extend(true, {}, modelData, {
entrance_exam_enabled: entrance_exam_enabled,
entrance_exam_minimum_score_pct: entrance_exam_minimum_score_pct
});
// select the entrance-exam-enabled checkbox.
entrance_exam_enabled_field
.attr('checked', 'true')
.trigger('change');
// input a valid value for entrance exam minimum score. // input a valid value for entrance exam minimum score.
entrance_exam_min_score.val(entrance_exam_minimum_score_pct).trigger('input'); entrance_exam_min_score.val(entrance_exam_minimum_score_pct).trigger('input');
......
...@@ -52,7 +52,7 @@ class SequenceFields(object): ...@@ -52,7 +52,7 @@ class SequenceFields(object):
"Tag this course module as an Entrance Exam. " + "Tag this course module as an Entrance Exam. " +
"Note, you must enable Entrance Exams for this course setting to take effect." "Note, you must enable Entrance Exams for this course setting to take effect."
), ),
scope=Scope.settings, scope=Scope.content,
) )
......
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