Commit 8a9ba79d by Calen Pennington

Merge remote-tracking branch 'origin/master' into feature/alex/poll-merged

Conflicts:
	cms/djangoapps/contentstore/views.py
	common/lib/xmodule/xmodule/course_module.py
	common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py
	common/lib/xmodule/xmodule/peer_grading_module.py
parents 14873e64 cde4cdf8
from xmodule.templates import update_templates
update_templates()
...@@ -11,6 +11,14 @@ Feature: Create Section ...@@ -11,6 +11,14 @@ Feature: Create Section
And I see a release date for my section And I see a release date for my section
And I see a link to create a new subsection And I see a link to create a new subsection
Scenario: Add a new section (with a quote in the name) to a course (bug #216)
Given I have opened a new course in Studio
When I click the New Section link
And I enter a section name with a quote and click save
Then I see my section name with a quote on the Courseware page
And I click to edit the section name
Then I see the complete section name with a quote in the editor
Scenario: Edit section release date Scenario: Edit section release date
Given I have opened a new course in Studio Given I have opened a new course in Studio
And I have added a new section And I have added a new section
......
from lettuce import world, step from lettuce import world, step
from common import * from common import *
from nose.tools import assert_equal
############### ACTIONS #################### ############### ACTIONS ####################
...@@ -12,10 +13,12 @@ def i_click_new_section_link(step): ...@@ -12,10 +13,12 @@ def i_click_new_section_link(step):
@step('I enter the section name and click save$') @step('I enter the section name and click save$')
def i_save_section_name(step): def i_save_section_name(step):
name_css = '.new-section-name' save_section_name('My Section')
save_css = '.new-section-name-save'
css_fill(name_css, 'My Section')
css_click(save_css) @step('I enter a section name with a quote and click save$')
def i_save_section_name_with_quote(step):
save_section_name('Section with "Quote"')
@step('I have added a new section$') @step('I have added a new section$')
...@@ -45,8 +48,24 @@ def i_save_a_new_section_release_date(step): ...@@ -45,8 +48,24 @@ def i_save_a_new_section_release_date(step):
@step('I see my section on the Courseware page$') @step('I see my section on the Courseware page$')
def i_see_my_section_on_the_courseware_page(step): def i_see_my_section_on_the_courseware_page(step):
section_css = 'span.section-name-span' see_my_section_on_the_courseware_page('My Section')
assert_css_with_text(section_css, 'My Section')
@step('I see my section name with a quote on the Courseware page$')
def i_see_my_section_name_with_quote_on_the_courseware_page(step):
see_my_section_on_the_courseware_page('Section with "Quote"')
@step('I click to edit the section name$')
def i_click_to_edit_section_name(step):
css_click('span.section-name-span')
@step('I see the complete section name with a quote in the editor$')
def i_see_complete_section_name_with_quote_in_editor(step):
css = '.edit-section-name'
assert world.browser.is_element_present_by_css(css, 5)
assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"')
@step('the section does not exist$') @step('the section does not exist$')
...@@ -88,3 +107,17 @@ def the_section_release_date_is_updated(step): ...@@ -88,3 +107,17 @@ def the_section_release_date_is_updated(step):
css = 'span.published-status' css = 'span.published-status'
status_text = world.browser.find_by_css(css).text status_text = world.browser.find_by_css(css).text
assert status_text == 'Will Release: 12/25/2013 at 12:00am' assert status_text == 'Will Release: 12/25/2013 at 12:00am'
############ HELPER METHODS ###################
def save_section_name(name):
name_css = '.new-section-name'
save_css = '.new-section-name-save'
css_fill(name_css, name)
css_click(save_css)
def see_my_section_on_the_courseware_page(name):
section_css = 'span.section-name-span'
assert_css_with_text(section_css, name)
\ No newline at end of file
...@@ -9,6 +9,14 @@ Feature: Create Subsection ...@@ -9,6 +9,14 @@ Feature: Create Subsection
And I enter the subsection name and click save And I enter the subsection name and click save
Then I see my subsection on the Courseware page Then I see my subsection on the Courseware page
Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216)
Given I have opened a new course section in Studio
When I click the New Subsection link
And I enter a subsection name with a quote and click save
Then I see my subsection name with a quote on the Courseware page
And I click to edit the subsection name
Then I see the complete subsection name with a quote in the editor
Scenario: Delete a subsection Scenario: Delete a subsection
Given I have opened a new course section in Studio Given I have opened a new course section in Studio
And I have added a new subsection And I have added a new subsection
......
from lettuce import world, step from lettuce import world, step
from common import * from common import *
from nose.tools import assert_equal
############### ACTIONS #################### ############### ACTIONS ####################
...@@ -20,28 +21,60 @@ def i_click_the_new_subsection_link(step): ...@@ -20,28 +21,60 @@ def i_click_the_new_subsection_link(step):
@step('I enter the subsection name and click save$') @step('I enter the subsection name and click save$')
def i_save_subsection_name(step): def i_save_subsection_name(step):
name_css = 'input.new-subsection-name-input' save_subsection_name('Subsection One')
save_css = 'input.new-subsection-name-save'
css_fill(name_css, 'Subsection One')
css_click(save_css) @step('I enter a subsection name with a quote and click save$')
def i_save_subsection_name_with_quote(step):
save_subsection_name('Subsection With "Quote"')
@step('I click to edit the subsection name$')
def i_click_to_edit_subsection_name(step):
css_click('span.subsection-name-value')
@step('I see the complete subsection name with a quote in the editor$')
def i_see_complete_subsection_name_with_quote_in_editor(step):
css = '.subsection-display-name-input'
assert world.browser.is_element_present_by_css(css, 5)
assert_equal(world.browser.find_by_css(css).value, 'Subsection With "Quote"')
@step('I have added a new subsection$') @step('I have added a new subsection$')
def i_have_added_a_new_subsection(step): def i_have_added_a_new_subsection(step):
add_subsection() add_subsection()
############ ASSERTIONS ################### ############ ASSERTIONS ###################
@step('I see my subsection on the Courseware page$') @step('I see my subsection on the Courseware page$')
def i_see_my_subsection_on_the_courseware_page(step): def i_see_my_subsection_on_the_courseware_page(step):
css = 'span.subsection-name' see_subsection_name('Subsection One')
assert world.browser.is_element_present_by_css(css)
css = 'span.subsection-name-value'
assert_css_with_text(css, 'Subsection One') @step('I see my subsection name with a quote on the Courseware page$')
def i_see_my_subsection_name_with_quote_on_the_courseware_page(step):
see_subsection_name('Subsection With "Quote"')
@step('the subsection does not exist$') @step('the subsection does not exist$')
def the_subsection_does_not_exist(step): def the_subsection_does_not_exist(step):
css = 'span.subsection-name' css = 'span.subsection-name'
assert world.browser.is_element_not_present_by_css(css) assert world.browser.is_element_not_present_by_css(css)
############ HELPER METHODS ###################
def save_subsection_name(name):
name_css = 'input.new-subsection-name-input'
save_css = 'input.new-subsection-name-save'
css_fill(name_css, name)
css_click(save_css)
def see_subsection_name(name):
css = 'span.subsection-name'
assert world.browser.is_element_present_by_css(css)
css = 'span.subsection-name-value'
assert_css_with_text(css, name)
from xmodule.templates import update_templates
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = \
'''Imports and updates the Studio component templates from the code pack and put in the DB'''
def handle(self, *args, **options):
update_templates()
\ No newline at end of file
from factory import Factory
from datetime import datetime
from uuid import uuid4
from student.models import (User, UserProfile, Registration,
CourseEnrollmentAllowed)
from django.contrib.auth.models import Group
class UserProfileFactory(Factory):
FACTORY_FOR = UserProfile
user = None
name = 'Robot Studio'
courseware = 'course.xml'
class RegistrationFactory(Factory):
FACTORY_FOR = Registration
user = None
activation_key = uuid4().hex
class UserFactory(Factory):
FACTORY_FOR = User
username = 'robot'
email = 'robot@edx.org'
password = 'test'
first_name = 'Robot'
last_name = 'Tester'
is_staff = False
is_active = True
is_superuser = False
last_login = datetime.now()
date_joined = datetime.now()
class GroupFactory(Factory):
FACTORY_FOR = Group
name = 'test_group'
class CourseEnrollmentAllowedFactory(Factory):
FACTORY_FOR = CourseEnrollmentAllowed
email = 'test@edx.org'
course_id = 'edX/test/2012_Fall'
...@@ -72,6 +72,10 @@ log = logging.getLogger(__name__) ...@@ -72,6 +72,10 @@ log = logging.getLogger(__name__)
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video'] COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
ADVANCED_COMPONENT_TYPES = ['annotatable','combinedopenended', 'peergrading']
ADVANCED_COMPONENT_CATEGORY = 'advanced'
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
# cdodge: these are categories which should not be parented, they are detached from the hierarchy # cdodge: these are categories which should not be parented, they are detached from the hierarchy
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
...@@ -290,10 +294,31 @@ def edit_unit(request, location): ...@@ -290,10 +294,31 @@ def edit_unit(request, location):
component_templates = defaultdict(list) component_templates = defaultdict(list)
# Check if there are any advanced modules specified in the course policy. These modules
# should be specified as a list of strings, where the strings are the names of the modules
# in ADVANCED_COMPONENT_TYPES that should be enabled for the course.
course_metadata = CourseMetadata.fetch(course.location)
course_advanced_keys = course_metadata.get(ADVANCED_COMPONENT_POLICY_KEY, [])
# Set component types according to course policy file
component_types = list(COMPONENT_TYPES)
if isinstance(course_advanced_keys, list):
course_advanced_keys = [c for c in course_advanced_keys if c in ADVANCED_COMPONENT_TYPES]
if len(course_advanced_keys) > 0:
component_types.append(ADVANCED_COMPONENT_CATEGORY)
else:
log.error("Improper format for course advanced keys! {0}".format(course_advanced_keys))
templates = modulestore().get_items(Location('i4x', 'edx', 'templates')) templates = modulestore().get_items(Location('i4x', 'edx', 'templates'))
for template in templates: for template in templates:
if template.location.category in COMPONENT_TYPES: category = template.location.category
component_templates[template.location.category].append((
if category in course_advanced_keys:
category = ADVANCED_COMPONENT_CATEGORY
if category in component_types:
#This is a hack to create categories for different xmodules
component_templates[category].append((
template.lms.display_name, template.lms.display_name,
template.location.url(), template.location.url(),
hasattr(template, 'markdown') and template.markdown != '', hasattr(template, 'markdown') and template.markdown != '',
...@@ -1107,6 +1132,7 @@ def module_info(request, module_location): ...@@ -1107,6 +1132,7 @@ def module_info(request, module_location):
else: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def get_course_settings(request, org, course, name): def get_course_settings(request, org, course, name):
...@@ -1122,12 +1148,15 @@ def get_course_settings(request, org, course, name): ...@@ -1122,12 +1148,15 @@ def get_course_settings(request, org, course, name):
raise PermissionDenied() raise PermissionDenied()
course_module = modulestore().get_item(location) course_module = modulestore().get_item(location)
course_details = CourseDetails.fetch(location)
return render_to_response('settings.html', { return render_to_response('settings.html', {
'context_course': course_module, 'context_course': course_module,
'course_location' : location, 'course_location': location,
'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder) 'details_url': reverse(course_settings_updates,
kwargs={"org": org,
"course": course,
"name": name,
"section": "details"})
}) })
@login_required @login_required
......
...@@ -27,6 +27,9 @@ STATIC_ROOT = TEST_ROOT / "staticfiles" ...@@ -27,6 +27,9 @@ STATIC_ROOT = TEST_ROOT / "staticfiles"
GITHUB_REPO_ROOT = TEST_ROOT / "data" GITHUB_REPO_ROOT = TEST_ROOT / "data"
COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data" COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data"
# Makes the tests run much faster...
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
# TODO (cpennington): We need to figure out how envs/test.py can inject things into common.py so that we don't have to repeat this sort of thing # TODO (cpennington): We need to figure out how envs/test.py can inject things into common.py so that we don't have to repeat this sort of thing
STATICFILES_DIRS = [ STATICFILES_DIRS = [
COMMON_ROOT / "static", COMMON_ROOT / "static",
......
...@@ -59,11 +59,6 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ ...@@ -59,11 +59,6 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
// NOTE don't return empty errors as that will be interpreted as an error state // NOTE don't return empty errors as that will be interpreted as an error state
}, },
url: function() {
var location = this.get('location');
return '/' + location.get('org') + "/" + location.get('course') + '/settings-details/' + location.get('name') + '/section/details';
},
_videokey_illegal_chars : /[^a-zA-Z0-9_-]/g, _videokey_illegal_chars : /[^a-zA-Z0-9_-]/g,
save_videosource: function(newsource) { save_videosource: function(newsource) {
// newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string // newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string
......
if (!CMS.Models['Settings']) CMS.Models.Settings = new Object();
CMS.Models.Settings.CourseSettings = Backbone.Model.extend({
// a container for the models representing the n possible tabbed states
defaults: {
courseLocation: null,
details: null,
faculty: null,
grading: null,
problems: null,
discussions: null
},
retrieve: function(submodel, callback) {
if (this.get(submodel)) callback();
else {
var cachethis = this;
switch (submodel) {
case 'details':
var details = new CMS.Models.Settings.CourseDetails({location: this.get('courseLocation')});
details.fetch( {
success : function(model) {
cachethis.set('details', model);
callback(model);
}
});
break;
case 'grading':
var grading = new CMS.Models.Settings.CourseGradingPolicy({course_location: this.get('courseLocation')});
grading.fetch( {
success : function(model) {
cachethis.set('grading', model);
callback(model);
}
});
break;
default:
break;
}
}
}
})
\ No newline at end of file
...@@ -44,6 +44,8 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -44,6 +44,8 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
self.render(); self.render();
} }
); );
// when the client refetches the updates as a whole, re-render them
this.listenTo(this.collection, 'reset', this.render);
}, },
render: function () { render: function () {
...@@ -53,8 +55,12 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -53,8 +55,12 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
$(updateEle).empty(); $(updateEle).empty();
var self = this; var self = this;
this.collection.each(function (update) { this.collection.each(function (update) {
var newEle = self.template({ updateModel : update }); try {
$(updateEle).append(newEle); var newEle = self.template({ updateModel : update });
$(updateEle).append(newEle);
} catch (e) {
// ignore
}
}); });
this.$el.find(".new-update-form").hide(); this.$el.find(".new-update-form").hide();
this.$el.find('.date').datepicker({ 'dateFormat': 'MM d, yy' }); this.$el.find('.date').datepicker({ 'dateFormat': 'MM d, yy' });
...@@ -150,7 +156,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -150,7 +156,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
}, },
closeEditor: function(self, removePost) { closeEditor: function(self, removePost) {
var targetModel = self.collection.getByCid(self.$currentPost.attr('name')); var targetModel = self.collection.get(self.$currentPost.attr('name'));
if(removePost) { if(removePost) {
self.$currentPost.remove(); self.$currentPost.remove();
...@@ -160,8 +166,13 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -160,8 +166,13 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
self.$currentPost.removeClass('editing'); self.$currentPost.removeClass('editing');
self.$currentPost.find('.date-display').html(targetModel.get('date')); self.$currentPost.find('.date-display').html(targetModel.get('date'));
self.$currentPost.find('.date').val(targetModel.get('date')); self.$currentPost.find('.date').val(targetModel.get('date'));
self.$currentPost.find('.update-contents').html(targetModel.get('content')); try {
self.$currentPost.find('.new-update-content').val(targetModel.get('content')); // just in case the content causes an error (embedded js errors)
self.$currentPost.find('.update-contents').html(targetModel.get('content'));
self.$currentPost.find('.new-update-content').val(targetModel.get('content'));
} catch (e) {
// ignore but handle rest of page
}
self.$currentPost.find('form').hide(); self.$currentPost.find('form').hide();
window.$modalCover.unbind('click'); window.$modalCover.unbind('click');
window.$modalCover.hide(); window.$modalCover.hide();
...@@ -172,7 +183,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({ ...@@ -172,7 +183,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
// Dereferencing from events to screen elements // Dereferencing from events to screen elements
eventModel: function(event) { eventModel: function(event) {
// not sure if it should be currentTarget or delegateTarget // not sure if it should be currentTarget or delegateTarget
return this.collection.getByCid($(event.currentTarget).attr("name")); return this.collection.get($(event.currentTarget).attr("name"));
}, },
modelDom: function(event) { modelDom: function(event) {
......
...@@ -254,6 +254,30 @@ ...@@ -254,6 +254,30 @@
background: url(../img/html-icon.png) center no-repeat; background: url(../img/html-icon.png) center no-repeat;
} }
.large-openended-icon {
display: inline-block;
width: 100px;
height: 60px;
margin-right: 5px;
background: url(../img/large-openended-icon.png) center no-repeat;
}
.large-annotations-icon {
display: inline-block;
width: 100px;
height: 60px;
margin-right: 5px;
background: url(../img/large-annotations-icon.png) center no-repeat;
}
.large-advanced-icon {
display: inline-block;
width: 100px;
height: 60px;
margin-right: 5px;
background: url(../img/large-advanced-icon.png) center no-repeat;
}
.large-textbook-icon { .large-textbook-icon {
display: inline-block; display: inline-block;
width: 100px; width: 100px;
......
...@@ -20,8 +20,8 @@ ...@@ -20,8 +20,8 @@
<script type="text/javascript" charset="utf-8"> <script type="text/javascript" charset="utf-8">
$(document).ready(function(){ $(document).ready(function(){
var course_updates = new CMS.Models.CourseUpdateCollection(); var course_updates = new CMS.Models.CourseUpdateCollection();
course_updates.reset(${course_updates|n});
course_updates.urlbase = '${url_base}'; course_updates.urlbase = '${url_base}';
course_updates.fetch();
var course_handouts = new CMS.Models.ModuleInfo({ var course_handouts = new CMS.Models.ModuleInfo({
id: '${handouts_location}' id: '${handouts_location}'
......
...@@ -30,13 +30,18 @@ from contentstore import utils ...@@ -30,13 +30,18 @@ from contentstore import utils
}).blur(function() { }).blur(function() {
$("label").removeClass("is-focused"); $("label").removeClass("is-focused");
}); });
var model = new CMS.Models.Settings.CourseDetails();
var editor = new CMS.Views.Settings.Details({ model.urlRoot = '${details_url}';
el: $('.settings-details'), model.fetch({success :
model: new CMS.Models.Settings.CourseDetails(${course_details|n},{parse:true}) function(model) {
}); var editor = new CMS.Views.Settings.Details({
el: $('.settings-details'),
editor.render(); model: model
});
editor.render();
}
});
}); });
</script> </script>
......
...@@ -512,7 +512,9 @@ class LoncapaProblem(object): ...@@ -512,7 +512,9 @@ class LoncapaProblem(object):
# let each Response render itself # let each Response render itself
if problemtree in self.responders: if problemtree in self.responders:
return self.responders[problemtree].render_html(self._extract_html) overall_msg = self.correct_map.get_overall_message()
return self.responders[problemtree].render_html(self._extract_html,
response_msg=overall_msg)
# let each custom renderer render itself: # let each custom renderer render itself:
if problemtree.tag in customrender.registry.registered_tags(): if problemtree.tag in customrender.registry.registered_tags():
......
...@@ -27,6 +27,7 @@ class CorrectMap(object): ...@@ -27,6 +27,7 @@ class CorrectMap(object):
self.cmap = dict() self.cmap = dict()
self.items = self.cmap.items self.items = self.cmap.items
self.keys = self.cmap.keys self.keys = self.cmap.keys
self.overall_message = ""
self.set(*args, **kwargs) self.set(*args, **kwargs)
def __getitem__(self, *args, **kwargs): def __getitem__(self, *args, **kwargs):
...@@ -104,16 +105,21 @@ class CorrectMap(object): ...@@ -104,16 +105,21 @@ class CorrectMap(object):
return self.is_queued(answer_id) and self.cmap[answer_id]['queuestate']['key'] == test_key return self.is_queued(answer_id) and self.cmap[answer_id]['queuestate']['key'] == test_key
def get_queuetime_str(self, answer_id): def get_queuetime_str(self, answer_id):
return self.cmap[answer_id]['queuestate']['time'] if self.cmap[answer_id]['queuestate']:
return self.cmap[answer_id]['queuestate']['time']
else:
return None
def get_npoints(self, answer_id): def get_npoints(self, answer_id):
npoints = self.get_property(answer_id, 'npoints') """ Return the number of points for an answer:
if npoints is not None: If the answer is correct, return the assigned
return npoints number of points (default: 1 point)
elif self.is_correct(answer_id): Otherwise, return 0 points """
return 1 if self.is_correct(answer_id):
# if not correct and no points have been assigned, return 0 npoints = self.get_property(answer_id, 'npoints')
return 0 return npoints if npoints is not None else 1
else:
return 0
def set_property(self, answer_id, property, value): def set_property(self, answer_id, property, value):
if answer_id in self.cmap: if answer_id in self.cmap:
...@@ -153,3 +159,15 @@ class CorrectMap(object): ...@@ -153,3 +159,15 @@ class CorrectMap(object):
if not isinstance(other_cmap, CorrectMap): if not isinstance(other_cmap, CorrectMap):
raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap) raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap)
self.cmap.update(other_cmap.get_dict()) self.cmap.update(other_cmap.get_dict())
self.set_overall_message(other_cmap.get_overall_message())
def set_overall_message(self, message_str):
""" Set a message that applies to the question as a whole,
rather than to individual inputs. """
self.overall_message = str(message_str) if message_str else ""
def get_overall_message(self):
""" Retrieve a message that applies to the question as a whole.
If no message is available, returns the empty string """
return self.overall_message
...@@ -174,13 +174,14 @@ class LoncapaResponse(object): ...@@ -174,13 +174,14 @@ class LoncapaResponse(object):
''' '''
return sum(self.maxpoints.values()) return sum(self.maxpoints.values())
def render_html(self, renderer): def render_html(self, renderer, response_msg=''):
''' '''
Return XHTML Element tree representation of this Response. Return XHTML Element tree representation of this Response.
Arguments: Arguments:
- renderer : procedure which produces HTML given an ElementTree - renderer : procedure which produces HTML given an ElementTree
- response_msg: a message displayed at the end of the Response
''' '''
# render ourself as a <span> + our content # render ourself as a <span> + our content
tree = etree.Element('span') tree = etree.Element('span')
...@@ -195,6 +196,11 @@ class LoncapaResponse(object): ...@@ -195,6 +196,11 @@ class LoncapaResponse(object):
if item_xhtml is not None: if item_xhtml is not None:
tree.append(item_xhtml) tree.append(item_xhtml)
tree.tail = self.xml.tail tree.tail = self.xml.tail
# Add a <div> for the message at the end of the response
if response_msg:
tree.append(self._render_response_msg_html(response_msg))
return tree return tree
def evaluate_answers(self, student_answers, old_cmap): def evaluate_answers(self, student_answers, old_cmap):
...@@ -319,6 +325,29 @@ class LoncapaResponse(object): ...@@ -319,6 +325,29 @@ class LoncapaResponse(object):
def __unicode__(self): def __unicode__(self):
return u'LoncapaProblem Response %s' % self.xml.tag return u'LoncapaProblem Response %s' % self.xml.tag
def _render_response_msg_html(self, response_msg):
""" Render a <div> for a message that applies to the entire response.
*response_msg* is a string, which may contain XHTML markup
Returns an etree element representing the response message <div> """
# First try wrapping the text in a <div> and parsing
# it as an XHTML tree
try:
response_msg_div = etree.XML('<div>%s</div>' % str(response_msg))
# If we can't do that, create the <div> and set the message
# as the text of the <div>
except:
response_msg_div = etree.Element('div')
response_msg_div.text = str(response_msg)
# Set the css class of the message <div>
response_msg_div.set("class", "response_message")
return response_msg_div
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
...@@ -965,6 +994,7 @@ def sympy_check2(): ...@@ -965,6 +994,7 @@ def sympy_check2():
# not expecting 'unknown's # not expecting 'unknown's
correct = ['unknown'] * len(idset) correct = ['unknown'] * len(idset)
messages = [''] * len(idset) messages = [''] * len(idset)
overall_message = ""
# put these in the context of the check function evaluator # put these in the context of the check function evaluator
# note that this doesn't help the "cfn" version - only the exec version # note that this doesn't help the "cfn" version - only the exec version
...@@ -996,6 +1026,10 @@ def sympy_check2(): ...@@ -996,6 +1026,10 @@ def sympy_check2():
# the list of messages to be filled in by the check function # the list of messages to be filled in by the check function
'messages': messages, 'messages': messages,
# a message that applies to the entire response
# instead of a particular input
'overall_message': overall_message,
# any options to be passed to the cfn # any options to be passed to the cfn
'options': self.xml.get('options'), 'options': self.xml.get('options'),
'testdat': 'hello world', 'testdat': 'hello world',
...@@ -1010,6 +1044,7 @@ def sympy_check2(): ...@@ -1010,6 +1044,7 @@ def sympy_check2():
exec self.code in self.context['global_context'], self.context exec self.code in self.context['global_context'], self.context
correct = self.context['correct'] correct = self.context['correct']
messages = self.context['messages'] messages = self.context['messages']
overall_message = self.context['overall_message']
except Exception as err: except Exception as err:
print "oops in customresponse (code) error %s" % err print "oops in customresponse (code) error %s" % err
print "context = ", self.context print "context = ", self.context
...@@ -1044,34 +1079,100 @@ def sympy_check2(): ...@@ -1044,34 +1079,100 @@ def sympy_check2():
log.error(traceback.format_exc()) log.error(traceback.format_exc())
raise Exception("oops in customresponse (cfn) error %s" % err) raise Exception("oops in customresponse (cfn) error %s" % err)
log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret) log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret)
if type(ret) == dict: if type(ret) == dict:
correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset)
msg = ret['msg'] # One kind of dictionary the check function can return has the
# form {'ok': BOOLEAN, 'msg': STRING}
if 1: # If there are multiple inputs, they all get marked
# try to clean up message html # to the same correct/incorrect value
msg = '<html>' + msg + '</html>' if 'ok' in ret:
msg = msg.replace('&#60;', '&lt;') correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset)
#msg = msg.replace('&lt;','<') msg = ret.get('msg', None)
msg = etree.tostring(fromstring_bs(msg, convertEntities=None), msg = self.clean_message_html(msg)
pretty_print=True)
#msg = etree.tostring(fromstring_bs(msg),pretty_print=True) # If there is only one input, apply the message to that input
msg = msg.replace('&#13;', '') # Otherwise, apply the message to the whole problem
#msg = re.sub('<html>(.*)</html>','\\1',msg,flags=re.M|re.DOTALL) # python 2.7 if len(idset) > 1:
msg = re.sub('(?ms)<html>(.*)</html>', '\\1', msg) overall_message = msg
else:
messages[0] = msg messages[0] = msg
# Another kind of dictionary the check function can return has
# the form:
# {'overall_message': STRING,
# 'input_list': [{ 'ok': BOOLEAN, 'msg': STRING }, ...] }
#
# This allows the function to return an 'overall message'
# that applies to the entire problem, as well as correct/incorrect
# status and messages for individual inputs
elif 'input_list' in ret:
overall_message = ret.get('overall_message', '')
input_list = ret['input_list']
correct = []
messages = []
for input_dict in input_list:
correct.append('correct' if input_dict['ok'] else 'incorrect')
msg = self.clean_message_html(input_dict['msg']) if 'msg' in input_dict else None
messages.append(msg)
# Otherwise, we do not recognize the dictionary
# Raise an exception
else:
log.error(traceback.format_exc())
raise Exception("CustomResponse: check function returned an invalid dict")
# The check function can return a boolean value,
# indicating whether all inputs should be marked
# correct or incorrect
else: else:
correct = ['correct'] * len(idset) if ret else ['incorrect'] * len(idset) correct = ['correct'] * len(idset) if ret else ['incorrect'] * len(idset)
# build map giving "correct"ness of the answer(s) # build map giving "correct"ness of the answer(s)
correct_map = CorrectMap() correct_map = CorrectMap()
overall_message = self.clean_message_html(overall_message)
correct_map.set_overall_message(overall_message)
for k in range(len(idset)): for k in range(len(idset)):
npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0 npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0
correct_map.set(idset[k], correct[k], msg=messages[k], correct_map.set(idset[k], correct[k], msg=messages[k],
npoints=npoints) npoints=npoints)
return correct_map return correct_map
def clean_message_html(self, msg):
# If *msg* is an empty string, then the code below
# will return "</html>". To avoid this, we first check
# that *msg* is a non-empty string.
if msg:
# When we parse *msg* using etree, there needs to be a root
# element, so we wrap the *msg* text in <html> tags
msg = '<html>' + msg + '</html>'
# Replace < characters
msg = msg.replace('&#60;', '&lt;')
# Use etree to prettify the HTML
msg = etree.tostring(fromstring_bs(msg, convertEntities=None),
pretty_print=True)
msg = msg.replace('&#13;', '')
# Remove the <html> tags we introduced earlier, so we're
# left with just the prettified message markup
msg = re.sub('(?ms)<html>(.*)</html>', '\\1', msg)
# Strip leading and trailing whitespace
return msg.strip()
# If we start with an empty string, then return an empty string
else:
return ""
def get_answers(self): def get_answers(self):
''' '''
Give correct answer expected for this response. Give correct answer expected for this response.
......
import unittest
from capa.correctmap import CorrectMap
import datetime
class CorrectMapTest(unittest.TestCase):
def setUp(self):
self.cmap = CorrectMap()
def test_set_input_properties(self):
# Set the correctmap properties for two inputs
self.cmap.set(answer_id='1_2_1',
correctness='correct',
npoints=5,
msg='Test message',
hint='Test hint',
hintmode='always',
queuestate={'key':'secretstring',
'time':'20130228100026'})
self.cmap.set(answer_id='2_2_1',
correctness='incorrect',
npoints=None,
msg=None,
hint=None,
hintmode=None,
queuestate=None)
# Assert that each input has the expected properties
self.assertTrue(self.cmap.is_correct('1_2_1'))
self.assertFalse(self.cmap.is_correct('2_2_1'))
self.assertEqual(self.cmap.get_correctness('1_2_1'), 'correct')
self.assertEqual(self.cmap.get_correctness('2_2_1'), 'incorrect')
self.assertEqual(self.cmap.get_npoints('1_2_1'), 5)
self.assertEqual(self.cmap.get_npoints('2_2_1'), 0)
self.assertEqual(self.cmap.get_msg('1_2_1'), 'Test message')
self.assertEqual(self.cmap.get_msg('2_2_1'), None)
self.assertEqual(self.cmap.get_hint('1_2_1'), 'Test hint')
self.assertEqual(self.cmap.get_hint('2_2_1'), None)
self.assertEqual(self.cmap.get_hintmode('1_2_1'), 'always')
self.assertEqual(self.cmap.get_hintmode('2_2_1'), None)
self.assertTrue(self.cmap.is_queued('1_2_1'))
self.assertFalse(self.cmap.is_queued('2_2_1'))
self.assertEqual(self.cmap.get_queuetime_str('1_2_1'), '20130228100026')
self.assertEqual(self.cmap.get_queuetime_str('2_2_1'), None)
self.assertTrue(self.cmap.is_right_queuekey('1_2_1', 'secretstring'))
self.assertFalse(self.cmap.is_right_queuekey('1_2_1', 'invalidstr'))
self.assertFalse(self.cmap.is_right_queuekey('1_2_1', ''))
self.assertFalse(self.cmap.is_right_queuekey('1_2_1', None))
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', 'secretstring'))
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', 'invalidstr'))
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', ''))
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', None))
def test_get_npoints(self):
# Set the correctmap properties for 4 inputs
# 1) correct, 5 points
# 2) correct, None points
# 3) incorrect, 5 points
# 4) incorrect, None points
# 5) correct, 0 points
self.cmap.set(answer_id='1_2_1',
correctness='correct',
npoints=5)
self.cmap.set(answer_id='2_2_1',
correctness='correct',
npoints=None)
self.cmap.set(answer_id='3_2_1',
correctness='incorrect',
npoints=5)
self.cmap.set(answer_id='4_2_1',
correctness='incorrect',
npoints=None)
self.cmap.set(answer_id='5_2_1',
correctness='correct',
npoints=0)
# Assert that we get the expected points
# If points assigned and correct --> npoints
# If no points assigned and correct --> 1 point
# Otherwise --> 0 points
self.assertEqual(self.cmap.get_npoints('1_2_1'), 5)
self.assertEqual(self.cmap.get_npoints('2_2_1'), 1)
self.assertEqual(self.cmap.get_npoints('3_2_1'), 0)
self.assertEqual(self.cmap.get_npoints('4_2_1'), 0)
self.assertEqual(self.cmap.get_npoints('5_2_1'), 0)
def test_set_overall_message(self):
# Default is an empty string string
self.assertEqual(self.cmap.get_overall_message(), "")
# Set a message that applies to the whole question
self.cmap.set_overall_message("Test message")
# Retrieve the message
self.assertEqual(self.cmap.get_overall_message(), "Test message")
# Setting the message to None --> empty string
self.cmap.set_overall_message(None)
self.assertEqual(self.cmap.get_overall_message(), "")
def test_update_from_correctmap(self):
# Initialize a CorrectMap with some properties
self.cmap.set(answer_id='1_2_1',
correctness='correct',
npoints=5,
msg='Test message',
hint='Test hint',
hintmode='always',
queuestate={'key':'secretstring',
'time':'20130228100026'})
self.cmap.set_overall_message("Test message")
# Create a second cmap, then update it to have the same properties
# as the first cmap
other_cmap = CorrectMap()
other_cmap.update(self.cmap)
# Assert that it has all the same properties
self.assertEqual(other_cmap.get_overall_message(),
self.cmap.get_overall_message())
self.assertEqual(other_cmap.get_dict(),
self.cmap.get_dict())
def test_update_from_invalid(self):
# Should get an exception if we try to update() a CorrectMap
# with a non-CorrectMap value
invalid_list = [None, "string", 5, datetime.datetime.today()]
for invalid in invalid_list:
with self.assertRaises(Exception):
self.cmap.update(invalid)
import unittest
from lxml import etree
import os
import textwrap
import json
import mock
from capa.capa_problem import LoncapaProblem
from response_xml_factory import StringResponseXMLFactory, CustomResponseXMLFactory
from . import test_system
class CapaHtmlRenderTest(unittest.TestCase):
def test_include_html(self):
# Create a test file to include
self._create_test_file('test_include.xml',
'<test>Test include</test>')
# Generate some XML with an <include>
xml_str = textwrap.dedent("""
<problem>
<include file="test_include.xml"/>
</problem>
""")
# Create the problem
problem = LoncapaProblem(xml_str, '1', system=test_system)
# Render the HTML
rendered_html = etree.XML(problem.get_html())
# Expect that the include file was embedded in the problem
test_element = rendered_html.find("test")
self.assertEqual(test_element.tag, "test")
self.assertEqual(test_element.text, "Test include")
def test_process_outtext(self):
# Generate some XML with <startouttext /> and <endouttext />
xml_str = textwrap.dedent("""
<problem>
<startouttext/>Test text<endouttext/>
</problem>
""")
# Create the problem
problem = LoncapaProblem(xml_str, '1', system=test_system)
# Render the HTML
rendered_html = etree.XML(problem.get_html())
# Expect that the <startouttext /> and <endouttext />
# were converted to <span></span> tags
span_element = rendered_html.find('span')
self.assertEqual(span_element.text, 'Test text')
def test_render_script(self):
# Generate some XML with a <script> tag
xml_str = textwrap.dedent("""
<problem>
<script>test=True</script>
</problem>
""")
# Create the problem
problem = LoncapaProblem(xml_str, '1', system=test_system)
# Render the HTML
rendered_html = etree.XML(problem.get_html())
# Expect that the script element has been removed from the rendered HTML
script_element = rendered_html.find('script')
self.assertEqual(None, script_element)
def test_render_response_xml(self):
# Generate some XML for a string response
kwargs = {'question_text': "Test question",
'explanation_text': "Test explanation",
'answer': 'Test answer',
'hints': [('test prompt', 'test_hint', 'test hint text')]}
xml_str = StringResponseXMLFactory().build_xml(**kwargs)
# Mock out the template renderer
test_system.render_template = mock.Mock()
test_system.render_template.return_value = "<div>Input Template Render</div>"
# Create the problem and render the HTML
problem = LoncapaProblem(xml_str, '1', system=test_system)
rendered_html = etree.XML(problem.get_html())
# Expect problem has been turned into a <div>
self.assertEqual(rendered_html.tag, "div")
# Expect question text is in a <p> child
question_element = rendered_html.find("p")
self.assertEqual(question_element.text, "Test question")
# Expect that the response has been turned into a <span>
response_element = rendered_html.find("span")
self.assertEqual(response_element.tag, "span")
# Expect that the response <span>
# that contains a <div> for the textline
textline_element = response_element.find("div")
self.assertEqual(textline_element.text, 'Input Template Render')
# Expect a child <div> for the solution
# with the rendered template
solution_element = rendered_html.find("div")
self.assertEqual(solution_element.text, 'Input Template Render')
# Expect that the template renderer was called with the correct
# arguments, once for the textline input and once for
# the solution
expected_textline_context = {'status': 'unsubmitted',
'value': '',
'preprocessor': None,
'msg': '',
'inline': False,
'hidden': False,
'do_math': False,
'id': '1_2_1',
'size': None}
expected_solution_context = {'id': '1_solution_1'}
expected_calls = [mock.call('textline.html', expected_textline_context),
mock.call('solutionspan.html', expected_solution_context)]
self.assertEqual(test_system.render_template.call_args_list,
expected_calls)
def test_render_response_with_overall_msg(self):
# CustomResponse script that sets an overall_message
script=textwrap.dedent("""
def check_func(*args):
msg = '<p>Test message 1<br /></p><p>Test message 2</p>'
return {'overall_message': msg,
'input_list': [ {'ok': True, 'msg': '' } ] }
""")
# Generate some XML for a CustomResponse
kwargs = {'script':script, 'cfn': 'check_func'}
xml_str = CustomResponseXMLFactory().build_xml(**kwargs)
# Create the problem and render the html
problem = LoncapaProblem(xml_str, '1', system=test_system)
# Grade the problem
correctmap = problem.grade_answers({'1_2_1': 'test'})
# Render the html
rendered_html = etree.XML(problem.get_html())
# Expect that there is a <div> within the response <div>
# with css class response_message
msg_div_element = rendered_html.find(".//div[@class='response_message']")
self.assertEqual(msg_div_element.tag, "div")
self.assertEqual(msg_div_element.get('class'), "response_message")
# Expect that the <div> contains our message (as part of the XML tree)
msg_p_elements = msg_div_element.findall('p')
self.assertEqual(msg_p_elements[0].tag, "p")
self.assertEqual(msg_p_elements[0].text, "Test message 1")
self.assertEqual(msg_p_elements[1].tag, "p")
self.assertEqual(msg_p_elements[1].text, "Test message 2")
def test_substitute_python_vars(self):
# Generate some XML with Python variables defined in a script
# and used later as attributes
xml_str = textwrap.dedent("""
<problem>
<script>test="TEST"</script>
<span attr="$test"></span>
</problem>
""")
# Create the problem and render the HTML
problem = LoncapaProblem(xml_str, '1', system=test_system)
rendered_html = etree.XML(problem.get_html())
# Expect that the variable $test has been replaced with its value
span_element = rendered_html.find('span')
self.assertEqual(span_element.get('attr'), "TEST")
def _create_test_file(self, path, content_str):
test_fp = test_system.filestore.open(path, "w")
test_fp.write(content_str)
test_fp.close()
self.addCleanup(lambda: os.remove(test_fp.name))
...@@ -8,6 +8,7 @@ import json ...@@ -8,6 +8,7 @@ import json
from nose.plugins.skip import SkipTest from nose.plugins.skip import SkipTest
import os import os
import unittest import unittest
import textwrap
from . import test_system from . import test_system
...@@ -663,30 +664,43 @@ class CustomResponseTest(ResponseTest): ...@@ -663,30 +664,43 @@ class CustomResponseTest(ResponseTest):
# Inline code can update the global messages list # Inline code can update the global messages list
# to pass messages to the CorrectMap for a particular input # to pass messages to the CorrectMap for a particular input
inline_script = """messages[0] = "Test Message" """ # The code can also set the global overall_message (str)
# to pass a message that applies to the whole response
inline_script = textwrap.dedent("""
messages[0] = "Test Message"
overall_message = "Overall message"
""")
problem = self.build_problem(answer=inline_script) problem = self.build_problem(answer=inline_script)
input_dict = {'1_2_1': '0'} input_dict = {'1_2_1': '0'}
msg = problem.grade_answers(input_dict).get_msg('1_2_1') correctmap = problem.grade_answers(input_dict)
self.assertEqual(msg, "Test Message")
def test_function_code(self): # Check that the message for the particular input was received
input_msg = correctmap.get_msg('1_2_1')
self.assertEqual(input_msg, "Test Message")
# For function code, we pass in three arguments: # Check that the overall message (for the whole response) was received
overall_msg = correctmap.get_overall_message()
self.assertEqual(overall_msg, "Overall message")
def test_function_code_single_input(self):
# For function code, we pass in these arguments:
# #
# 'expect' is the expect attribute of the <customresponse> # 'expect' is the expect attribute of the <customresponse>
# #
# 'answer_given' is the answer the student gave (if there is just one input) # 'answer_given' is the answer the student gave (if there is just one input)
# or an ordered list of answers (if there are multiple inputs) # or an ordered list of answers (if there are multiple inputs)
# #
# 'student_answers' is a dictionary of answers by input ID
#
# #
# The function should return a dict of the form # The function should return a dict of the form
# { 'ok': BOOL, 'msg': STRING } # { 'ok': BOOL, 'msg': STRING }
# #
script = """def check_func(expect, answer_given, student_answers): script = textwrap.dedent("""
return {'ok': answer_given == expect, 'msg': 'Message text'}""" def check_func(expect, answer_given):
return {'ok': answer_given == expect, 'msg': 'Message text'}
""")
problem = self.build_problem(script=script, cfn="check_func", expect="42") problem = self.build_problem(script=script, cfn="check_func", expect="42")
...@@ -698,7 +712,7 @@ class CustomResponseTest(ResponseTest): ...@@ -698,7 +712,7 @@ class CustomResponseTest(ResponseTest):
msg = correct_map.get_msg('1_2_1') msg = correct_map.get_msg('1_2_1')
self.assertEqual(correctness, 'correct') self.assertEqual(correctness, 'correct')
self.assertEqual(msg, "Message text\n") self.assertEqual(msg, "Message text")
# Incorrect answer # Incorrect answer
input_dict = {'1_2_1': '0'} input_dict = {'1_2_1': '0'}
...@@ -708,19 +722,108 @@ class CustomResponseTest(ResponseTest): ...@@ -708,19 +722,108 @@ class CustomResponseTest(ResponseTest):
msg = correct_map.get_msg('1_2_1') msg = correct_map.get_msg('1_2_1')
self.assertEqual(correctness, 'incorrect') self.assertEqual(correctness, 'incorrect')
self.assertEqual(msg, "Message text\n") self.assertEqual(msg, "Message text")
def test_function_code_multiple_input_no_msg(self):
# Check functions also have the option of returning
# a single boolean value
# If true, mark all the inputs correct
# If false, mark all the inputs incorrect
script = textwrap.dedent("""
def check_func(expect, answer_given):
return (answer_given[0] == expect and
answer_given[1] == expect)
""")
problem = self.build_problem(script=script, cfn="check_func",
expect="42", num_inputs=2)
# Correct answer -- expect both inputs marked correct
input_dict = {'1_2_1': '42', '1_2_2': '42'}
correct_map = problem.grade_answers(input_dict)
correctness = correct_map.get_correctness('1_2_1')
self.assertEqual(correctness, 'correct')
correctness = correct_map.get_correctness('1_2_2')
self.assertEqual(correctness, 'correct')
# One answer incorrect -- expect both inputs marked incorrect
input_dict = {'1_2_1': '0', '1_2_2': '42'}
correct_map = problem.grade_answers(input_dict)
correctness = correct_map.get_correctness('1_2_1')
self.assertEqual(correctness, 'incorrect')
correctness = correct_map.get_correctness('1_2_2')
self.assertEqual(correctness, 'incorrect')
def test_function_code_multiple_inputs(self):
# If the <customresponse> has multiple inputs associated with it,
# the check function can return a dict of the form:
#
# {'overall_message': STRING,
# 'input_list': [{'ok': BOOL, 'msg': STRING}, ...] }
#
# 'overall_message' is displayed at the end of the response
#
# 'input_list' contains dictionaries representing the correctness
# and message for each input.
script = textwrap.dedent("""
def check_func(expect, answer_given):
check1 = (int(answer_given[0]) == 1)
check2 = (int(answer_given[1]) == 2)
check3 = (int(answer_given[2]) == 3)
return {'overall_message': 'Overall message',
'input_list': [
{'ok': check1, 'msg': 'Feedback 1'},
{'ok': check2, 'msg': 'Feedback 2'},
{'ok': check3, 'msg': 'Feedback 3'} ] }
""")
problem = self.build_problem(script=script,
cfn="check_func", num_inputs=3)
# Grade the inputs (one input incorrect)
input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3' }
correct_map = problem.grade_answers(input_dict)
# Expect that we receive the overall message (for the whole response)
self.assertEqual(correct_map.get_overall_message(), "Overall message")
# Expect that the inputs were graded individually
self.assertEqual(correct_map.get_correctness('1_2_1'), 'incorrect')
self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct')
self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct')
def test_multiple_inputs(self): # Expect that we received messages for each individual input
self.assertEqual(correct_map.get_msg('1_2_1'), 'Feedback 1')
self.assertEqual(correct_map.get_msg('1_2_2'), 'Feedback 2')
self.assertEqual(correct_map.get_msg('1_2_3'), 'Feedback 3')
def test_multiple_inputs_return_one_status(self):
# When given multiple inputs, the 'answer_given' argument # When given multiple inputs, the 'answer_given' argument
# to the check_func() is a list of inputs # to the check_func() is a list of inputs
#
# The sample script below marks the problem as correct # The sample script below marks the problem as correct
# if and only if it receives answer_given=[1,2,3] # if and only if it receives answer_given=[1,2,3]
# (or string values ['1','2','3']) # (or string values ['1','2','3'])
script = """def check_func(expect, answer_given, student_answers): #
check1 = (int(answer_given[0]) == 1) # Since we return a dict describing the status of one input,
check2 = (int(answer_given[1]) == 2) # we expect that the same 'ok' value is applied to each
check3 = (int(answer_given[2]) == 3) # of the inputs.
return {'ok': (check1 and check2 and check3), 'msg': 'Message text'}""" script = textwrap.dedent("""
def check_func(expect, answer_given):
check1 = (int(answer_given[0]) == 1)
check2 = (int(answer_given[1]) == 2)
check3 = (int(answer_given[2]) == 3)
return {'ok': (check1 and check2 and check3),
'msg': 'Message text'}
""")
problem = self.build_problem(script=script, problem = self.build_problem(script=script,
cfn="check_func", num_inputs=3) cfn="check_func", num_inputs=3)
...@@ -743,6 +846,37 @@ class CustomResponseTest(ResponseTest): ...@@ -743,6 +846,37 @@ class CustomResponseTest(ResponseTest):
self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct') self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct')
self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct') self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct')
# Message is interpreted as an "overall message"
self.assertEqual(correct_map.get_overall_message(), 'Message text')
def test_script_exception(self):
# Construct a script that will raise an exception
script = textwrap.dedent("""
def check_func(expect, answer_given):
raise Exception("Test")
""")
problem = self.build_problem(script=script, cfn="check_func")
# Expect that an exception gets raised when we check the answer
with self.assertRaises(Exception):
problem.grade_answers({'1_2_1': '42'})
def test_invalid_dict_exception(self):
# Construct a script that passes back an invalid dict format
script = textwrap.dedent("""
def check_func(expect, answer_given):
return {'invalid': 'test'}
""")
problem = self.build_problem(script=script, cfn="check_func")
# Expect that an exception gets raised when we check the answer
with self.assertRaises(Exception):
problem.grade_answers({'1_2_1': '42'})
class SchematicResponseTest(ResponseTest): class SchematicResponseTest(ResponseTest):
from response_xml_factory import SchematicResponseXMLFactory from response_xml_factory import SchematicResponseXMLFactory
......
...@@ -35,7 +35,8 @@ class StaticContent(object): ...@@ -35,7 +35,8 @@ class StaticContent(object):
@staticmethod @staticmethod
def compute_location(org, course, name, revision=None, is_thumbnail=False): def compute_location(org, course, name, revision=None, is_thumbnail=False):
name = name.replace('/', '_') name = name.replace('/', '_')
return Location([XASSET_LOCATION_TAG, org, course, 'asset' if not is_thumbnail else 'thumbnail', Location.clean(name), revision]) return Location([XASSET_LOCATION_TAG, org, course, 'asset' if not is_thumbnail else 'thumbnail',
Location.clean_keeping_underscores(name), revision])
def get_id(self): def get_id(self):
return StaticContent.get_id_from_location(self.location) return StaticContent.get_id_from_location(self.location)
......
...@@ -225,7 +225,7 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -225,7 +225,7 @@ class CourseDescriptor(SequenceDescriptor):
# NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically # NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically
# disable the syllabus content for courses that do not provide a syllabus # disable the syllabus content for courses that do not provide a syllabus
self.syllabus_present = self.system.resources_fs.exists(path('syllabus')) self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
self._grading_policy = {}
self.set_grading_policy(self.grading_policy) self.set_grading_policy(self.grading_policy)
self.test_center_exams = [] self.test_center_exams = []
...@@ -295,9 +295,9 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -295,9 +295,9 @@ class CourseDescriptor(SequenceDescriptor):
grading_policy.update(course_policy) grading_policy.update(course_policy)
# Here is where we should parse any configurations, so that we can fail early # Here is where we should parse any configurations, so that we can fail early
grading_policy['RAW_GRADER'] = grading_policy['GRADER'] # used for cms access # Use setters so that side effecting to .definitions works
grading_policy['GRADER'] = grader_from_conf(grading_policy['GRADER']) self.raw_grader = grading_policy['GRADER'] # used for cms access
self._grading_policy = grading_policy self.grade_cutoffs = grading_policy['GRADE_CUTOFFS']
@classmethod @classmethod
def read_grading_policy(cls, paths, system): def read_grading_policy(cls, paths, system):
...@@ -390,7 +390,7 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -390,7 +390,7 @@ class CourseDescriptor(SequenceDescriptor):
@property @property
def grader(self): def grader(self):
return self._grading_policy['GRADER'] return grader_from_conf(self.raw_grader)
@property @property
def raw_grader(self): def raw_grader(self):
......
...@@ -71,6 +71,17 @@ class Location(_LocationBase): ...@@ -71,6 +71,17 @@ class Location(_LocationBase):
""" """
return Location._clean(value, INVALID_CHARS) return Location._clean(value, INVALID_CHARS)
@staticmethod
def clean_keeping_underscores(value):
"""
Return value, replacing INVALID_CHARS, but not collapsing multiple '_' chars.
This for cleaning asset names, as the YouTube ID's may have underscores in them, and we need the
transcript asset name to match. In the future we may want to change the behavior of _clean.
"""
return INVALID_CHARS.sub('_', value)
@staticmethod @staticmethod
def clean_for_url_name(value): def clean_for_url_name(value):
""" """
......
...@@ -139,7 +139,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem): ...@@ -139,7 +139,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
location = Location(location) location = Location(location)
json_data = self.module_data.get(location) json_data = self.module_data.get(location)
if json_data is None: if json_data is None:
return self.modulestore.get_item(location) module = self.modulestore.get_item(location)
if module is not None:
# update our own cache after going to the DB to get cache miss
self.module_data.update(module.system.module_data)
return module
else: else:
# load the module and apply the inherited metadata # load the module and apply the inherited metadata
try: try:
......
...@@ -77,6 +77,9 @@ class CombinedOpenEndedV1Module(): ...@@ -77,6 +77,9 @@ class CombinedOpenEndedV1Module():
INTERMEDIATE_DONE = 'intermediate_done' INTERMEDIATE_DONE = 'intermediate_done'
DONE = 'done' DONE = 'done'
#Where the templates live for this problem
TEMPLATE_DIR = "combinedopenended"
def __init__(self, system, location, definition, descriptor, def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, metadata = None, static_data = None, **kwargs): instance_state=None, shared_state=None, metadata = None, static_data = None, **kwargs):
...@@ -334,7 +337,7 @@ class CombinedOpenEndedV1Module(): ...@@ -334,7 +337,7 @@ class CombinedOpenEndedV1Module():
Output: rendered html Output: rendered html
""" """
context = self.get_context() context = self.get_context()
html = self.system.render_template('combined_open_ended.html', context) html = self.system.render_template('{0}/combined_open_ended.html'.format(self.TEMPLATE_DIR), context)
return html return html
def get_html_nonsystem(self): def get_html_nonsystem(self):
...@@ -345,7 +348,7 @@ class CombinedOpenEndedV1Module(): ...@@ -345,7 +348,7 @@ class CombinedOpenEndedV1Module():
Output: HTML rendered directly via Mako Output: HTML rendered directly via Mako
""" """
context = self.get_context() context = self.get_context()
html = self.system.render_template('combined_open_ended.html', context) html = self.system.render_template('{0}/combined_open_ended.html'.format(self.TEMPLATE_DIR), context)
return html return html
def get_html_base(self): def get_html_base(self):
...@@ -522,7 +525,7 @@ class CombinedOpenEndedV1Module(): ...@@ -522,7 +525,7 @@ class CombinedOpenEndedV1Module():
'task_name' : 'Scored Rubric', 'task_name' : 'Scored Rubric',
'class_name' : 'combined-rubric-container' 'class_name' : 'combined-rubric-container'
} }
html = self.system.render_template('combined_open_ended_results.html', context) html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context)
return {'html': html, 'success': True} return {'html': html, 'success': True}
def get_legend(self, get): def get_legend(self, get):
...@@ -534,7 +537,7 @@ class CombinedOpenEndedV1Module(): ...@@ -534,7 +537,7 @@ class CombinedOpenEndedV1Module():
context = { context = {
'legend_list' : LEGEND_LIST, 'legend_list' : LEGEND_LIST,
} }
html = self.system.render_template('combined_open_ended_legend.html', context) html = self.system.render_template('{0}/combined_open_ended_legend.html'.format(self.TEMPLATE_DIR), context)
return {'html': html, 'success': True} return {'html': html, 'success': True}
def get_results(self, get): def get_results(self, get):
...@@ -565,7 +568,7 @@ class CombinedOpenEndedV1Module(): ...@@ -565,7 +568,7 @@ class CombinedOpenEndedV1Module():
'submission_id' : ri['submission_ids'][i], 'submission_id' : ri['submission_ids'][i],
} }
context_list.append(context) context_list.append(context)
feedback_table = self.system.render_template('open_ended_result_table.html', { feedback_table = self.system.render_template('{0}/open_ended_result_table.html'.format(self.TEMPLATE_DIR), {
'context_list' : context_list, 'context_list' : context_list,
'grader_type_image_dict' : GRADER_TYPE_IMAGE_DICT, 'grader_type_image_dict' : GRADER_TYPE_IMAGE_DICT,
'human_grader_types' : HUMAN_GRADER_TYPE, 'human_grader_types' : HUMAN_GRADER_TYPE,
...@@ -577,7 +580,7 @@ class CombinedOpenEndedV1Module(): ...@@ -577,7 +580,7 @@ class CombinedOpenEndedV1Module():
'task_name' : "Feedback", 'task_name' : "Feedback",
'class_name' : "result-container", 'class_name' : "result-container",
} }
html = self.system.render_template('combined_open_ended_results.html', context) html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context)
return {'html': html, 'success': True} return {'html': html, 'success': True}
def get_status_ajax(self, get): def get_status_ajax(self, get):
...@@ -691,7 +694,7 @@ class CombinedOpenEndedV1Module(): ...@@ -691,7 +694,7 @@ class CombinedOpenEndedV1Module():
'legend_list' : LEGEND_LIST, 'legend_list' : LEGEND_LIST,
'render_via_ajax' : render_via_ajax, 'render_via_ajax' : render_via_ajax,
} }
status_html = self.system.render_template("combined_open_ended_status.html", context) status_html = self.system.render_template("{0}/combined_open_ended_status.html".format(self.TEMPLATE_DIR), context)
return status_html return status_html
......
...@@ -30,6 +30,8 @@ class RubricParsingError(Exception): ...@@ -30,6 +30,8 @@ class RubricParsingError(Exception):
class CombinedOpenEndedRubric(object): class CombinedOpenEndedRubric(object):
TEMPLATE_DIR = "combinedopenended/openended"
def __init__ (self, system, view_only = False): def __init__ (self, system, view_only = False):
self.has_score = False self.has_score = False
self.view_only = view_only self.view_only = view_only
...@@ -57,9 +59,9 @@ class CombinedOpenEndedRubric(object): ...@@ -57,9 +59,9 @@ class CombinedOpenEndedRubric(object):
rubric_scores = [cat['score'] for cat in rubric_categories] rubric_scores = [cat['score'] for cat in rubric_categories]
max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories) max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories)
max_score = max(max_scores) max_score = max(max_scores)
rubric_template = 'open_ended_rubric.html' rubric_template = '{0}/open_ended_rubric.html'.format(self.TEMPLATE_DIR)
if self.view_only: if self.view_only:
rubric_template = 'open_ended_view_only_rubric.html' rubric_template = '{0}/open_ended_view_only_rubric.html'.format(self.TEMPLATE_DIR)
html = self.system.render_template(rubric_template, html = self.system.render_template(rubric_template,
{'categories': rubric_categories, {'categories': rubric_categories,
'has_score': self.has_score, 'has_score': self.has_score,
...@@ -207,7 +209,7 @@ class CombinedOpenEndedRubric(object): ...@@ -207,7 +209,7 @@ class CombinedOpenEndedRubric(object):
for grader_type in tuple[3]: for grader_type in tuple[3]:
rubric_categories[i]['options'][j]['grader_types'].append(grader_type) rubric_categories[i]['options'][j]['grader_types'].append(grader_type)
html = self.system.render_template('open_ended_combined_rubric.html', html = self.system.render_template('{0}/open_ended_combined_rubric.html'.format(self.TEMPLATE_DIR),
{'categories': rubric_categories, {'categories': rubric_categories,
'has_score': True, 'has_score': True,
'view_only': True, 'view_only': True,
......
...@@ -40,6 +40,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -40,6 +40,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
</openended> </openended>
""" """
TEMPLATE_DIR = "combinedopenended/openended"
def setup_response(self, system, location, definition, descriptor): def setup_response(self, system, location, definition, descriptor):
""" """
Sets up the response type. Sets up the response type.
...@@ -397,10 +399,10 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -397,10 +399,10 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
rubric_scores = rubric_dict['rubric_scores'] rubric_scores = rubric_dict['rubric_scores']
if not response_items['success']: if not response_items['success']:
return system.render_template("open_ended_error.html", return system.render_template("{0}/open_ended_error.html".format(self.TEMPLATE_DIR),
{'errors': feedback}) {'errors': feedback})
feedback_template = system.render_template("open_ended_feedback.html", { feedback_template = system.render_template("{0}/open_ended_feedback.html".format(self.TEMPLATE_DIR), {
'grader_type': response_items['grader_type'], 'grader_type': response_items['grader_type'],
'score': "{0} / {1}".format(response_items['score'], self.max_score()), 'score': "{0} / {1}".format(response_items['score'], self.max_score()),
'feedback': feedback, 'feedback': feedback,
...@@ -558,7 +560,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -558,7 +560,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
@return: Rendered html @return: Rendered html
""" """
context = {'msg': feedback, 'id': "1", 'rows': 50, 'cols': 50} context = {'msg': feedback, 'id': "1", 'rows': 50, 'cols': 50}
html = system.render_template('open_ended_evaluation.html', context) html = system.render_template('{0}/open_ended_evaluation.html'.format(self.TEMPLATE_DIR), context)
return html return html
def handle_ajax(self, dispatch, get, system): def handle_ajax(self, dispatch, get, system):
...@@ -692,7 +694,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -692,7 +694,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
'accept_file_upload': self.accept_file_upload, 'accept_file_upload': self.accept_file_upload,
'eta_message' : eta_string, 'eta_message' : eta_string,
} }
html = system.render_template('open_ended.html', context) html = system.render_template('{0}/open_ended.html'.format(self.TEMPLATE_DIR), context)
return html return html
......
...@@ -3,11 +3,8 @@ import logging ...@@ -3,11 +3,8 @@ import logging
from lxml import etree from lxml import etree
from xmodule.capa_module import ComplexEncoder from xmodule.capa_module import ComplexEncoder
from xmodule.editing_module import EditingDescriptor
from xmodule.progress import Progress from xmodule.progress import Progress
from xmodule.stringify import stringify_children from xmodule.stringify import stringify_children
from xmodule.xml_module import XmlDescriptor
from xblock.core import List, Integer, String, Scope
import openendedchild import openendedchild
from combined_open_ended_rubric import CombinedOpenEndedRubric from combined_open_ended_rubric import CombinedOpenEndedRubric
...@@ -33,27 +30,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -33,27 +30,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
</selfassessment> </selfassessment>
""" """
# states TEMPLATE_DIR = "combinedopenended/selfassessment"
INITIAL = 'initial'
ASSESSING = 'assessing'
REQUEST_HINT = 'request_hint'
DONE = 'done'
student_answers = List(scope=Scope.student_state, default=[])
scores = List(scope=Scope.student_state, default=[])
hints = List(scope=Scope.student_state, default=[])
state = String(scope=Scope.student_state, default=INITIAL)
# Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect).
max_score = Integer(scope=Scope.settings, default=openendedchild.MAX_SCORE)
max_attempts = Integer(scope=Scope.settings, default=openendedchild.MAX_ATTEMPTS)
attempts = Integer(scope=Scope.student_state, default=0)
rubric = String(scope=Scope.content)
prompt = String(scope=Scope.content)
submitmessage = String(scope=Scope.content)
hintprompt = String(scope=Scope.content)
def setup_response(self, system, location, definition, descriptor): def setup_response(self, system, location, definition, descriptor):
""" """
...@@ -91,7 +68,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -91,7 +68,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
'accept_file_upload': self.accept_file_upload, 'accept_file_upload': self.accept_file_upload,
} }
html = system.render_template('self_assessment_prompt.html', context) html = system.render_template('{0}/self_assessment_prompt.html'.format(self.TEMPLATE_DIR), context)
return html return html
...@@ -152,7 +129,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -152,7 +129,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
#This is a dev_facing_error #This is a dev_facing_error
raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.state)) raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.state))
return system.render_template('self_assessment_rubric.html', context) return system.render_template('{0}/self_assessment_rubric.html'.format(self.TEMPLATE_DIR), context)
def get_hint_html(self, system): def get_hint_html(self, system):
""" """
...@@ -178,7 +155,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -178,7 +155,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
#This is a dev_facing_error #This is a dev_facing_error
raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.state)) raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.state))
return system.render_template('self_assessment_hint.html', context) return system.render_template('{0}/self_assessment_hint.html'.format(self.TEMPLATE_DIR), context)
def save_answer(self, get, system): def save_answer(self, get, system):
......
...@@ -460,6 +460,9 @@ class PeerGradingModule(XModule): ...@@ -460,6 +460,9 @@ class PeerGradingModule(XModule):
#This is a student_facing_error #This is a student_facing_error
error_text = "Could not get list of problems to peer grade. Please notify course staff." error_text = "Could not get list of problems to peer grade. Please notify course staff."
success = False success = False
except:
log.exception("Could not contact peer grading service.")
success = False
def _find_corresponding_module_for_location(location): def _find_corresponding_module_for_location(location):
...@@ -562,3 +565,40 @@ class PeerGradingDescriptor(RawDescriptor): ...@@ -562,3 +565,40 @@ class PeerGradingDescriptor(RawDescriptor):
stores_state = True stores_state = True
has_score = True has_score = True
template_dir_name = "peer_grading" template_dir_name = "peer_grading"
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
js_module_name = "HTMLEditingDescriptor"
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
Pull out the individual tasks, the rubric, and the prompt, and parse
Returns:
{
'rubric': 'some-html',
'prompt': 'some-html',
'task_xml': dictionary of xml strings,
}
"""
expected_children = []
for child in expected_children:
if len(xml_object.xpath(child)) == 0:
#This is a staff_facing_error
raise ValueError("Peer grading definition must include at least one '{0}' tag. Contact the learning sciences group for assistance.".format(child))
def parse_task(k):
"""Assumes that xml_object has child k"""
return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))]
def parse(k):
"""Assumes that xml_object has child k"""
return xml_object.xpath(k)[0]
return {}, []
def definition_to_xml(self, resource_fs):
'''Return an xml element representing this definition.'''
elt = etree.Element('peergrading')
return elt
---
metadata:
display_name: Open Ended Response
max_attempts: 1
max_score: 1
is_graded: False
version: 1
display_name: Open Ended Response
skip_spelling_checks: False
accept_file_upload: False
data: |
<combinedopenended>
<rubric>
<rubric>
<category>
<description>Category 1</description>
<option>
The response does not incorporate what is needed for a one response.
</option>
<option>
The response is correct for category 1.
</option>
</category>
</rubric>
</rubric>
<prompt>
<p>Why is the sky blue?</p>
</prompt>
<task>
<selfassessment/>
</task>
<task>
<openended min_score_to_attempt="1" max_score_to_attempt="2">
<openendedparam>
<initial_display>Enter essay here.</initial_display>
<answer_display>This is the answer.</answer_display>
<grader_payload>{"grader_settings" : "peer_grading.conf", "problem_id" : "700x/Demo"}</grader_payload>
</openendedparam>
</openended>
</task>
</combinedopenended>
children: []
---
metadata:
display_name: Peer Grading Interface
attempts: 1
use_for_single_location: False
link_to_location: None
is_graded: False
max_grade: 1
data: |
<peergrading>
</peergrading>
children: []
...@@ -19,9 +19,14 @@ class ContentTest(unittest.TestCase): ...@@ -19,9 +19,14 @@ class ContentTest(unittest.TestCase):
content = StaticContent('loc', 'name', 'content_type', 'data') content = StaticContent('loc', 'name', 'content_type', 'data')
self.assertIsNone(content.thumbnail_location) self.assertIsNone(content.thumbnail_location)
def test_generate_thumbnail_nonimage(self): def test_generate_thumbnail_image(self):
contentStore = ContentStore() contentStore = ContentStore()
content = Content(Location(u'c4x', u'mitX', u'800', u'asset', u'monsters.jpg'), None) content = Content(Location(u'c4x', u'mitX', u'800', u'asset', u'monsters__.jpg'), None)
(thumbnail_content, thumbnail_file_location) = contentStore.generate_thumbnail(content) (thumbnail_content, thumbnail_file_location) = contentStore.generate_thumbnail(content)
self.assertIsNone(thumbnail_content) self.assertIsNone(thumbnail_content)
self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters.jpg'), thumbnail_file_location) self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters__.jpg'), thumbnail_file_location)
def test_compute_location(self):
# We had a bug that __ got converted into a single _. Make sure that substitution of INVALID_CHARS (like space)
# still happen.
asset_location = StaticContent.compute_location('mitX', '400', 'subs__1eo_jXvZnE .srt.sjson')
self.assertEqual(Location(u'c4x', u'mitX', u'400', u'asset', u'subs__1eo_jXvZnE_.srt.sjson', None), asset_location)
...@@ -357,6 +357,8 @@ Supported fields at the course level ...@@ -357,6 +357,8 @@ Supported fields at the course level
* `cohorted_discussions`: list of discussions that should be cohorted. Any not specified in this list are not cohorted. * `cohorted_discussions`: list of discussions that should be cohorted. Any not specified in this list are not cohorted.
* `auto_cohort`: Truthy. * `auto_cohort`: Truthy.
* `auto_cohort_groups`: `["group name 1", "group name 2", ...]` If `cohorted` and `auto_cohort` is true, automatically put each student into a random group from the `auto_cohort_groups` list, creating the group if needed. * `auto_cohort_groups`: `["group name 1", "group name 2", ...]` If `cohorted` and `auto_cohort` is true, automatically put each student into a random group from the `auto_cohort_groups` list, creating the group if needed.
* - `pdf_textbooks`
- have pdf-based textbooks on tabs in the courseware. See below for details on config.
Available metadata Available metadata
...@@ -508,13 +510,15 @@ If you want to customize the courseware tabs displayed for your course, specify ...@@ -508,13 +510,15 @@ If you want to customize the courseware tabs displayed for your course, specify
"url_slug": "news", "url_slug": "news",
"name": "Exciting news" "name": "Exciting news"
}, },
{"type": "textbooks"} {"type": "textbooks"},
{"type": "pdf_textbooks"}
] ]
* If you specify any tabs, you must specify all tabs. They will appear in the order given. * If you specify any tabs, you must specify all tabs. They will appear in the order given.
* The first two tabs must have types `"courseware"` and `"course_info"`, in that order, or the course will not load. * The first two tabs must have types `"courseware"` and `"course_info"`, in that order, or the course will not load.
* The `courseware` tab never has a name attribute -- it's always rendered as "Courseware" for consistency between courses. * The `courseware` tab never has a name attribute -- it's always rendered as "Courseware" for consistency between courses.
* The `textbooks` tab will actually generate one tab per textbook, using the textbook titles as names. * The `textbooks` tab will actually generate one tab per textbook, using the textbook titles as names.
* The `pdf_textbooks` tab will actually generate one tab per pdf_textbook. The tab name is found in the pdf textbook definition.
* For static tabs, the `url_slug` will be the url that points to the tab. It can not be one of the existing courseware url types (even if those aren't used in your course). The static content will come from `tabs/{course_url_name}/{url_slug}.html`, or `tabs/{url_slug}.html` if that doesn't exist. * For static tabs, the `url_slug` will be the url that points to the tab. It can not be one of the existing courseware url types (even if those aren't used in your course). The static content will come from `tabs/{course_url_name}/{url_slug}.html`, or `tabs/{url_slug}.html` if that doesn't exist.
* An Instructor tab will be automatically added at the end for course staff users. * An Instructor tab will be automatically added at the end for course staff users.
...@@ -527,13 +531,15 @@ If you want to customize the courseware tabs displayed for your course, specify ...@@ -527,13 +531,15 @@ If you want to customize the courseware tabs displayed for your course, specify
* - `course_info` * - `course_info`
- Parameter `name`. - Parameter `name`.
* - `wiki` * - `wiki`
- arameter `name`. - Parameter `name`.
* - `discussion` * - `discussion`
- Parameter `name`. - Parameter `name`.
* - `external_link` * - `external_link`
- Parameters `name`, `link`. - Parameters `name`, `link`.
* - `textbooks` * - `textbooks`
- No parameters--generates tab names from book titles. - No parameters--generates tab names from book titles.
* - `pdf_textbooks`
- No parameters--generates tab names from pdf book definition. (See discussion below for configuration.)
* - `progress` * - `progress`
- Parameter `name`. - Parameter `name`.
* - `static_tab` * - `static_tab`
...@@ -541,6 +547,39 @@ If you want to customize the courseware tabs displayed for your course, specify ...@@ -541,6 +547,39 @@ If you want to customize the courseware tabs displayed for your course, specify
* - `staff_grading` * - `staff_grading`
- No parameters. If specified, displays the staff grading tab for instructors. - No parameters. If specified, displays the staff grading tab for instructors.
*********
Textbooks
*********
Support is currently provided for image-based and PDF-based textbooks.
Image-based Textbooks
^^^^^^^^^^^^^^^^^^^^^
TBD.
PDF-based Textbooks
^^^^^^^^^^^^^^^^^^^
PDF-based textbooks are configured at the course level in the policy file. The JSON markup consists of an array of maps, with each map corresponding to a separate textbook. There are two styles to presenting PDF-based material. The first way is as a single PDF on a tab, which requires only a tab title and a URL for configuration. A second way permits the display of multiple PDFs that should be displayed together on a single view. For this view, a side panel of links is available on the left, allowing selection of a particular PDF to view.
.. code-block:: json
"pdf_textbooks": [
{"tab_title": "Textbook 1",
"url": "https://www.example.com/book1.pdf" },
{"tab_title": "Textbook 2",
"chapters": [
{ "title": "Chapter 1", "url": "https://www.example.com/Chapter1.pdf" },
{ "title": "Chapter 2", "url": "https://www.example.com/Chapter2.pdf" },
{ "title": "Chapter 3", "url": "https://www.example.com/Chapter3.pdf" },
{ "title": "Chapter 4", "url": "https://www.example.com/Chapter4.pdf" },
{ "title": "Chapter 5", "url": "https://www.example.com/Chapter5.pdf" },
{ "title": "Chapter 6", "url": "https://www.example.com/Chapter6.pdf" },
{ "title": "Chapter 7", "url": "https://www.example.com/Chapter7.pdf" }
]
}
]
************************************* *************************************
Other file locations (info and about) Other file locations (info and about)
************************************* *************************************
......
####################################
CustomResponse XML and Python Script
####################################
This document explains how to write a CustomResponse problem. CustomResponse
problems execute Python script to check student answers and provide hints.
There are two general ways to create a CustomResponse problem:
*****************
Answer tag format
*****************
One format puts the Python code in an ``<answer>`` tag:
.. code-block:: xml
<problem>
<p>What is the sum of 2 and 3?</p>
<customresponse expect="5">
<textline math="1" />
</customresponse>
<answer>
# Python script goes here
</answer>
</problem>
The Python script interacts with these variables in the global context:
* ``answers``: An ordered list of answers the student provided.
For example, if the student answered ``6``, then ``answers[0]`` would
equal ``6``.
* ``expect``: The value of the ``expect`` attribute of ``<customresponse>``
(if provided).
* ``correct``: An ordered list of strings indicating whether the
student answered the question correctly. Valid values are
``"correct"``, ``"incorrect"``, and ``"unknown"``. You can set these
values in the script.
* ``messages``: An ordered list of message strings that will be displayed
beneath each input. You can use this to provide hints to users.
For example ``messages[0] = "The capital of California is Sacramento"``
would display that message beneath the first input of the response.
* ``overall_message``: A string that will be displayed beneath the
entire problem. You can use this to provide a hint that applies
to the entire problem rather than a particular input.
Example of a checking script:
.. code-block:: python
if answers[0] == expect:
correct[0] = 'correct'
overall_message = 'Good job!'
else:
correct[0] = 'incorrect'
messages[0] = 'This answer is incorrect'
overall_message = 'Please try again'
**Important**: Python is picky about indentation. Within the ``<answer>`` tag,
you must begin your script with no indentation.
*****************
Script tag format
*****************
The other way to create a CustomResponse is to put a "checking function"
in a ``<script>`` tag, then use the ``cfn`` attribute of the
``<customresponse>`` tag:
.. code-block:: xml
<problem>
<p>What is the sum of 2 and 3?</p>
<customresponse cfn="check_func" expect="5">
<textline math="1" />
</customresponse>
<script type="loncapa/python">
def check_func(expect, ans):
# Python script goes here
</script>
</problem>
**Important**: Python is picky about indentation. Within the ``<script>`` tag,
the ``def check_func(expect, ans):`` line must have no indentation.
The check function accepts two arguments:
* ``expect`` is the value of the ``expect`` attribute of ``<customresponse>``
(if provided)
* ``answer`` is either:
* The value of the answer the student provided, if there is only one input.
* An ordered list of answers the student provided, if there
are multiple inputs.
There are several ways that the check function can indicate whether the student
succeeded. The check function can return any of the following:
* ``True``: Indicates that the student answered correctly for all inputs.
* ``False``: Indicates that the student answered incorrectly.
All inputs will be marked incorrect.
* A dictionary of the form: ``{ 'ok': True, 'msg': 'Message' }``
If the dictionary's value for ``ok`` is set to ``True``, all inputs are
marked correct; if it is set to ``False``, all inputs are marked incorrect.
The ``msg`` is displayed beneath all inputs, and it may contain
XHTML markup.
* A dictionary of the form
.. code-block:: xml
{ 'overall_message': 'Overall message',
'input_list': [
{ 'ok': True, 'msg': 'Feedback for input 1'},
{ 'ok': False, 'msg': 'Feedback for input 2'},
... ] }
The last form is useful for responses that contain multiple inputs.
It allows you to provide feedback for each input individually,
as well as a message that applies to the entire response.
Example of a checking function:
.. code-block:: python
def check_func(expect, answer_given):
check1 = (int(answer_given[0]) == 1)
check2 = (int(answer_given[1]) == 2)
check3 = (int(answer_given[2]) == 3)
return {'overall_message': 'Overall message',
'input_list': [
{ 'ok': check1, 'msg': 'Feedback 1'},
{ 'ok': check2, 'msg': 'Feedback 2'},
{ 'ok': check3, 'msg': 'Feedback 3'} ] }
The function checks that the user entered ``1`` for the first input,
``2`` for the second input, and ``3`` for the third input.
It provides feedback messages for each individual input, as well
as a message displayed beneath the entire problem.
...@@ -24,6 +24,7 @@ Specific Problem Types ...@@ -24,6 +24,7 @@ Specific Problem Types
course_data_formats/drag_and_drop/drag_and_drop_input.rst course_data_formats/drag_and_drop/drag_and_drop_input.rst
course_data_formats/graphical_slider_tool/graphical_slider_tool.rst course_data_formats/graphical_slider_tool/graphical_slider_tool.rst
course_data_formats/custom_response.rst
Internal Data Formats Internal Data Formats
......
import unittest import unittest
import logging
import time import time
from mock import Mock from mock import Mock, MagicMock, patch
from django.conf import settings
from django.test import TestCase from django.test import TestCase
from xmodule.course_module import CourseDescriptor
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore import Location from xmodule.modulestore import Location
from factories import CourseEnrollmentAllowedFactory from xmodule.timeparse import parse_time
from xmodule.x_module import XModule, XModuleDescriptor
import courseware.access as access import courseware.access as access
from factories import CourseEnrollmentAllowedFactory
class AccessTestCase(TestCase): class AccessTestCase(TestCase):
......
import logging
from mock import MagicMock, patch
import json
import factory
import unittest
from nose.tools import set_trace
from django.http import Http404, HttpResponse, HttpRequest
from django.conf import settings
from django.contrib.auth.models import User
from django.test.client import Client
from django.conf import settings
from django.test import TestCase
from django.test.client import RequestFactory
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.exceptions import NotFoundError
from xmodule.modulestore import Location
import courseware.module_render as render
from xmodule.modulestore.django import modulestore, _MODULESTORES
from xmodule.seq_module import SequenceModule
from courseware.tests.tests import PageLoader
from student.models import Registration
from courseware.model_data import ModelDataCache
from factories import UserFactory
class Stub:
def __init__(self):
pass
def xml_store_config(data_dir):
return {
'default': {
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
'OPTIONS': {
'data_dir': data_dir,
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
}
}
}
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class ModuleRenderTestCase(PageLoader):
def setUp(self):
self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview']
self._MODULESTORES = {}
self.course_id = 'edX/toy/2012_Fall'
self.toy_course = modulestore().get_course(self.course_id)
def test_get_module(self):
self.assertIsNone(render.get_module('dummyuser', None,
'invalid location', None, None))
def test_modx_dispatch(self):
self.assertRaises(Http404, render.modx_dispatch, 'dummy', 'dummy',
'invalid Location', 'dummy')
mock_request = MagicMock()
mock_request.FILES.keys.return_value = ['file_id']
mock_request.FILES.getlist.return_value = ['file'] * (settings.MAX_FILEUPLOADS_PER_INPUT + 1)
self.assertEquals(render.modx_dispatch(mock_request, 'dummy', self.location,
'dummy').content,
json.dumps({'success': 'Submission aborted! Maximum %d files may be submitted at once' %
settings.MAX_FILEUPLOADS_PER_INPUT}))
mock_request_2 = MagicMock()
mock_request_2.FILES.keys.return_value = ['file_id']
inputfile = Stub()
inputfile.size = 1 + settings.STUDENT_FILEUPLOAD_MAX_SIZE
inputfile.name = 'name'
filelist = [inputfile]
mock_request_2.FILES.getlist.return_value = filelist
self.assertEquals(render.modx_dispatch(mock_request_2, 'dummy', self.location,
'dummy').content,
json.dumps({'success': 'Submission aborted! Your file "%s" is too large (max size: %d MB)' %
(inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))}))
mock_request_3 = MagicMock()
mock_request_3.POST.copy.return_value = {}
mock_request_3.FILES = False
mock_request_3.user = UserFactory()
inputfile_2 = Stub()
inputfile_2.size = 1
inputfile_2.name = 'name'
self.assertRaises(ItemNotFoundError, render.modx_dispatch,
mock_request_3, 'dummy', self.location, 'toy')
self.assertRaises(Http404, render.modx_dispatch, mock_request_3, 'dummy',
self.location, self.course_id)
mock_request_3.POST.copy.return_value = {'position': 1}
self.assertIsInstance(render.modx_dispatch(mock_request_3, 'goto_position',
self.location, self.course_id), HttpResponse)
def test_get_score_bucket(self):
self.assertEquals(render.get_score_bucket(0, 10), 'incorrect')
self.assertEquals(render.get_score_bucket(1, 10), 'partial')
self.assertEquals(render.get_score_bucket(10, 10), 'correct')
# get_score_bucket calls error cases 'incorrect'
self.assertEquals(render.get_score_bucket(11, 10), 'incorrect')
self.assertEquals(render.get_score_bucket(-1, 10), 'incorrect')
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestTOC(TestCase):
"""Check the Table of Contents for a course"""
def setUp(self):
self._MODULESTORES = {}
# Toy courses should be loaded
self.course_name = 'edX/toy/2012_Fall'
self.toy_course = modulestore().get_course(self.course_name)
self.portal_user = UserFactory()
def test_toc_toy_from_chapter(self):
chapter = 'Overview'
chapter_url = '%s/%s/%s' % ('/courses', self.course_name, chapter)
factory = RequestFactory()
request = factory.get(chapter_url)
model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
self.toy_course.id, self.portal_user, self.toy_course, depth=2)
expected = ([{'active': True, 'sections':
[{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True,
'format': u'Lecture Sequence', 'due': '', 'active': False},
{'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True,
'format': '', 'due': '', 'active': False},
{'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True,
'format': '', 'due': '', 'active': False},
{'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True,
'format': '', 'due': '', 'active': False}],
'url_name': 'Overview', 'display_name': u'Overview'},
{'active': False, 'sections':
[{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True,
'format': '', 'due': '', 'active': False}],
'url_name': 'secret:magic', 'display_name': 'secret:magic'}])
actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, None, model_data_cache)
self.assertEqual(expected, actual)
def test_toc_toy_from_section(self):
chapter = 'Overview'
chapter_url = '%s/%s/%s' % ('/courses', self.course_name, chapter)
section = 'Welcome'
factory = RequestFactory()
request = factory.get(chapter_url)
model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
self.toy_course.id, self.portal_user, self.toy_course, depth=2)
expected = ([{'active': True, 'sections':
[{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True,
'format': u'Lecture Sequence', 'due': '', 'active': False},
{'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True,
'format': '', 'due': '', 'active': True},
{'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True,
'format': '', 'due': '', 'active': False},
{'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True,
'format': '', 'due': '', 'active': False}],
'url_name': 'Overview', 'display_name': u'Overview'},
{'active': False, 'sections':
[{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True,
'format': '', 'due': '', 'active': False}],
'url_name': 'secret:magic', 'display_name': 'secret:magic'}])
actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, section, model_data_cache)
self.assertEqual(expected, actual)
from django.test import TestCase
from courseware import progress
from mock import MagicMock
class ProgessTests(TestCase):
def setUp(self):
self.d = dict({'duration_total': 0,
'duration_watched': 0,
'done': True,
'questions_correct': 4,
'questions_incorrect': 0,
'questions_total': 0})
self.c = progress.completion()
self.c2 = progress.completion()
self.c2.dict = dict({'duration_total': 0,
'duration_watched': 0,
'done': True,
'questions_correct': 2,
'questions_incorrect': 1,
'questions_total': 0})
self.cplusc2 = dict({'duration_total': 0,
'duration_watched': 0,
'done': True,
'questions_correct': 2,
'questions_incorrect': 1,
'questions_total': 0})
self.oth = dict({'duration_total': 0,
'duration_watched': 0,
'done': True,
'questions_correct': 4,
'questions_incorrect': 0,
'questions_total': 7})
self.x = MagicMock()
self.x.dict = self.oth
self.d_oth = {'duration_total': 0,
'duration_watched': 0,
'done': True,
'questions_correct': 4,
'questions_incorrect': 0,
'questions_total': 7}
def test_getitem(self):
self.assertEqual(self.c.__getitem__('duration_watched'), 0)
def test_setitem(self):
self.c.__setitem__('questions_correct', 4)
self.assertEqual(str(self.c), str(self.d))
def test_repr(self):
self.assertEqual(self.c.__repr__(), str(progress.completion()))
import logging
from mock import MagicMock, patch
import datetime
import factory
import unittest
import os
from django.test import TestCase
from django.http import Http404, HttpResponse
from django.conf import settings
from django.test.utils import override_settings
from django.contrib.auth.models import User
from django.test.client import RequestFactory
from student.models import CourseEnrollment
from xmodule.modulestore.django import modulestore, _MODULESTORES
from xmodule.modulestore.exceptions import InvalidLocationError,\
ItemNotFoundError, NoPathToItem
import courseware.views as views
from xmodule.modulestore import Location
from factories import UserFactory
class Stub():
pass
# This part is required for modulestore() to work properly
def xml_store_config(data_dir):
return {
'default': {
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
'OPTIONS': {
'data_dir': data_dir,
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
}
}
}
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestJumpTo(TestCase):
"""Check the jumpto link for a course"""
def setUp(self):
self._MODULESTORES = {}
# Toy courses should be loaded
self.course_name = 'edX/toy/2012_Fall'
self.toy_course = modulestore().get_course('edX/toy/2012_Fall')
def test_jumpto_invalid_location(self):
location = Location('i4x', 'edX', 'toy', 'NoSuchPlace', None)
jumpto_url = '%s/%s/jump_to/%s' % ('/courses', self.course_name, location)
expected = 'courses/edX/toy/2012_Fall/courseware/Overview/'
response = self.client.get(jumpto_url)
self.assertEqual(response.status_code, 404)
def test_jumpto_from_chapter(self):
location = Location('i4x', 'edX', 'toy', 'chapter', 'Overview')
jumpto_url = '%s/%s/jump_to/%s' % ('/courses', self.course_name, location)
expected = 'courses/edX/toy/2012_Fall/courseware/Overview/'
response = self.client.get(jumpto_url)
self.assertRedirects(response, expected, status_code=302, target_status_code=302)
class ViewsTestCase(TestCase):
def setUp(self):
self.user = User.objects.create(username='dummy', password='123456',
email='test@mit.edu')
self.date = datetime.datetime(2013, 1, 22)
self.course_id = 'edX/toy/2012_Fall'
self.enrollment = CourseEnrollment.objects.get_or_create(user=self.user,
course_id=self.course_id,
created=self.date)[0]
self.location = ['tag', 'org', 'course', 'category', 'name']
self._MODULESTORES = {}
# This is a CourseDescriptor object
self.toy_course = modulestore().get_course('edX/toy/2012_Fall')
self.request_factory = RequestFactory()
chapter = 'Overview'
self.chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter)
def test_user_groups(self):
# depreciated function
mock_user = MagicMock()
mock_user.is_authenticated.return_value = False
self.assertEquals(views.user_groups(mock_user), [])
def test_get_current_child(self):
self.assertIsNone(views.get_current_child(Stub()))
mock_xmodule = MagicMock()
mock_xmodule.position = -1
mock_xmodule.get_display_items.return_value = ['one', 'two']
self.assertEquals(views.get_current_child(mock_xmodule), 'one')
mock_xmodule_2 = MagicMock()
mock_xmodule_2.position = 3
mock_xmodule_2.get_display_items.return_value = []
self.assertIsNone(views.get_current_child(mock_xmodule_2))
def test_redirect_to_course_position(self):
mock_module = MagicMock()
mock_module.descriptor.id = 'Underwater Basketweaving'
mock_module.position = 3
mock_module.get_display_items.return_value = []
self.assertRaises(Http404, views.redirect_to_course_position,
mock_module)
def test_registered_for_course(self):
self.assertFalse(views.registered_for_course('Basketweaving', None))
mock_user = MagicMock()
mock_user.is_authenticated.return_value = False
self.assertFalse(views.registered_for_course('dummy', mock_user))
mock_course = MagicMock()
mock_course.id = self.course_id
self.assertTrue(views.registered_for_course(mock_course, self.user))
def test_jump_to_invalid(self):
request = self.request_factory.get(self.chapter_url)
self.assertRaisesRegexp(Http404, 'Invalid location', views.jump_to,
request, 'bar', ())
self.assertRaisesRegexp(Http404, 'No data*', views.jump_to, request,
'dummy', self.location)
...@@ -53,46 +53,46 @@ def registration(email): ...@@ -53,46 +53,46 @@ def registration(email):
def mongo_store_config(data_dir): def mongo_store_config(data_dir):
return { return {
'default': { 'default': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': { 'OPTIONS': {
'default_class': 'xmodule.raw_module.RawDescriptor', 'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost', 'host': 'localhost',
'db': 'test_xmodule', 'db': 'test_xmodule',
'collection': 'modulestore', 'collection': 'modulestore',
'fs_root': data_dir, 'fs_root': data_dir,
'render_template': 'mitxmako.shortcuts.render_to_string', 'render_template': 'mitxmako.shortcuts.render_to_string',
}
} }
} }
}
def draft_mongo_store_config(data_dir): def draft_mongo_store_config(data_dir):
return { return {
'default': { 'default': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'OPTIONS': { 'OPTIONS': {
'default_class': 'xmodule.raw_module.RawDescriptor', 'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost', 'host': 'localhost',
'db': 'test_xmodule', 'db': 'test_xmodule',
'collection': 'modulestore', 'collection': 'modulestore',
'fs_root': data_dir, 'fs_root': data_dir,
'render_template': 'mitxmako.shortcuts.render_to_string', 'render_template': 'mitxmako.shortcuts.render_to_string',
}
} }
} }
}
def xml_store_config(data_dir): def xml_store_config(data_dir):
return { return {
'default': { 'default': {
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
'OPTIONS': { 'OPTIONS': {
'data_dir': data_dir, 'data_dir': data_dir,
'default_class': 'xmodule.hidden_module.HiddenDescriptor', 'default_class': 'xmodule.hidden_module.HiddenDescriptor',
}
} }
} }
}
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
...@@ -115,8 +115,7 @@ class ActivateLoginTestCase(TestCase): ...@@ -115,8 +115,7 @@ class ActivateLoginTestCase(TestCase):
'Response status code was {0} instead of 302'.format(response.status_code)) 'Response status code was {0} instead of 302'.format(response.status_code))
url = response['Location'] url = response['Location']
e_scheme, e_netloc, e_path, e_query, e_fragment = urlsplit( e_scheme, e_netloc, e_path, e_query, e_fragment = urlsplit(expected_url)
expected_url)
if not (e_scheme or e_netloc): if not (e_scheme or e_netloc):
expected_url = urlunsplit(('http', 'testserver', e_path, expected_url = urlunsplit(('http', 'testserver', e_path,
e_query, e_fragment)) e_query, e_fragment))
...@@ -211,7 +210,7 @@ class PageLoader(ActivateLoginTestCase): ...@@ -211,7 +210,7 @@ class PageLoader(ActivateLoginTestCase):
resp = self.client.post('/change_enrollment', { resp = self.client.post('/change_enrollment', {
'enrollment_action': 'enroll', 'enrollment_action': 'enroll',
'course_id': course.id, 'course_id': course.id,
}) })
return parse_json(resp) return parse_json(resp)
def try_enroll(self, course): def try_enroll(self, course):
...@@ -230,11 +229,10 @@ class PageLoader(ActivateLoginTestCase): ...@@ -230,11 +229,10 @@ class PageLoader(ActivateLoginTestCase):
resp = self.client.post('/change_enrollment', { resp = self.client.post('/change_enrollment', {
'enrollment_action': 'unenroll', 'enrollment_action': 'unenroll',
'course_id': course.id, 'course_id': course.id,
}) })
data = parse_json(resp) data = parse_json(resp)
self.assertTrue(data['success']) self.assertTrue(data['success'])
def check_for_get_code(self, code, url): def check_for_get_code(self, code, url):
""" """
Check that we got the expected code when accessing url via GET. Check that we got the expected code when accessing url via GET.
...@@ -246,7 +244,6 @@ class PageLoader(ActivateLoginTestCase): ...@@ -246,7 +244,6 @@ class PageLoader(ActivateLoginTestCase):
.format(resp.status_code, url, code)) .format(resp.status_code, url, code))
return resp return resp
def check_for_post_code(self, code, url, data={}): def check_for_post_code(self, code, url, data={}):
""" """
Check that we got the expected code when accessing url via POST. Check that we got the expected code when accessing url via POST.
...@@ -258,12 +255,8 @@ class PageLoader(ActivateLoginTestCase): ...@@ -258,12 +255,8 @@ class PageLoader(ActivateLoginTestCase):
.format(resp.status_code, url, code)) .format(resp.status_code, url, code))
return resp return resp
def check_pages_load(self, module_store): def check_pages_load(self, module_store):
"""Make all locations in course load""" """Make all locations in course load"""
# enroll in the course before trying to access pages # enroll in the course before trying to access pages
courses = module_store.get_courses() courses = module_store.get_courses()
self.assertEqual(len(courses), 1) self.assertEqual(len(courses), 1)
...@@ -316,7 +309,7 @@ class PageLoader(ActivateLoginTestCase): ...@@ -316,7 +309,7 @@ class PageLoader(ActivateLoginTestCase):
msg = str(resp.status_code) msg = str(resp.status_code)
if resp.status_code != 200: if resp.status_code != 200:
msg = "ERROR " + msg + ": " + descriptor.location.url() msg = "ERROR " + msg + ": " + descriptor.location.url()
all_ok = False all_ok = False
num_bad += 1 num_bad += 1
elif resp.redirect_chain[0][1] != 302: elif resp.redirect_chain[0][1] != 302:
...@@ -344,7 +337,6 @@ class PageLoader(ActivateLoginTestCase): ...@@ -344,7 +337,6 @@ class PageLoader(ActivateLoginTestCase):
self.assertTrue(all_ok) self.assertTrue(all_ok)
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestCoursesLoadTestCase_XmlModulestore(PageLoader): class TestCoursesLoadTestCase_XmlModulestore(PageLoader):
'''Check that all pages in test courses load properly''' '''Check that all pages in test courses load properly'''
...@@ -355,21 +347,21 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoader): ...@@ -355,21 +347,21 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoader):
def test_toy_course_loads(self): def test_toy_course_loads(self):
module_store = XMLModuleStore( module_store = XMLModuleStore(
TEST_DATA_DIR, TEST_DATA_DIR,
default_class='xmodule.hidden_module.HiddenDescriptor', default_class='xmodule.hidden_module.HiddenDescriptor',
course_dirs=['toy'], course_dirs=['toy'],
load_error_modules=True, load_error_modules=True,
) )
self.check_pages_load(module_store) self.check_pages_load(module_store)
def test_full_course_loads(self): def test_full_course_loads(self):
module_store = XMLModuleStore( module_store = XMLModuleStore(
TEST_DATA_DIR, TEST_DATA_DIR,
default_class='xmodule.hidden_module.HiddenDescriptor', default_class='xmodule.hidden_module.HiddenDescriptor',
course_dirs=['full'], course_dirs=['full'],
load_error_modules=True, load_error_modules=True,
) )
self.check_pages_load(module_store) self.check_pages_load(module_store)
...@@ -525,7 +517,6 @@ class TestViewAuth(PageLoader): ...@@ -525,7 +517,6 @@ class TestViewAuth(PageLoader):
print 'checking for 404 on {0}'.format(url) print 'checking for 404 on {0}'.format(url)
self.check_for_get_code(404, url) self.check_for_get_code(404, url)
# now also make the instructor staff # now also make the instructor staff
u = user(self.instructor) u = user(self.instructor)
u.is_staff = True u.is_staff = True
...@@ -536,7 +527,6 @@ class TestViewAuth(PageLoader): ...@@ -536,7 +527,6 @@ class TestViewAuth(PageLoader):
print 'checking for 200 on {0}'.format(url) print 'checking for 200 on {0}'.format(url)
self.check_for_get_code(200, url) self.check_for_get_code(200, url)
def run_wrapped(self, test): def run_wrapped(self, test):
""" """
test.py turns off start dates. Enable them. test.py turns off start dates. Enable them.
...@@ -552,7 +542,6 @@ class TestViewAuth(PageLoader): ...@@ -552,7 +542,6 @@ class TestViewAuth(PageLoader):
finally: finally:
settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD
def test_dark_launch(self): def test_dark_launch(self):
"""Make sure that before course start, students can't access course """Make sure that before course start, students can't access course
pages, but instructors can""" pages, but instructors can"""
...@@ -646,7 +635,6 @@ class TestViewAuth(PageLoader): ...@@ -646,7 +635,6 @@ class TestViewAuth(PageLoader):
url = reverse_urls(['courseware'], course)[0] url = reverse_urls(['courseware'], course)[0]
self.check_for_get_code(302, url) self.check_for_get_code(302, url)
# First, try with an enrolled student # First, try with an enrolled student
print '=== Testing student access....' print '=== Testing student access....'
self.login(self.student, self.password) self.login(self.student, self.password)
...@@ -761,7 +749,6 @@ class TestViewAuth(PageLoader): ...@@ -761,7 +749,6 @@ class TestViewAuth(PageLoader):
self.assertTrue(has_access(student_user, self.toy, 'load')) self.assertTrue(has_access(student_user, self.toy, 'load'))
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestCourseGrader(PageLoader): class TestCourseGrader(PageLoader):
"""Check that a course gets graded properly""" """Check that a course gets graded properly"""
...@@ -832,13 +819,12 @@ class TestCourseGrader(PageLoader): ...@@ -832,13 +819,12 @@ class TestCourseGrader(PageLoader):
kwargs={ kwargs={
'course_id': self.graded_course.id, 'course_id': self.graded_course.id,
'location': problem_location, 'location': problem_location,
'dispatch': 'problem_check', } 'dispatch': 'problem_check', })
)
resp = self.client.post(modx_url, { resp = self.client.post(modx_url, {
'input_i4x-edX-graded-problem-{0}_2_1'.format(problem_url_name): responses[0], 'input_i4x-edX-graded-problem-{0}_2_1'.format(problem_url_name): responses[0],
'input_i4x-edX-graded-problem-{0}_2_2'.format(problem_url_name): responses[1], 'input_i4x-edX-graded-problem-{0}_2_2'.format(problem_url_name): responses[1],
}) })
print "modx_url", modx_url, "responses", responses print "modx_url", modx_url, "responses", responses
print "resp", resp print "resp", resp
...@@ -854,8 +840,7 @@ class TestCourseGrader(PageLoader): ...@@ -854,8 +840,7 @@ class TestCourseGrader(PageLoader):
kwargs={ kwargs={
'course_id': self.graded_course.id, 'course_id': self.graded_course.id,
'location': problem_location, 'location': problem_location,
'dispatch': 'problem_reset', } 'dispatch': 'problem_reset', })
)
resp = self.client.post(modx_url) resp = self.client.post(modx_url)
return resp return resp
......
...@@ -102,6 +102,9 @@ def get_current_child(xmodule): ...@@ -102,6 +102,9 @@ def get_current_child(xmodule):
children. If xmodule has no position or is out of bounds, return the first child. children. If xmodule has no position or is out of bounds, return the first child.
Returns None only if there are no children at all. Returns None only if there are no children at all.
""" """
if not hasattr(xmodule, 'position'):
return None
if xmodule.position is None: if xmodule.position is None:
pos = 0 pos = 0
else: else:
...@@ -304,6 +307,10 @@ def index(request, course_id, chapter=None, section=None, ...@@ -304,6 +307,10 @@ def index(request, course_id, chapter=None, section=None,
# Specifically asked-for section doesn't exist # Specifically asked-for section doesn't exist
raise Http404 raise Http404
# cdodge: this looks silly, but let's refetch the section_descriptor with depth=None
# which will prefetch the children more efficiently than doing a recursive load
section_descriptor = modulestore().get_instance(course.id, section_descriptor.location, depth=None)
# Load all descendants of the section, because we're going to display its # Load all descendants of the section, because we're going to display its
# html, which in general will need all of its children # html, which in general will need all of its children
section_model_data_cache = ModelDataCache.cache_for_descriptor_descendents( section_model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
......
...@@ -3,6 +3,7 @@ import json ...@@ -3,6 +3,7 @@ import json
from datetime import datetime from datetime import datetime
from django.http import Http404 from django.http import Http404
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from django.db import connection
from student.models import CourseEnrollment, CourseEnrollmentAllowed from student.models import CourseEnrollment, CourseEnrollmentAllowed
from django.contrib.auth.models import User from django.contrib.auth.models import User
...@@ -12,16 +13,18 @@ def dictfetchall(cursor): ...@@ -12,16 +13,18 @@ def dictfetchall(cursor):
'''Returns a list of all rows from a cursor as a column: result dict. '''Returns a list of all rows from a cursor as a column: result dict.
Borrowed from Django documentation''' Borrowed from Django documentation'''
desc = cursor.description desc = cursor.description
table=[] table = []
table.append([col[0] for col in desc]) table.append([col[0] for col in desc])
table = table + cursor.fetchall()
print "Table: " + str(table) # ensure response from db is a list, not a tuple (which is returned
# by MySQL backed django instances)
rows_from_cursor=cursor.fetchall()
table = table + [list(row) for row in rows_from_cursor]
return table return table
def SQL_query_to_list(cursor, query_string): def SQL_query_to_list(cursor, query_string):
cursor.execute(query_string) cursor.execute(query_string)
raw_result=dictfetchall(cursor) raw_result=dictfetchall(cursor)
print raw_result
return raw_result return raw_result
def dashboard(request): def dashboard(request):
...@@ -50,7 +53,6 @@ def dashboard(request): ...@@ -50,7 +53,6 @@ def dashboard(request):
results["scalars"]["Total Enrollments Across All Courses"]=CourseEnrollment.objects.count() results["scalars"]["Total Enrollments Across All Courses"]=CourseEnrollment.objects.count()
# establish a direct connection to the database (for executing raw SQL) # establish a direct connection to the database (for executing raw SQL)
from django.db import connection
cursor = connection.cursor() cursor = connection.cursor()
# define the queries that will generate our user-facing tables # define the queries that will generate our user-facing tables
......
...@@ -549,11 +549,6 @@ def instructor_dashboard(request, course_id): ...@@ -549,11 +549,6 @@ def instructor_dashboard(request, course_id):
msg += "Error! Failed to un-enroll student with email '%s'\n" % student msg += "Error! Failed to un-enroll student with email '%s'\n" % student
msg += str(err) + '\n' msg += str(err) + '\n'
elif action == 'Un-enroll ALL students':
ret = _do_enroll_students(course, course_id, '', overload=True)
datatable = ret['datatable']
elif action == 'Enroll multiple students': elif action == 'Enroll multiple students':
students = request.POST.get('enroll_multiple', '') students = request.POST.get('enroll_multiple', '')
......
...@@ -96,14 +96,24 @@ def peer_grading(request, course_id): ...@@ -96,14 +96,24 @@ def peer_grading(request, course_id):
Show a peer grading interface Show a peer grading interface
''' '''
#Get the current course
course = get_course_with_access(request.user, course_id, 'load') course = get_course_with_access(request.user, course_id, 'load')
course_id_parts = course.id.split("/") course_id_parts = course.id.split("/")
course_id_norun = "/".join(course_id_parts[0:2]) false_dict = [False,"False", "false", "FALSE"]
pg_location = "i4x://" + course_id_norun + "/peergrading/init"
#Reverse the base course url
base_course_url = reverse('courses') base_course_url = reverse('courses')
try: try:
problem_url_parts = search.path_to_location(modulestore(), course.id, pg_location) #TODO: This will not work with multiple runs of a course. Make it work. The last key in the Location passed
#to get_items is called revision. Is this the same as run?
#Get the peer grading modules currently in the course
items = modulestore().get_items(['i4x', None, course_id_parts[1], 'peergrading', None])
#See if any of the modules are centralized modules (ie display info from multiple problems)
items = [i for i in items if i.metadata.get("use_for_single_location", True) in false_dict]
#Get the first one
item_location = items[0].location
#Generate a url for the first module and redirect the user to it
problem_url_parts = search.path_to_location(modulestore(), course.id, item_location)
problem_url = generate_problem_url(problem_url_parts, base_course_url) problem_url = generate_problem_url(problem_url_parts, base_course_url)
return HttpResponseRedirect(problem_url) return HttpResponseRedirect(problem_url)
......
...@@ -246,7 +246,6 @@ function goto( mode) ...@@ -246,7 +246,6 @@ function goto( mode)
<p> <p>
Student Email: <input type="text" name="enstudent"> <input type="submit" name="action" value="Un-enroll student"> Student Email: <input type="text" name="enstudent"> <input type="submit" name="action" value="Un-enroll student">
<input type="submit" name="action" value="Enroll student"> <input type="submit" name="action" value="Enroll student">
<input type="submit" name="action" value="Un-enroll ALL students">
<hr width="40%" style="align:left"> <hr width="40%" style="align:left">
%if settings.MITX_FEATURES.get('REMOTE_GRADEBOOK_URL','') and instructor_access: %if settings.MITX_FEATURES.get('REMOTE_GRADEBOOK_URL','') and instructor_access:
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
% for i, entry in enumerate(history_entries): % for i, entry in enumerate(history_entries):
<hr/> <hr/>
<div> <div>
<b>#${len(history_entries) - i}</b>: ${entry.created} UTC</br> <b>#${len(history_entries) - i}</b>: ${entry.created} (${TIME_ZONE} time)</br>
Score: ${entry.grade} / ${entry.max_grade} Score: ${entry.grade} / ${entry.max_grade}
<pre> <pre>
${json.dumps(json.loads(entry.state), indent=2, sort_keys=True) | h} ${json.dumps(json.loads(entry.state), indent=2, sort_keys=True) | h}
......
...@@ -449,6 +449,13 @@ namespace :cms do ...@@ -449,6 +449,13 @@ namespace :cms do
end end
namespace :cms do namespace :cms do
desc "Imports all the templates from the code pack"
task :update_templates do
sh(django_admin(:cms, :dev, :update_templates))
end
end
namespace :cms do
desc "Import course data within the given DATA_DIR variable" desc "Import course data within the given DATA_DIR variable"
task :xlint do task :xlint do
if ENV['DATA_DIR'] and ENV['COURSE_DIR'] if ENV['DATA_DIR'] and ENV['COURSE_DIR']
......
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