Commit e86ed0db by Will Daly

Merge branch 'master' into fix/will/mc_checkbox_bug

parents 8d83d2fb 19eb6a34
from xmodule.templates import update_templates
update_templates()
......@@ -11,6 +11,14 @@ Feature: Create Section
And I see a release date for my section
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
Given I have opened a new course in Studio
And I have added a new section
......
from lettuce import world, step
from common import *
from nose.tools import assert_equal
############### ACTIONS ####################
......@@ -12,10 +13,12 @@ def i_click_new_section_link(step):
@step('I enter the section name and click save$')
def i_save_section_name(step):
name_css = '.new-section-name'
save_css = '.new-section-name-save'
css_fill(name_css, 'My Section')
css_click(save_css)
save_section_name('My Section')
@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$')
......@@ -45,8 +48,24 @@ def i_save_a_new_section_release_date(step):
@step('I see my section on the Courseware page$')
def i_see_my_section_on_the_courseware_page(step):
section_css = 'span.section-name-span'
assert_css_with_text(section_css, 'My Section')
see_my_section_on_the_courseware_page('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$')
......@@ -88,3 +107,17 @@ def the_section_release_date_is_updated(step):
css = 'span.published-status'
status_text = world.browser.find_by_css(css).text
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
And I enter the subsection name and click save
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
Given I have opened a new course section in Studio
And I have added a new subsection
......
from lettuce import world, step
from common import *
from nose.tools import assert_equal
############### ACTIONS ####################
......@@ -20,28 +21,60 @@ def i_click_the_new_subsection_link(step):
@step('I enter the subsection name and click save$')
def i_save_subsection_name(step):
name_css = 'input.new-subsection-name-input'
save_css = 'input.new-subsection-name-save'
css_fill(name_css, 'Subsection One')
css_click(save_css)
save_subsection_name('Subsection One')
@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$')
def i_have_added_a_new_subsection(step):
add_subsection()
############ ASSERTIONS ###################
@step('I see my subsection on the Courseware page$')
def i_see_my_subsection_on_the_courseware_page(step):
css = 'span.subsection-name'
assert world.browser.is_element_present_by_css(css)
css = 'span.subsection-name-value'
assert_css_with_text(css, 'Subsection One')
see_subsection_name('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$')
def the_subsection_does_not_exist(step):
css = 'span.subsection-name'
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)
......@@ -17,8 +17,7 @@ from auth.authz import _delete_course_group
class Command(BaseCommand):
help = \
'''Delete a MongoDB backed course'''
help = '''Delete a MongoDB backed course'''
def handle(self, *args, **options):
if len(args) != 1 and len(args) != 2:
......@@ -28,19 +27,19 @@ class Command(BaseCommand):
commit = False
if len(args) == 2:
commit = args[1] == 'commit'
commit = args[1] == 'commit'
if commit:
print 'Actually going to delete the course from DB....'
print 'Actually going to delete the course from DB....'
ms = modulestore('direct')
cs = contentstore()
if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"):
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
loc = CourseDescriptor.id_to_location(loc_str)
if delete_course(ms, cs, loc, commit) == True:
print 'removing User permissions from course....'
# in the django layer, we need to remove all the user permissions groups associated with this course
if commit:
_delete_course_group(loc)
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
loc = CourseDescriptor.id_to_location(loc_str)
if delete_course(ms, cs, loc, commit) == True:
print 'removing User permissions from course....'
# in the django layer, we need to remove all the user permissions groups associated with this course
if commit:
_delete_course_group(loc)
......@@ -13,7 +13,7 @@ def query_yes_no(question, default="yes"):
"""
valid = {"yes":True, "y":True, "ye":True,
"no":False, "n":False}
if default == None:
if default is None:
prompt = " [y/n] "
elif default == "yes":
prompt = " [Y/n] "
......
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'
......@@ -263,7 +263,33 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# note, we know the link it should be because that's what in the 'full' course in the test data
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
def test_export_course_with_unknown_metadata(self):
ms = modulestore('direct')
cs = contentstore()
import_from_xml(ms, 'common/test/data/', ['full'])
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
root_dir = path(mkdtemp_clean())
course = ms.get_item(location)
# add a bool piece of unknown metadata so we can verify we don't throw an exception
course.metadata['new_metadata'] = True
ms.update_metadata(location, course.metadata)
print 'Exporting to tempdir = {0}'.format(root_dir)
# export out to a tempdir
bExported = False
try:
export_to_xml(ms, cs, location, root_dir, 'test_export')
bExported = True
except Exception:
pass
self.assertTrue(bExported)
class ContentStoreTest(ModuleStoreTestCase):
"""
......
......@@ -68,6 +68,10 @@ log = logging.getLogger(__name__)
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
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
......@@ -281,10 +285,31 @@ def edit_unit(request, location):
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'))
for template in templates:
if template.location.category in COMPONENT_TYPES:
component_templates[template.location.category].append((
category = template.location.category
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.display_name,
template.location.url(),
'markdown' in template.metadata,
......@@ -1109,6 +1134,7 @@ def module_info(request, module_location):
else:
return HttpResponseBadRequest()
@login_required
@ensure_csrf_cookie
def get_course_settings(request, org, course, name):
......@@ -1124,12 +1150,15 @@ def get_course_settings(request, org, course, name):
raise PermissionDenied()
course_module = modulestore().get_item(location)
course_details = CourseDetails.fetch(location)
return render_to_response('settings.html', {
'context_course': course_module,
'course_location' : location,
'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder)
'course_location': location,
'details_url': reverse(course_settings_updates,
kwargs={"org": org,
"course": course,
"name": name,
"section": "details"})
})
@login_required
......
......@@ -4,9 +4,6 @@ This config file runs the simplest dev environment"""
from .common import *
from logsettings import get_logger_config
import logging
import sys
DEBUG = True
TEMPLATE_DEBUG = DEBUG
LOGGING = get_logger_config(ENV_ROOT / "log",
......@@ -107,3 +104,36 @@ CACHE_TIMEOUT = 0
# Dummy secret key for dev
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
################################ DEBUG TOOLBAR #################################
INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo')
MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware',
'debug_toolbar.middleware.DebugToolbarMiddleware',)
INTERNAL_IPS = ('127.0.0.1',)
DEBUG_TOOLBAR_PANELS = (
'debug_toolbar.panels.version.VersionDebugPanel',
'debug_toolbar.panels.timer.TimerDebugPanel',
'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel',
'debug_toolbar.panels.headers.HeaderDebugPanel',
'debug_toolbar.panels.request_vars.RequestVarsDebugPanel',
'debug_toolbar.panels.sql.SQLDebugPanel',
'debug_toolbar.panels.signals.SignalDebugPanel',
'debug_toolbar.panels.logger.LoggingPanel',
# This is breaking Mongo updates-- Christina is investigating.
# 'debug_toolbar_mongo.panel.MongoDebugPanel',
# Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and
# Django=1.3.1/1.4 where requests to views get duplicated (your method gets
# hit twice). So you can uncomment when you need to diagnose performance
# problems, but you shouldn't leave it on.
# 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
)
DEBUG_TOOLBAR_CONFIG = {
'INTERCEPT_REDIRECTS': False
}
# To see stacktraces for MongoDB queries, set this to True.
# Stacktraces slow down page loads drastically (for pages with lots of queries).
# DEBUG_TOOLBAR_MONGO_STACKTRACES = False
......@@ -27,6 +27,9 @@ STATIC_ROOT = TEST_ROOT / "staticfiles"
GITHUB_REPO_ROOT = TEST_ROOT / "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
STATICFILES_DIRS = [
COMMON_ROOT / "static",
......
......@@ -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
},
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,
save_videosource: function(newsource) {
// 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({
self.render();
}
);
// when the client refetches the updates as a whole, re-render them
this.listenTo(this.collection, 'reset', this.render);
},
render: function () {
......@@ -53,8 +55,12 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
$(updateEle).empty();
var self = this;
this.collection.each(function (update) {
var newEle = self.template({ updateModel : update });
$(updateEle).append(newEle);
try {
var newEle = self.template({ updateModel : update });
$(updateEle).append(newEle);
} catch (e) {
// ignore
}
});
this.$el.find(".new-update-form").hide();
this.$el.find('.date').datepicker({ 'dateFormat': 'MM d, yy' });
......@@ -150,7 +156,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
},
closeEditor: function(self, removePost) {
var targetModel = self.collection.getByCid(self.$currentPost.attr('name'));
var targetModel = self.collection.get(self.$currentPost.attr('name'));
if(removePost) {
self.$currentPost.remove();
......@@ -160,8 +166,13 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
self.$currentPost.removeClass('editing');
self.$currentPost.find('.date-display').html(targetModel.get('date'));
self.$currentPost.find('.date').val(targetModel.get('date'));
self.$currentPost.find('.update-contents').html(targetModel.get('content'));
self.$currentPost.find('.new-update-content').val(targetModel.get('content'));
try {
// 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();
window.$modalCover.unbind('click');
window.$modalCover.hide();
......@@ -172,7 +183,7 @@ CMS.Views.ClassInfoUpdateView = Backbone.View.extend({
// Dereferencing from events to screen elements
eventModel: function(event) {
// 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) {
......
......@@ -254,6 +254,30 @@
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 {
display: inline-block;
width: 100px;
......
......@@ -28,7 +28,7 @@
{{uploadDate}}
</td>
<td class="embed-col">
<input type="text" class="embeddable-xml-input" value='{{url}}' disabled>
<input type="text" class="embeddable-xml-input" value='{{url}}' readonly>
</td>
</tr>
</script>
......@@ -84,7 +84,7 @@
${asset['uploadDate']}
</td>
<td class="embed-col">
<input type="text" class="embeddable-xml-input" value="${asset['url']}" disabled>
<input type="text" class="embeddable-xml-input" value="${asset['url']}" readonly>
</td>
</tr>
% endfor
......@@ -115,7 +115,7 @@
</div>
<div class="embeddable">
<label>URL:</label>
<input type="text" class="embeddable-xml-input" value='' disabled>
<input type="text" class="embeddable-xml-input" value='' readonly>
</div>
<form class="file-chooser" action="${upload_asset_callback_url}"
method="post" enctype="multipart/form-data">
......
......@@ -20,8 +20,8 @@
<script type="text/javascript" charset="utf-8">
$(document).ready(function(){
var course_updates = new CMS.Models.CourseUpdateCollection();
course_updates.reset(${course_updates|n});
course_updates.urlbase = '${url_base}';
course_updates.fetch();
var course_handouts = new CMS.Models.ModuleInfo({
id: '${handouts_location}'
......
......@@ -30,13 +30,18 @@ from contentstore import utils
}).blur(function() {
$("label").removeClass("is-focused");
});
var editor = new CMS.Views.Settings.Details({
el: $('.settings-details'),
model: new CMS.Models.Settings.CourseDetails(${course_details|n},{parse:true})
});
editor.render();
var model = new CMS.Models.Settings.CourseDetails();
model.urlRoot = '${details_url}';
model.fetch({success :
function(model) {
var editor = new CMS.Views.Settings.Details({
el: $('.settings-details'),
model: model
});
editor.render();
}
});
});
</script>
......
......@@ -115,7 +115,7 @@ def get_date_for_press(publish_date):
def press(request):
json_articles = cache.get("student_press_json_articles")
if json_articles == None:
if json_articles is None:
if hasattr(settings, 'RSS_URL'):
content = urllib.urlopen(settings.PRESS_URL).read()
json_articles = json.loads(content)
......@@ -301,7 +301,7 @@ def change_enrollment(request):
action = request.POST.get("enrollment_action", "")
course_id = request.POST.get("course_id", None)
if course_id == None:
if course_id is None:
return HttpResponse(json.dumps({'success': False,
'error': 'There was an error receiving the course id.'}))
......@@ -554,7 +554,7 @@ def create_account(request, post_override=None):
try:
validate_slug(post_vars['username'])
except ValidationError:
js['value'] = "Username should only consist of A-Z and 0-9.".format(field=a)
js['value'] = "Username should only consist of A-Z and 0-9, with no spaces.".format(field=a)
js['field'] = 'username'
return HttpResponse(json.dumps(js))
......@@ -1203,7 +1203,7 @@ def _get_news(top=None):
"Return the n top news items on settings.RSS_URL"
feed_data = cache.get("students_index_rss_feed_data")
if feed_data == None:
if feed_data is None:
if hasattr(settings, 'RSS_URL'):
feed_data = urllib.urlopen(settings.RSS_URL).read()
else:
......
......@@ -146,6 +146,13 @@ class LoncapaProblem(object):
if not self.student_answers: # True when student_answers is an empty dict
self.set_initial_display()
# dictionary of InputType objects associated with this problem
# input_id string -> InputType object
self.inputs = {}
self.extracted_tree = self._extract_html(self.tree)
def do_reset(self):
'''
Reset internal state to unfinished, with no answers
......@@ -324,7 +331,27 @@ class LoncapaProblem(object):
'''
Main method called externally to get the HTML to be rendered for this capa Problem.
'''
return contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context)
html = contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context)
return html
def handle_input_ajax(self, get):
'''
InputTypes can support specialized AJAX calls. Find the correct input and pass along the correct data
Also, parse out the dispatch from the get so that it can be passed onto the input type nicely
'''
# pull out the id
input_id = get['input_id']
if self.inputs[input_id]:
dispatch = get['dispatch']
return self.inputs[input_id].handle_ajax(dispatch, get)
else:
log.warning("Could not find matching input for id: %s" % problem_id)
return {}
# ======= Private Methods Below ========
......@@ -458,6 +485,8 @@ class LoncapaProblem(object):
finally:
sys.path = original_path
def _extract_html(self, problemtree): # private
'''
Main (private) function which converts Problem XML tree to HTML.
......@@ -468,6 +497,7 @@ class LoncapaProblem(object):
Used by get_html.
'''
if (problemtree.tag == 'script' and problemtree.get('type')
and 'javascript' in problemtree.get('type')):
# leave javascript intact.
......@@ -484,8 +514,9 @@ class LoncapaProblem(object):
msg = ''
hint = ''
hintmode = None
input_id = problemtree.get('id')
if problemid in self.correct_map:
pid = problemtree.get('id')
pid = input_id
status = self.correct_map.get_correctness(pid)
msg = self.correct_map.get_msg(pid)
hint = self.correct_map.get_hint(pid)
......@@ -496,21 +527,23 @@ class LoncapaProblem(object):
value = self.student_answers[problemid]
# do the rendering
state = {'value': value,
'status': status,
'id': problemtree.get('id'),
'id': input_id,
'feedback': {'message': msg,
'hint': hint,
'hintmode': hintmode, }}
input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag)
the_input = input_type_cls(self.system, problemtree, state)
return the_input.get_html()
# save the input type so that we can make ajax calls on it if we need to
self.inputs[input_id] = input_type_cls(self.system, problemtree, state)
return self.inputs[input_id].get_html()
# let each Response render itself
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:
if problemtree.tag in customrender.registry.registered_tags():
......
......@@ -27,6 +27,7 @@ class CorrectMap(object):
self.cmap = dict()
self.items = self.cmap.items
self.keys = self.cmap.keys
self.overall_message = ""
self.set(*args, **kwargs)
def __getitem__(self, *args, **kwargs):
......@@ -94,7 +95,7 @@ class CorrectMap(object):
def is_correct(self, answer_id):
if answer_id in self.cmap:
return self.cmap[answer_id]['correctness'] == 'correct'
return self.cmap[answer_id]['correctness'] in ['correct', 'partially-correct']
return None
def is_queued(self, answer_id):
......@@ -104,16 +105,21 @@ class CorrectMap(object):
return self.is_queued(answer_id) and self.cmap[answer_id]['queuestate']['key'] == test_key
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):
npoints = self.get_property(answer_id, 'npoints')
if npoints is not None:
return npoints
elif self.is_correct(answer_id):
return 1
# if not correct and no points have been assigned, return 0
return 0
""" Return the number of points for an answer:
If the answer is correct, return the assigned
number of points (default: 1 point)
Otherwise, return 0 points """
if self.is_correct(answer_id):
npoints = self.get_property(answer_id, 'npoints')
return npoints if npoints is not None else 1
else:
return 0
def set_property(self, answer_id, property, value):
if answer_id in self.cmap:
......@@ -153,3 +159,15 @@ class CorrectMap(object):
if not isinstance(other_cmap, CorrectMap):
raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap)
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
......@@ -45,8 +45,10 @@ import re
import shlex # for splitting quoted strings
import sys
import os
import pyparsing
from registry import TagRegistry
from capa.chem import chemcalc
log = logging.getLogger('mitx.' + __name__)
......@@ -215,6 +217,18 @@ class InputTypeBase(object):
"""
pass
def handle_ajax(self, dispatch, get):
"""
InputTypes that need to handle specialized AJAX should override this.
Input:
dispatch: a string that can be used to determine how to handle the data passed in
get: a dictionary containing the data that was sent with the ajax call
Output:
a dictionary object that can be serialized into JSON. This will be sent back to the Javascript.
"""
pass
def _get_render_context(self):
"""
......@@ -740,6 +754,45 @@ class ChemicalEquationInput(InputTypeBase):
"""
return {'previewer': '/static/js/capa/chemical_equation_preview.js', }
def handle_ajax(self, dispatch, get):
'''
Since we only have chemcalc preview this input, check to see if it
matches the corresponding dispatch and send it through if it does
'''
if dispatch == 'preview_chemcalc':
return self.preview_chemcalc(get)
return {}
def preview_chemcalc(self, get):
"""
Render an html preview of a chemical formula or equation. get should
contain a key 'formula' and value 'some formula string'.
Returns a json dictionary:
{
'preview' : 'the-preview-html' or ''
'error' : 'the-error' or ''
}
"""
result = {'preview': '',
'error': ''}
formula = get['formula']
if formula is None:
result['error'] = "No formula specified."
return result
try:
result['preview'] = chemcalc.render_to_html(formula)
except pyparsing.ParseException as p:
result['error'] = "Couldn't parse formula: {0}".format(p)
except Exception:
# this is unexpected, so log
log.warning("Error while previewing chemical formula", exc_info=True)
result['error'] = "Error while rendering preview"
return result
registry.register(ChemicalEquationInput)
#-----------------------------------------------------------------------------
......@@ -909,33 +962,142 @@ registry.register(DesignProtein2dInput)
class EditAGeneInput(InputTypeBase):
"""
An input type for editing a gene. Integrates with the genex java applet.
Example:
<editagene width="800" hight="500" dna_sequence="ETAAGGCTATAACCGA" />
"""
template = "editageneinput.html"
tags = ['editageneinput']
@classmethod
def get_attributes(cls):
"""
Note: width, hight, and dna_sequencee are required.
"""
Note: width, height, and dna_sequencee are required.
"""
return [Attribute('width'),
Attribute('height'),
Attribute('dna_sequence')
Attribute('dna_sequence'),
Attribute('genex_problem_number')
]
def _extra_context(self):
"""
"""
context = {
'applet_loader': '/static/js/capa/edit-a-gene.js',
}
return context
registry.register(EditAGeneInput)
#---------------------------------------------------------------------
class AnnotationInput(InputTypeBase):
"""
Input type for annotations: students can enter some notes or other text
(currently ungraded), and then choose from a set of tags/optoins, which are graded.
Example:
<annotationinput>
<title>Annotation Exercise</title>
<text>
They are the ones who, at the public assembly, had put savage derangement [ate] into my thinking
[phrenes] |89 on that day when I myself deprived Achilles of his honorific portion [geras]
</text>
<comment>Agamemnon says that ate or 'derangement' was the cause of his actions: why could Zeus say the same thing?</comment>
<comment_prompt>Type a commentary below:</comment_prompt>
<tag_prompt>Select one tag:</tag_prompt>
<options>
<option choice="correct">ate - both a cause and an effect</option>
<option choice="incorrect">ate - a cause</option>
<option choice="partially-correct">ate - an effect</option>
</options>
</annotationinput>
# TODO: allow ordering to be randomized
"""
template = "annotationinput.html"
tags = ['annotationinput']
def setup(self):
xml = self.xml
self.debug = False # set to True to display extra debug info with input
self.return_to_annotation = True # return only works in conjunction with annotatable xmodule
self.title = xml.findtext('./title', 'Annotation Exercise')
self.text = xml.findtext('./text')
self.comment = xml.findtext('./comment')
self.comment_prompt = xml.findtext('./comment_prompt', 'Type a commentary below:')
self.tag_prompt = xml.findtext('./tag_prompt', 'Select one tag:')
self.options = self._find_options()
# Need to provide a value that JSON can parse if there is no
# student-supplied value yet.
if self.value == '':
self.value = 'null'
self._validate_options()
def _find_options(self):
''' Returns an array of dicts where each dict represents an option. '''
elements = self.xml.findall('./options/option')
return [{
'id': index,
'description': option.text,
'choice': option.get('choice')
} for (index, option) in enumerate(elements) ]
def _validate_options(self):
''' Raises a ValueError if the choice attribute is missing or invalid. '''
valid_choices = ('correct', 'partially-correct', 'incorrect')
for option in self.options:
choice = option['choice']
if choice is None:
raise ValueError('Missing required choice attribute.')
elif choice not in valid_choices:
raise ValueError('Invalid choice attribute: {0}. Must be one of: {1}'.format(choice, ', '.join(valid_choices)))
def _unpack(self, json_value):
''' Unpacks the json input state into a dict. '''
d = json.loads(json_value)
if type(d) != dict:
d = {}
comment_value = d.get('comment', '')
if not isinstance(comment_value, basestring):
comment_value = ''
options_value = d.get('options', [])
if not isinstance(options_value, list):
options_value = []
return {
'options_value': options_value,
'has_options_value': len(options_value) > 0, # for convenience
'comment_value': comment_value,
}
def _extra_context(self):
extra_context = {
'title': self.title,
'text': self.text,
'comment': self.comment,
'comment_prompt': self.comment_prompt,
'tag_prompt': self.tag_prompt,
'options': self.options,
'return_to_annotation': self.return_to_annotation,
'debug': self.debug
}
extra_context.update(self._unpack(self.value))
return extra_context
registry.register(AnnotationInput)
<form class="annotation-input">
<div class="script_placeholder" data-src="/static/js/capa/annotationinput.js"/>
<div class="annotation-header">
${title}
% if return_to_annotation:
<a class="annotation-return" href="javascript:void(0)">Return to Annotation</a><br/>
% endif
</div>
<div class="annotation-body">
<div class="block block-highlight">${text}</div>
<div class="block block-comment">${comment}</div>
<div class="block">${comment_prompt}</div>
<textarea class="comment" id="input_${id}_comment" name="input_${id}_comment">${comment_value|h}</textarea>
<div class="block">${tag_prompt}</div>
<ul class="tags">
% for option in options:
<li>
% if has_options_value:
% if all([c == 'correct' for c in option['choice'], status]):
<span class="tag-status correct" id="status_${id}"></span>
% elif all([c == 'partially-correct' for c in option['choice'], status]):
<span class="tag-status partially-correct" id="status_${id}"></span>
% elif all([c == 'incorrect' for c in option['choice'], status]):
<span class="tag-status incorrect" id="status_${id}"></span>
% endif
% endif
<span class="tag
% if option['id'] in options_value:
selected
% endif
" data-id="${option['id']}">
${option['description']}
</span>
</li>
% endfor
</ul>
% if debug:
<div class="debug-value">
Rendered with value:<br/>
<pre>${value|h}</pre>
Current input value:<br/>
<input type="text" class="value" name="input_${id}" id="input_${id}" value="${value|h}" />
</div>
% else:
<input type="hidden" class="value" name="input_${id}" id="input_${id}" value="${value|h}" />
% endif
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif status == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
% elif status == 'incorrect' and not has_options_value:
<span class="incorrect" id="status_${id}"></span>
% endif
<p id="answer_${id}" class="answer answer-annotation"></p>
</div>
</form>
% if msg:
<span class="message">${msg|n}</span>
% endif
......@@ -11,7 +11,7 @@
<div class="incorrect" id="status_${id}">
% endif
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
<input type="text" name="input_${id}" id="input_${id}" data-input-id="${id}" value="${value|h}"
% if size:
size="${size}"
% endif
......
<section id="editageneinput_${id}" class="editageneinput">
<section id="editageneinput_${id}" class="editageneinput">
<div class="script_placeholder" data-src="/static/js/capa/genex/genex.nocache.js?raw"/>
<div class="script_placeholder" data-src="${applet_loader}"/>
% if status == 'unsubmitted':
......@@ -8,16 +9,12 @@
% elif status == 'incorrect':
<div class="incorrect" id="status_${id}">
% elif status == 'incomplete':
<div class="incorrect" id="status_${id}">
<div class="incomplete" id="status_${id}">
% endif
<object type="application/x-java-applet" id="applet_${id}" class="applet" width="${width}" height="${height}">
<param name="archive" value="/static/applets/capa/genex.jar" />
<param name="code" value="GX.GenexApplet.class" />
<param name="DNA_SEQUENCE" value="${dna_sequence}" />
Applet failed to run. No Java plug-in was found.
</object>
<div id="genex_container"></div>
<input type="hidden" name="dna_sequence" id="dna_sequence" value ="${dna_sequence}"></input>
<input type="hidden" name="genex_problem_number" id="genex_problem_number" value ="${genex_problem_number}"></input>
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
<p class="status">
......@@ -37,3 +34,4 @@
</div>
% endif
</section>
......@@ -666,3 +666,36 @@ class StringResponseXMLFactory(ResponseXMLFactory):
def create_input_element(self, **kwargs):
return ResponseXMLFactory.textline_input_xml(**kwargs)
class AnnotationResponseXMLFactory(ResponseXMLFactory):
""" Factory for creating <annotationresponse> XML trees """
def create_response_element(self, **kwargs):
""" Create a <annotationresponse> element """
return etree.Element("annotationresponse")
def create_input_element(self, **kwargs):
""" Create a <annotationinput> element."""
input_element = etree.Element("annotationinput")
text_children = [
{'tag': 'title', 'text': kwargs.get('title', 'super cool annotation') },
{'tag': 'text', 'text': kwargs.get('text', 'texty text') },
{'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah') },
{'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below') },
{'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag') }
]
for child in text_children:
etree.SubElement(input_element, child['tag']).text = child['text']
default_options = [('green', 'correct'),('eggs', 'incorrect'),('ham', 'partially-correct')]
options = kwargs.get('options', default_options)
options_element = etree.SubElement(input_element, 'options')
for (description, correctness) in options:
option_element = etree.SubElement(options_element, 'option', {'choice': correctness})
option_element.text = description
return input_element
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_blank_problem(self):
"""
It's important that blank problems don't break, since that's
what you start with in studio.
"""
xml_str = "<problem> </problem>"
# Create the problem
problem = LoncapaProblem(xml_str, '1', system=test_system)
# Render the HTML
rendered_html = etree.XML(problem.get_html())
# expect that we made it here without blowing up
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),
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))
......@@ -482,27 +482,43 @@ class ChemicalEquationTest(unittest.TestCase):
'''
Check that chemical equation inputs work.
'''
def test_rendering(self):
size = "42"
xml_str = """<chemicalequationinput id="prob_1_2" size="{size}"/>""".format(size=size)
def setUp(self):
self.size = "42"
xml_str = """<chemicalequationinput id="prob_1_2" size="{size}"/>""".format(size=self.size)
element = etree.fromstring(xml_str)
state = {'value': 'H2OYeah', }
the_input = lookup_tag('chemicalequationinput')(test_system, element, state)
self.the_input = lookup_tag('chemicalequationinput')(test_system, element, state)
context = the_input._get_render_context()
def test_rendering(self):
''' Verify that the render context matches the expected render context'''
context = self.the_input._get_render_context()
expected = {'id': 'prob_1_2',
'value': 'H2OYeah',
'status': 'unanswered',
'msg': '',
'size': size,
'size': self.size,
'previewer': '/static/js/capa/chemical_equation_preview.js',
}
self.assertEqual(context, expected)
def test_chemcalc_ajax_sucess(self):
''' Verify that using the correct dispatch and valid data produces a valid response'''
data = {'formula': "H"}
response = self.the_input.handle_ajax("preview_chemcalc", data)
self.assertTrue('preview' in response)
self.assertNotEqual(response['preview'], '')
self.assertEqual(response['error'], "")
class DragAndDropTest(unittest.TestCase):
'''
......@@ -570,3 +586,65 @@ class DragAndDropTest(unittest.TestCase):
context.pop('drag_and_drop_json')
expected.pop('drag_and_drop_json')
self.assertEqual(context, expected)
class AnnotationInputTest(unittest.TestCase):
'''
Make sure option inputs work
'''
def test_rendering(self):
xml_str = '''
<annotationinput>
<title>foo</title>
<text>bar</text>
<comment>my comment</comment>
<comment_prompt>type a commentary</comment_prompt>
<tag_prompt>select a tag</tag_prompt>
<options>
<option choice="correct">x</option>
<option choice="incorrect">y</option>
<option choice="partially-correct">z</option>
</options>
</annotationinput>
'''
element = etree.fromstring(xml_str)
value = {"comment": "blah blah", "options": [1]}
json_value = json.dumps(value)
state = {
'value': json_value,
'id': 'annotation_input',
'status': 'answered'
}
tag = 'annotationinput'
the_input = lookup_tag(tag)(test_system, element, state)
context = the_input._get_render_context()
expected = {
'id': 'annotation_input',
'value': value,
'status': 'answered',
'msg': '',
'title': 'foo',
'text': 'bar',
'comment': 'my comment',
'comment_prompt': 'type a commentary',
'tag_prompt': 'select a tag',
'options': [
{'id': 0, 'description': 'x', 'choice': 'correct'},
{'id': 1, 'description': 'y', 'choice': 'incorrect'},
{'id': 2, 'description': 'z', 'choice': 'partially-correct'}
],
'value': json_value,
'options_value': value['options'],
'has_options_value': len(value['options']) > 0,
'comment_value': value['comment'],
'debug': False,
'return_to_annotation': True
}
self.maxDiff = None
self.assertDictEqual(context, expected)
......@@ -46,6 +46,7 @@ setup(
"custom_tag_template = xmodule.raw_module:RawDescriptor",
"about = xmodule.html_module:AboutDescriptor",
"graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor",
"annotatable = xmodule.annotatable_module:AnnotatableDescriptor",
"foldit = xmodule.foldit_module:FolditDescriptor",
]
}
......
import logging
from lxml import etree
from pkg_resources import resource_string, resource_listdir
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
log = logging.getLogger(__name__)
class AnnotatableModule(XModule):
js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/html/display.coffee'),
resource_string(__name__, 'js/src/annotatable/display.coffee')],
'js': []
}
js_module_name = "Annotatable"
css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]}
icon_class = 'annotatable'
def _get_annotation_class_attr(self, index, el):
""" Returns a dict with the CSS class attribute to set on the annotation
and an XML key to delete from the element.
"""
attr = {}
cls = ['annotatable-span', 'highlight']
highlight_key = 'highlight'
color = el.get(highlight_key)
if color is not None:
if color in self.highlight_colors:
cls.append('highlight-'+color)
attr['_delete'] = highlight_key
attr['value'] = ' '.join(cls)
return { 'class' : attr }
def _get_annotation_data_attr(self, index, el):
""" Returns a dict in which the keys are the HTML data attributes
to set on the annotation element. Each data attribute has a
corresponding 'value' and (optional) '_delete' key to specify
an XML attribute to delete.
"""
data_attrs = {}
attrs_map = {
'body': 'data-comment-body',
'title': 'data-comment-title',
'problem': 'data-problem-id'
}
for xml_key in attrs_map.keys():
if xml_key in el.attrib:
value = el.get(xml_key, '')
html_key = attrs_map[xml_key]
data_attrs[html_key] = { 'value': value, '_delete': xml_key }
return data_attrs
def _render_annotation(self, index, el):
""" Renders an annotation element for HTML output. """
attr = {}
attr.update(self._get_annotation_class_attr(index, el))
attr.update(self._get_annotation_data_attr(index, el))
el.tag = 'span'
for key in attr.keys():
el.set(key, attr[key]['value'])
if '_delete' in attr[key] and attr[key]['_delete'] is not None:
delete_key = attr[key]['_delete']
del el.attrib[delete_key]
def _render_content(self):
""" Renders annotatable content with annotation spans and returns HTML. """
xmltree = etree.fromstring(self.content)
xmltree.tag = 'div'
if 'display_name' in xmltree.attrib:
del xmltree.attrib['display_name']
index = 0
for el in xmltree.findall('.//annotation'):
self._render_annotation(index, el)
index += 1
return etree.tostring(xmltree, encoding='unicode')
def _extract_instructions(self, xmltree):
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
instructions = xmltree.find('instructions')
if instructions is not None:
instructions.tag = 'div'
xmltree.remove(instructions)
return etree.tostring(instructions, encoding='unicode')
return None
def get_html(self):
""" Renders parameters to template. """
context = {
'display_name': self.display_name,
'element_id': self.element_id,
'instructions_html': self.instructions,
'content_html': self._render_content()
}
return self.system.render_template('annotatable.html', context)
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
xmltree = etree.fromstring(self.definition['data'])
self.instructions = self._extract_instructions(xmltree)
self.content = etree.tostring(xmltree, encoding='unicode')
self.element_id = self.location.html_id()
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
class AnnotatableDescriptor(RawDescriptor):
module_class = AnnotatableModule
stores_state = True
template_dir_name = "annotatable"
mako_template = "widgets/raw-edit.html"
......@@ -35,7 +35,8 @@ class StaticContent(object):
@staticmethod
def compute_location(org, course, name, revision=None, is_thumbnail=False):
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):
return StaticContent.get_id_from_location(self.location)
......
......@@ -127,6 +127,7 @@ class CourseDescriptor(SequenceDescriptor):
# 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
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
self._grading_policy = {}
self.set_grading_policy(self.definition['data'].get('grading_policy', None))
self.test_center_exams = []
......@@ -196,11 +197,9 @@ class CourseDescriptor(SequenceDescriptor):
grading_policy.update(course_policy)
# 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
grading_policy['GRADER'] = grader_from_conf(grading_policy['GRADER'])
self._grading_policy = grading_policy
# Use setters so that side effecting to .definitions works
self.raw_grader = grading_policy['GRADER'] # used for cms access
self.grade_cutoffs = grading_policy['GRADE_CUTOFFS']
@classmethod
def read_grading_policy(cls, paths, system):
......@@ -319,7 +318,7 @@ class CourseDescriptor(SequenceDescriptor):
@property
def grader(self):
return self._grading_policy['GRADER']
return grader_from_conf(self.raw_grader)
@property
def raw_grader(self):
......
$border-color: #C8C8C8;
$body-font-size: em(14);
.annotatable-header {
margin-bottom: .5em;
.annotatable-title {
font-size: em(22);
text-transform: uppercase;
padding: 2px 4px;
}
}
.annotatable-section {
position: relative;
padding: .5em 1em;
border: 1px solid $border-color;
border-radius: .5em;
margin-bottom: .5em;
&.shaded { background-color: #EDEDED; }
.annotatable-section-title {
font-weight: bold;
a { font-weight: normal; }
}
.annotatable-section-body {
border-top: 1px solid $border-color;
margin-top: .5em;
padding-top: .5em;
@include clearfix;
}
ul.instructions-template {
list-style: disc;
margin-left: 4em;
b { font-weight: bold; }
i { font-style: italic; }
code {
display: inline;
white-space: pre;
font-family: Courier New, monospace;
}
}
}
.annotatable-toggle {
position: absolute;
right: 0;
margin: 2px 1em 2px 0;
&.expanded:after { content: " \2191" }
&.collapsed:after { content: " \2193" }
}
.annotatable-span {
display: inline;
cursor: pointer;
@each $highlight in (
(yellow rgba(255,255,10,0.3) rgba(255,255,10,0.9)),
(red rgba(178,19,16,0.3) rgba(178,19,16,0.9)),
(orange rgba(255,165,0,0.3) rgba(255,165,0,0.9)),
(green rgba(25,255,132,0.3) rgba(25,255,132,0.9)),
(blue rgba(35,163,255,0.3) rgba(35,163,255,0.9)),
(purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) {
$marker: nth($highlight,1);
$color: nth($highlight,2);
$selected_color: nth($highlight,3);
@if $marker == yellow {
&.highlight {
background-color: $color;
&.selected { background-color: $selected_color; }
}
}
&.highlight-#{$marker} {
background-color: $color;
&.selected { background-color: $selected_color; }
}
}
&.hide {
cursor: none;
background-color: inherit;
.annotatable-icon {
display: none;
}
}
.annotatable-comment {
display: none;
}
}
.ui-tooltip.qtip.ui-tooltip {
font-size: $body-font-size;
border: 1px solid #333;
border-radius: 1em;
background-color: rgba(0,0,0,.85);
color: #fff;
-webkit-font-smoothing: antialiased;
.ui-tooltip-titlebar {
font-size: em(16);
color: inherit;
background-color: transparent;
padding: 5px 10px;
border: none;
.ui-tooltip-title {
padding: 5px 0px;
border-bottom: 2px solid #333;
font-weight: bold;
}
.ui-tooltip-icon {
right: 10px;
background: #333;
}
.ui-state-hover {
color: inherit;
border: 1px solid #ccc;
}
}
.ui-tooltip-content {
color: inherit;
font-size: em(14);
text-align: left;
font-weight: 400;
padding: 0 10px 10px 10px;
background-color: transparent;
}
p {
color: inherit;
line-height: normal;
}
}
.ui-tooltip.qtip.ui-tooltip-annotatable {
max-width: 375px;
.ui-tooltip-content {
padding: 0 10px;
.annotatable-comment {
display: block;
margin: 0px 0px 10px 0;
max-height: 225px;
overflow: auto;
}
.annotatable-reply {
display: block;
border-top: 2px solid #333;
padding: 5px 0;
margin: 0;
text-align: center;
}
}
&:after {
content: '';
display: inline-block;
position: absolute;
bottom: -20px;
left: 50%;
height: 0;
width: 0;
margin-left: -5px;
border: 10px solid transparent;
border-top-color: rgba(0, 0, 0, .85);
}
}
......@@ -240,6 +240,15 @@ section.problem {
width: 25px;
}
&.partially-correct {
@include inline-block();
background: url('../images/partially-correct-icon.png') center center no-repeat;
height: 20px;
position: relative;
top: 6px;
width: 25px;
}
&.incorrect, &.ui-icon-close {
@include inline-block();
background: url('../images/incorrect-icon.png') center center no-repeat;
......@@ -811,4 +820,91 @@ section.problem {
display: none;
}
}
.annotation-input {
$yellow: rgba(255,255,10,0.3);
border: 1px solid #ccc;
border-radius: 1em;
margin: 0 0 1em 0;
.annotation-header {
font-weight: bold;
border-bottom: 1px solid #ccc;
padding: .5em 1em;
}
.annotation-body { padding: .5em 1em; }
a.annotation-return {
float: right;
font: inherit;
font-weight: normal;
}
a.annotation-return:after { content: " \2191" }
.block, ul.tags {
margin: .5em 0;
padding: 0;
}
.block-highlight {
padding: .5em;
color: #333;
font-style: normal;
background-color: $yellow;
border: 1px solid darken($yellow, 10%);
}
.block-comment { font-style: italic; }
ul.tags {
display: block;
list-style-type: none;
margin-left: 1em;
li {
display: block;
margin: 1em 0 0 0;
position: relative;
.tag {
display: inline-block;
cursor: pointer;
border: 1px solid rgb(102,102,102);
margin-left: 40px;
&.selected {
background-color: $yellow;
}
}
.tag-status {
position: absolute;
left: 0;
}
.tag-status, .tag { padding: .25em .5em; }
}
}
textarea.comment {
$num-lines-to-show: 5;
$line-height: 1.4em;
$padding: .2em;
width: 100%;
padding: $padding (2 * $padding);
line-height: $line-height;
height: ($num-lines-to-show * $line-height) + (2*$padding) - (($line-height - 1)/2);
}
.answer-annotation { display: block; margin: 0; }
/* for debugging the input value field. enable the debug flag on the inputtype */
.debug-value {
color: #fff;
padding: 1em;
margin: 1em 0;
background-color: #999;
border: 1px solid #000;
input[type="text"] { width: 100%; }
pre { background-color: #CCC; color: #000; }
&:before {
display: block;
content: "debug input value";
text-transform: uppercase;
font-weight: bold;
font-size: 1.5em;
}
}
}
}
$leaderboard: #F4F4F4;
section.foldit {
div.folditchallenge {
table {
border: 1px solid lighten($leaderboard, 10%);
border-collapse: collapse;
margin-top: 20px;
}
th {
background: $leaderboard;
color: darken($leaderboard, 25%);
}
td {
background: lighten($leaderboard, 3%);
border-bottom: 1px solid #fff;
padding: 8px;
}
}
}
......@@ -11,14 +11,27 @@ from xmodule.xml_module import XmlDescriptor
log = logging.getLogger(__name__)
class FolditModule(XModule):
css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]}
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
# ooh look--I'm lazy, so hardcoding the 7.00x required level.
# If we need it generalized, can pull from the xml later
self.required_level = 4
self.required_sublevel = 5
"""
Example:
<foldit show_basic_score="true"
required_level="4"
required_sublevel="3"
show_leaderboard="false"/>
"""
req_level = self.metadata.get("required_level")
req_sublevel = self.metadata.get("required_sublevel")
# default to what Spring_7012x uses
self.required_level = req_level if req_level else 4
self.required_sublevel = req_sublevel if req_sublevel else 5
def parse_due_date():
"""
......@@ -66,6 +79,14 @@ class FolditModule(XModule):
PuzzleComplete.completed_puzzles(self.system.anonymous_student_id),
key=lambda d: (d['set'], d['subset']))
def puzzle_leaders(self, n=10):
"""
Returns a list of n pairs (user, score) corresponding to the top
scores; the pairs are in descending order of score.
"""
from foldit.models import Score
return [(e['username'], e['score']) for e in Score.get_tops_n(10)]
def get_html(self):
"""
......@@ -75,15 +96,48 @@ class FolditModule(XModule):
self.required_level,
self.required_sublevel)
showbasic = (self.metadata.get("show_basic_score", "").lower() == "true")
showleader = (self.metadata.get("show_leaderboard", "").lower() == "true")
context = {
'due': self.due_str,
'success': self.is_complete(),
'goal_level': goal_level,
'completed': self.completed_puzzles(),
'top_scores': self.puzzle_leaders(),
'show_basic': showbasic,
'show_leader': showleader,
'folditbasic': self.get_basicpuzzles_html(),
'folditchallenge': self.get_challenge_html()
}
return self.system.render_template('foldit.html', context)
def get_basicpuzzles_html(self):
"""
Render html for the basic puzzle section.
"""
goal_level = '{0}-{1}'.format(
self.required_level,
self.required_sublevel)
context = {
'due': self.due_str,
'success': self.is_complete(),
'goal_level': goal_level,
'completed': self.completed_puzzles(),
}
return self.system.render_template('folditbasic.html', context)
def get_challenge_html(self):
"""
Render html for challenge (i.e., the leaderboard)
"""
context = {
'top_scores': self.puzzle_leaders()}
return self.system.render_template('folditchallenge.html', context)
def get_score(self):
"""
......@@ -97,9 +151,10 @@ class FolditModule(XModule):
return 1
class FolditDescriptor(XmlDescriptor, EditingDescriptor):
"""
Module for adding open ended response questions to courses
Module for adding Foldit problems to courses
"""
mako_template = "widgets/html-edit.html"
module_class = FolditModule
......@@ -119,6 +174,6 @@ class FolditDescriptor(XmlDescriptor, EditingDescriptor):
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
For now, don't need anything from the xml
Get the xml_object's attributes.
"""
return {}
return {'metadata': xml_object.attrib}
<section class='xmodule_display xmodule_AnnotatableModule' data-type='Annotatable'>
<div class="annotatable-wrapper">
<div class="annotatable-header">
<div class="annotatable-title">First Annotation Exercise</div>
</div>
<div class="annotatable-section">
<div class="annotatable-section-title">
Instructions
<a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">Collapse Instructions</a>
</div>
<div class="annotatable-section-body annotatable-instructions">
<div><p>The main goal of this exercise is to start practicing the art of slow reading.</p>
</div>
</div>
<div class="annotatable-section">
<div class="annotatable-section-title">
Guided Discussion
<a class="annotatable-toggle annotatable-toggle-annotations" href="javascript:void(0)">Hide Annotations</a>
</div>
</div>
<div class="annotatable-content">
|87 No, those who are really responsible are Zeus and Fate [Moira] and the Fury [Erinys] who roams in the mist. <br/>
|88 <span data-problem-id="0" data-comment-body="Agamemnon says..." class="annotatable-span highlight" data-comment-title="Your Title Here">They are the ones who</span><br/>
|100 He [= Zeus], making a formal declaration [eukhesthai], spoke up at a meeting of all the gods and said: <br/>
|101 <span data-problem-id="1" data-comment-body="When Zeus speaks..." class="annotatable-span highlight">“hear me, all gods and all goddesses,</span><br/>
|113 but he swore a great oath.
<span data-problem-id="2" data-comment-body="How is the ‘veering off-course’ ..." class="annotatable-span highlight">And right then and there</span><br/>
</div>
</div>
</section>
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
describe 'Annotatable', ->
beforeEach ->
loadFixtures 'annotatable.html'
describe 'constructor', ->
el = $('.xmodule_display.xmodule_AnnotatableModule')
beforeEach ->
@annotatable = new Annotatable(el)
it 'works', ->
expect(1).toBe(1)
\ No newline at end of file
class @Annotatable
_debug: false
# selectors for the annotatable xmodule
toggleAnnotationsSelector: '.annotatable-toggle-annotations'
toggleInstructionsSelector: '.annotatable-toggle-instructions'
instructionsSelector: '.annotatable-instructions'
sectionSelector: '.annotatable-section'
spanSelector: '.annotatable-span'
replySelector: '.annotatable-reply'
# these selectors are for responding to events from the annotation capa problem type
problemXModuleSelector: '.xmodule_CapaModule'
problemSelector: 'section.problem'
problemInputSelector: 'section.problem .annotation-input'
problemReturnSelector: 'section.problem .annotation-return'
constructor: (el) ->
console.log 'loaded Annotatable' if @_debug
@el = el
@$el = $(el)
@init()
$: (selector) ->
$(selector, @el)
init: () ->
@initEvents()
@initTips()
initEvents: () ->
# Initialize toggle handlers for the instructions and annotations sections
[@annotationsHidden, @instructionsHidden] = [false, false]
@$(@toggleAnnotationsSelector).bind 'click', @onClickToggleAnnotations
@$(@toggleInstructionsSelector).bind 'click', @onClickToggleInstructions
# Initialize handler for 'reply to annotation' events that scroll to
# the associated problem. The reply buttons are part of the tooltip
# content. It's important that the tooltips be configured to render
# as descendants of the annotation module and *not* the document.body.
@$el.delegate @replySelector, 'click', @onClickReply
# Initialize handler for 'return to annotation' events triggered from problems.
# 1) There are annotationinput capa problems rendered on the page
# 2) Each one has an embedded return link (see annotation capa problem template).
# Since the capa problem injects HTML content via AJAX, the best we can do is
# is let the click events bubble up to the body and handle them there.
$('body').delegate @problemReturnSelector, 'click', @onClickReturn
initTips: () ->
# tooltips are used to display annotations for highlighted text spans
@$(@spanSelector).each (index, el) =>
$(el).qtip(@getSpanTipOptions el)
getSpanTipOptions: (el) ->
content:
title:
text: @makeTipTitle(el)
text: @makeTipContent(el)
position:
my: 'bottom center' # of tooltip
at: 'top center' # of target
target: $(el) # where the tooltip was triggered (i.e. the annotation span)
container: @$el
adjust:
y: -5
show:
event: 'click mouseenter'
solo: true
hide:
event: 'click mouseleave'
delay: 500,
fixed: true # don't hide the tooltip if it is moused over
style:
classes: 'ui-tooltip-annotatable'
events:
show: @onShowTip
onClickToggleAnnotations: (e) => @toggleAnnotations()
onClickToggleInstructions: (e) => @toggleInstructions()
onClickReply: (e) => @replyTo(e.currentTarget)
onClickReturn: (e) => @returnFrom(e.currentTarget)
onShowTip: (event, api) =>
event.preventDefault() if @annotationsHidden
getSpanForProblemReturn: (el) ->
problem_id = $(@problemReturnSelector).index(el)
@$(@spanSelector).filter("[data-problem-id='#{problem_id}']")
getProblem: (el) ->
problem_id = @getProblemId(el)
$(@problemSelector).has(@problemInputSelector).eq(problem_id)
getProblemId: (el) ->
$(el).data('problem-id')
toggleAnnotations: () ->
hide = (@annotationsHidden = not @annotationsHidden)
@toggleAnnotationButtonText hide
@toggleSpans hide
@toggleTips hide
toggleTips: (hide) ->
visible = @findVisibleTips()
@hideTips visible
toggleAnnotationButtonText: (hide) ->
buttonText = (if hide then 'Show' else 'Hide')+' Annotations'
@$(@toggleAnnotationsSelector).text(buttonText)
toggleInstructions: () ->
hide = (@instructionsHidden = not @instructionsHidden)
@toggleInstructionsButton hide
@toggleInstructionsText hide
toggleInstructionsButton: (hide) ->
txt = (if hide then 'Expand' else 'Collapse')+' Instructions'
cls = (if hide then ['expanded', 'collapsed'] else ['collapsed','expanded'])
@$(@toggleInstructionsSelector).text(txt).removeClass(cls[0]).addClass(cls[1])
toggleInstructionsText: (hide) ->
slideMethod = (if hide then 'slideUp' else 'slideDown')
@$(@instructionsSelector)[slideMethod]()
toggleSpans: (hide) ->
@$(@spanSelector).toggleClass 'hide', hide, 250
replyTo: (buttonEl) ->
offset = -20
el = @getProblem buttonEl
if el.length > 0
@scrollTo(el, @afterScrollToProblem, offset)
else
console.log('problem not found. event: ', e) if @_debug
returnFrom: (buttonEl) ->
offset = -200
el = @getSpanForProblemReturn buttonEl
if el.length > 0
@scrollTo(el, @afterScrollToSpan, offset)
else
console.log('span not found. event:', e) if @_debug
scrollTo: (el, after, offset = -20) ->
$('html,body').scrollTo(el, {
duration: 500
onAfter: @_once => after?.call this, el
offset: offset
}) if $(el).length > 0
afterScrollToProblem: (problem_el) ->
problem_el.effect 'highlight', {}, 500
afterScrollToSpan: (span_el) ->
span_el.addClass 'selected', 400, 'swing', ->
span_el.removeClass 'selected', 400, 'swing'
makeTipContent: (el) ->
(api) =>
text = $(el).data('comment-body')
comment = @createComment(text)
problem_id = @getProblemId(el)
reply = @createReplyLink(problem_id)
$(comment).add(reply)
makeTipTitle: (el) ->
(api) =>
title = $(el).data('comment-title')
(if title then title else 'Commentary')
createComment: (text) ->
$("<div class=\"annotatable-comment\">#{text}</div>")
createReplyLink: (problem_id) ->
$("<a class=\"annotatable-reply\" href=\"javascript:void(0);\" data-problem-id=\"#{problem_id}\">Reply to Annotation</a>")
findVisibleTips: () ->
visible = []
@$(@spanSelector).each (index, el) ->
api = $(el).qtip('api')
tip = $(api?.elements.tooltip)
if tip.is(':visible')
visible.push el
visible
hideTips: (elements) ->
$(elements).qtip('hide')
_once: (fn) ->
done = false
return =>
fn.call this unless done
done = true
......@@ -76,6 +76,24 @@ class @Problem
# TODO: Some logic to dynamically adjust polling rate based on queuelen
window.queuePollerID = window.setTimeout(@poll, 1000)
# Use this if you want to make an ajax call on the input type object
# static method so you don't have to instantiate a Problem in order to use it
# Input:
# url: the AJAX url of the problem
# input_id: the input_id of the input you would like to make the call on
# NOTE: the id is the ${id} part of "input_${id}" during rendering
# If this function is passed the entire prefixed id, the backend may have trouble
# finding the correct input
# dispatch: string that indicates how this data should be handled by the inputtype
# callback: the function that will be called once the AJAX call has been completed.
# It will be passed a response object
@inputAjax: (url, input_id, dispatch, data, callback) ->
data['dispatch'] = dispatch
data['input_id'] = input_id
$.postWithPrefix "#{url}/input_ajax", data, callback
render: (content) ->
if content
@el.html(content)
......
......@@ -71,6 +71,17 @@ class Location(_LocationBase):
"""
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
def clean_for_url_name(value):
"""
......
......@@ -64,7 +64,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
location = Location(location)
json_data = self.module_data.get(location)
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:
# load the module and apply the inherited metadata
try:
......
......@@ -7,47 +7,47 @@ from json import dumps
def export_to_xml(modulestore, contentstore, course_location, root_dir, course_dir):
course = modulestore.get_item(course_location)
course = modulestore.get_item(course_location)
fs = OSFS(root_dir)
export_fs = fs.makeopendir(course_dir)
fs = OSFS(root_dir)
export_fs = fs.makeopendir(course_dir)
xml = course.export_to_xml(export_fs)
with export_fs.open('course.xml', 'w') as course_xml:
course_xml.write(xml)
xml = course.export_to_xml(export_fs)
with export_fs.open('course.xml', 'w') as course_xml:
course_xml.write(xml)
# export the static assets
contentstore.export_all_for_course(course_location, root_dir + '/' + course_dir + '/static/')
# export the static assets
contentstore.export_all_for_course(course_location, root_dir + '/' + course_dir + '/static/')
# export the static tabs
export_extra_content(export_fs, modulestore, course_location, 'static_tab', 'tabs', '.html')
# export the static tabs
export_extra_content(export_fs, modulestore, course_location, 'static_tab', 'tabs', '.html')
# export the custom tags
export_extra_content(export_fs, modulestore, course_location, 'custom_tag_template', 'custom_tags')
# export the custom tags
export_extra_content(export_fs, modulestore, course_location, 'custom_tag_template', 'custom_tags')
# export the course updates
export_extra_content(export_fs, modulestore, course_location, 'course_info', 'info', '.html')
# export the course updates
export_extra_content(export_fs, modulestore, course_location, 'course_info', 'info', '.html')
# export the grading policy
policies_dir = export_fs.makeopendir('policies')
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
if 'grading_policy' in course.definition['data']:
with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
grading_policy.write(dumps(course.definition['data']['grading_policy']))
# export the grading policy
policies_dir = export_fs.makeopendir('policies')
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
if 'grading_policy' in course.definition['data']:
with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
grading_policy.write(dumps(course.definition['data']['grading_policy']))
# export all of the course metadata in policy.json
with course_run_policy_dir.open('policy.json', 'w') as course_policy:
policy = {}
policy = {'course/' + course.location.name: course.metadata}
course_policy.write(dumps(policy))
# export all of the course metadata in policy.json
with course_run_policy_dir.open('policy.json', 'w') as course_policy:
policy = {}
policy = {'course/' + course.location.name: course.metadata}
course_policy.write(dumps(policy))
def export_extra_content(export_fs, modulestore, course_location, category_type, dirname, file_suffix=''):
query_loc = Location('i4x', course_location.org, course_location.course, category_type, None)
items = modulestore.get_items(query_loc)
if len(items) > 0:
item_dir = export_fs.makeopendir(dirname)
for item in items:
with item_dir.open(item.location.name + file_suffix, 'w') as item_file:
item_file.write(item.definition['data'].encode('utf8'))
query_loc = Location('i4x', course_location.org, course_location.course, category_type, None)
items = modulestore.get_items(query_loc)
if len(items) > 0:
item_dir = export_fs.makeopendir(dirname)
for item in items:
with item_dir.open(item.location.name + file_suffix, 'w') as item_file:
item_file.write(item.definition['data'].encode('utf8'))
......@@ -79,6 +79,9 @@ class CombinedOpenEndedV1Module():
INTERMEDIATE_DONE = 'intermediate_done'
DONE = 'done'
#Where the templates live for this problem
TEMPLATE_DIR = "combinedopenended"
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, metadata = None, static_data = None, **kwargs):
......@@ -343,7 +346,7 @@ class CombinedOpenEndedV1Module():
Output: rendered html
"""
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
def get_html_nonsystem(self):
......@@ -354,7 +357,7 @@ class CombinedOpenEndedV1Module():
Output: HTML rendered directly via Mako
"""
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
def get_html_base(self):
......@@ -531,7 +534,7 @@ class CombinedOpenEndedV1Module():
'task_name' : 'Scored Rubric',
'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}
def get_legend(self, get):
......@@ -543,7 +546,7 @@ class CombinedOpenEndedV1Module():
context = {
'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}
def get_results(self, get):
......@@ -574,7 +577,7 @@ class CombinedOpenEndedV1Module():
'submission_id' : ri['submission_ids'][i],
}
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,
'grader_type_image_dict' : GRADER_TYPE_IMAGE_DICT,
'human_grader_types' : HUMAN_GRADER_TYPE,
......@@ -586,7 +589,7 @@ class CombinedOpenEndedV1Module():
'task_name' : "Feedback",
'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}
def get_status_ajax(self, get):
......@@ -700,7 +703,7 @@ class CombinedOpenEndedV1Module():
'legend_list' : LEGEND_LIST,
'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
......
......@@ -30,6 +30,8 @@ class RubricParsingError(Exception):
class CombinedOpenEndedRubric(object):
TEMPLATE_DIR = "combinedopenended/openended"
def __init__ (self, system, view_only = False):
self.has_score = False
self.view_only = view_only
......@@ -57,9 +59,9 @@ class CombinedOpenEndedRubric(object):
rubric_scores = [cat['score'] for cat in rubric_categories]
max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories)
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:
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,
{'categories': rubric_categories,
'has_score': self.has_score,
......@@ -207,7 +209,7 @@ class CombinedOpenEndedRubric(object):
for grader_type in tuple[3]:
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,
'has_score': True,
'view_only': True,
......
......@@ -40,6 +40,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
</openended>
"""
TEMPLATE_DIR = "combinedopenended/openended"
def setup_response(self, system, location, definition, descriptor):
"""
Sets up the response type.
......@@ -397,10 +399,10 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
rubric_scores = rubric_dict['rubric_scores']
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})
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'],
'score': "{0} / {1}".format(response_items['score'], self.max_score()),
'feedback': feedback,
......@@ -558,7 +560,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
@return: Rendered html
"""
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
def handle_ajax(self, dispatch, get, system):
......@@ -692,7 +694,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
'accept_file_upload': self.accept_file_upload,
'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
......
......@@ -32,6 +32,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
</selfassessment>
"""
TEMPLATE_DIR = "combinedopenended/selfassessment"
def setup_response(self, system, location, definition, descriptor):
"""
Sets up the module
......@@ -68,7 +70,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
'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
......@@ -129,7 +131,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
#This is a dev_facing_error
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):
"""
......@@ -155,7 +157,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
#This is a dev_facing_error
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):
......
......@@ -471,6 +471,9 @@ class PeerGradingModule(XModule):
#This is a student_facing_error
error_text = "Could not get list of problems to peer grade. Please notify course staff."
success = False
except:
log.exception("Could not contact peer grading service.")
success = False
def _find_corresponding_module_for_location(location):
......@@ -524,7 +527,7 @@ class PeerGradingModule(XModule):
'''
Show individual problem interface
'''
if get == None or get.get('location') == None:
if get is None or get.get('location') is None:
if not self.use_for_single_location:
#This is an error case, because it must be set to use a single location to be called without get parameters
#This is a dev_facing_error
......@@ -589,7 +592,6 @@ class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor):
'task_xml': dictionary of xml strings,
}
"""
log.debug("In definition")
expected_children = []
for child in expected_children:
if len(xml_object.xpath(child)) == 0:
......
---
metadata:
display_name: 'Annotation'
data: |
<annotatable>
<instructions>
<p>Enter your (optional) instructions for the exercise in HTML format.</p>
<p>Annotations are specified by an <code>&lt;annotation&gt;</code> tag which may may have the following attributes:</p>
<ul class="instructions-template">
<li><code>title</code> (optional). Title of the annotation. Defaults to <i>Commentary</i> if omitted.</li>
<li><code>body</code> (<b>required</b>). Text of the annotation.</li>
<li><code>problem</code> (optional). Numeric index of the problem associated with this annotation. This is a zero-based index, so the first problem on the page would have <code>problem="0"</code>.</li>
<li><code>highlight</code> (optional). Possible values: yellow, red, orange, green, blue, or purple. Defaults to yellow if this attribute is omitted.</li>
</ul>
</instructions>
<p>Add your HTML with annotation spans here.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <annotation title="My title" body="My comment" highlight="yellow" problem="0">Ut sodales laoreet est, egestas gravida felis egestas nec.</annotation> Aenean at volutpat erat. Cras commodo viverra nibh in aliquam.</p>
<p>Nulla facilisi. <annotation body="Basic annotation example." problem="1">Pellentesque id vestibulum libero.</annotation> Suspendisse potenti. Morbi scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus eros.</p>
</annotatable>
children: []
---
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: []
......@@ -28,21 +28,35 @@ open_ended_grading_interface = {
'grading_controller' : 'grading_controller'
}
test_system = ModuleSystem(
ajax_url='courses/course_id/modx/a_location',
track_function=Mock(),
get_module=Mock(),
# "render" to just the context...
render_template=lambda template, context: str(context),
replace_urls=Mock(),
user=Mock(is_staff=False),
filestore=Mock(),
debug=True,
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10},
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
anonymous_student_id='student',
open_ended_grading_interface= open_ended_grading_interface
)
def test_system():
"""
Construct a test ModuleSystem instance.
By default, the render_template() method simply returns
the context it is passed as a string.
You can override this behavior by monkey patching:
system = test_system()
system.render_template = my_render_func
where my_render_func is a function of the form
my_render_func(template, context)
"""
return ModuleSystem(
ajax_url='courses/course_id/modx/a_location',
track_function=Mock(),
get_module=Mock(),
render_template=lambda template, context: str(context),
replace_urls=lambda html: str(html),
user=Mock(is_staff=False),
filestore=Mock(),
debug=True,
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10},
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
anonymous_student_id='student',
open_ended_grading_interface= open_ended_grading_interface
)
class ModelsTest(unittest.TestCase):
......
"""Module annotatable tests"""
import unittest
from lxml import etree
from mock import Mock
from xmodule.annotatable_module import AnnotatableModule
from xmodule.modulestore import Location
from . import test_system
class AnnotatableModuleTestCase(unittest.TestCase):
location = Location(["i4x", "edX", "toy", "annotatable", "guided_discussion"])
sample_xml = '''
<annotatable display_name="Iliad">
<instructions>Read the text.</instructions>
<p>
<annotation body="first">Sing</annotation>,
<annotation title="goddess" body="second">O goddess</annotation>,
<annotation title="anger" body="third" highlight="blue">the anger of Achilles son of Peleus</annotation>,
that brought <i>countless</i> ills upon the Achaeans. Many a brave soul did it send
hurrying down to Hades, and many a hero did it yield a prey to dogs and
<div style="font-weight:bold"><annotation body="fourth" problem="4">vultures</annotation>, for so were the counsels
of Jove fulfilled from the day on which the son of Atreus, king of men, and great
Achilles, first fell out with one another.</div>
</p>
<annotation title="footnote" body="the end">The Iliad of Homer by Samuel Butler</annotation>
</annotatable>
'''
definition = { 'data': sample_xml }
descriptor = Mock()
instance_state = None
shared_state = None
def setUp(self):
self.annotatable = AnnotatableModule(test_system(), self.location, self.definition, self.descriptor, self.instance_state, self.shared_state)
def test_annotation_data_attr(self):
el = etree.fromstring('<annotation title="bar" body="foo" problem="0">test</annotation>')
expected_attr = {
'data-comment-body': {'value': 'foo', '_delete': 'body' },
'data-comment-title': {'value': 'bar', '_delete': 'title'},
'data-problem-id': {'value': '0', '_delete': 'problem'}
}
actual_attr = self.annotatable._get_annotation_data_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_class_attr_default(self):
xml = '<annotation title="x" body="y" problem="0">test</annotation>'
el = etree.fromstring(xml)
expected_attr = { 'class': { 'value': 'annotatable-span highlight' } }
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_class_attr_with_valid_highlight(self):
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
for color in self.annotatable.highlight_colors:
el = etree.fromstring(xml.format(highlight=color))
value = 'annotatable-span highlight highlight-{highlight}'.format(highlight=color)
expected_attr = { 'class': {
'value': value,
'_delete': 'highlight' }
}
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_annotation_class_attr_with_invalid_highlight(self):
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
for invalid_color in ['rainbow', 'blink', 'invisible', '', None]:
el = etree.fromstring(xml.format(highlight=invalid_color))
expected_attr = { 'class': {
'value': 'annotatable-span highlight',
'_delete': 'highlight' }
}
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
self.assertTrue(type(actual_attr) is dict)
self.assertDictEqual(expected_attr, actual_attr)
def test_render_annotation(self):
expected_html = '<span class="annotatable-span highlight highlight-yellow" data-comment-title="x" data-comment-body="y" data-problem-id="0">z</span>'
expected_el = etree.fromstring(expected_html)
actual_el = etree.fromstring('<annotation title="x" body="y" problem="0" highlight="yellow">z</annotation>')
self.annotatable._render_annotation(0, actual_el)
self.assertEqual(expected_el.tag, actual_el.tag)
self.assertEqual(expected_el.text, actual_el.text)
self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib))
def test_render_content(self):
content = self.annotatable._render_content()
el = etree.fromstring(content)
self.assertEqual('div', el.tag, 'root tag is a div')
expected_num_annotations = 5
actual_num_annotations = el.xpath('count(//span[contains(@class,"annotatable-span")])')
self.assertEqual(expected_num_annotations, actual_num_annotations, 'check number of annotations')
def test_get_html(self):
context = self.annotatable.get_html()
for key in ['display_name', 'element_id', 'content_html', 'instructions_html']:
self.assertIn(key, context)
def test_extract_instructions(self):
xmltree = etree.fromstring(self.sample_xml)
expected_xml = u"<div>Read the text.</div>"
actual_xml = self.annotatable._extract_instructions(xmltree)
self.assertIsNotNone(actual_xml)
self.assertEqual(expected_xml.strip(), actual_xml.strip())
xmltree = etree.fromstring('<annotatable>foo</annotatable>')
actual = self.annotatable._extract_instructions(xmltree)
self.assertIsNone(actual)
......@@ -54,7 +54,8 @@ class OpenEndedChildTest(unittest.TestCase):
descriptor = Mock()
def setUp(self):
self.openendedchild = OpenEndedChild(test_system, self.location,
self.test_system = test_system()
self.openendedchild = OpenEndedChild(self.test_system, self.location,
self.definition, self.descriptor, self.static_data, self.metadata)
......@@ -69,7 +70,7 @@ class OpenEndedChildTest(unittest.TestCase):
def test_latest_post_assessment_empty(self):
answer = self.openendedchild.latest_post_assessment(test_system)
answer = self.openendedchild.latest_post_assessment(self.test_system)
self.assertEqual(answer, "")
......@@ -106,7 +107,7 @@ class OpenEndedChildTest(unittest.TestCase):
post_assessment = "Post assessment"
self.openendedchild.record_latest_post_assessment(post_assessment)
self.assertEqual(post_assessment,
self.openendedchild.latest_post_assessment(test_system))
self.openendedchild.latest_post_assessment(self.test_system))
def test_get_score(self):
new_answer = "New Answer"
......@@ -125,7 +126,7 @@ class OpenEndedChildTest(unittest.TestCase):
def test_reset(self):
self.openendedchild.reset(test_system)
self.openendedchild.reset(self.test_system)
state = json.loads(self.openendedchild.get_instance_state())
self.assertEqual(state['state'], OpenEndedChild.INITIAL)
......@@ -182,11 +183,13 @@ class OpenEndedModuleTest(unittest.TestCase):
descriptor = Mock()
def setUp(self):
test_system.location = self.location
self.test_system = test_system()
self.test_system.location = self.location
self.mock_xqueue = MagicMock()
self.mock_xqueue.send_to_queue.return_value = (None, "Message")
test_system.xqueue = {'interface': self.mock_xqueue, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 1}
self.openendedmodule = OpenEndedModule(test_system, self.location,
self.test_system.xqueue = {'interface': self.mock_xqueue, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 1}
self.openendedmodule = OpenEndedModule(self.test_system, self.location,
self.definition, self.descriptor, self.static_data, self.metadata)
def test_message_post(self):
......@@ -195,7 +198,7 @@ class OpenEndedModuleTest(unittest.TestCase):
'grader_id': '1',
'score': 3}
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
student_info = {'anonymous_student_id': test_system.anonymous_student_id,
student_info = {'anonymous_student_id': self.test_system.anonymous_student_id,
'submission_time': qtime}
contents = {
'feedback': get['feedback'],
......@@ -205,7 +208,7 @@ class OpenEndedModuleTest(unittest.TestCase):
'student_info': json.dumps(student_info)
}
result = self.openendedmodule.message_post(get, test_system)
result = self.openendedmodule.message_post(get, self.test_system)
self.assertTrue(result['success'])
# make sure it's actually sending something we want to the queue
self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY)
......@@ -216,7 +219,7 @@ class OpenEndedModuleTest(unittest.TestCase):
def test_send_to_grader(self):
submission = "This is a student submission"
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
student_info = {'anonymous_student_id': test_system.anonymous_student_id,
student_info = {'anonymous_student_id': self.test_system.anonymous_student_id,
'submission_time': qtime}
contents = self.openendedmodule.payload.copy()
contents.update({
......@@ -224,7 +227,7 @@ class OpenEndedModuleTest(unittest.TestCase):
'student_response': submission,
'max_score': self.max_score
})
result = self.openendedmodule.send_to_grader(submission, test_system)
result = self.openendedmodule.send_to_grader(submission, self.test_system)
self.assertTrue(result)
self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY)
......@@ -238,7 +241,7 @@ class OpenEndedModuleTest(unittest.TestCase):
}
get = {'queuekey': "abcd",
'xqueue_body': score_msg}
self.openendedmodule.update_score(get, test_system)
self.openendedmodule.update_score(get, self.test_system)
def update_score_single(self):
self.openendedmodule.new_history_entry("New Entry")
......@@ -261,11 +264,11 @@ class OpenEndedModuleTest(unittest.TestCase):
}
get = {'queuekey': "abcd",
'xqueue_body': json.dumps(score_msg)}
self.openendedmodule.update_score(get, test_system)
self.openendedmodule.update_score(get, self.test_system)
def test_latest_post_assessment(self):
self.update_score_single()
assessment = self.openendedmodule.latest_post_assessment(test_system)
assessment = self.openendedmodule.latest_post_assessment(self.test_system)
self.assertFalse(assessment == '')
# check for errors
self.assertFalse('errors' in assessment)
......@@ -336,7 +339,13 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
descriptor = Mock()
def setUp(self):
self.combinedoe = CombinedOpenEndedV1Module(test_system, self.location, self.definition, self.descriptor, static_data = self.static_data, metadata=self.metadata)
self.test_system = test_system()
self.combinedoe = CombinedOpenEndedV1Module(self.test_system,
self.location,
self.definition,
self.descriptor,
static_data = self.static_data,
metadata=self.metadata)
def test_get_tag_name(self):
name = self.combinedoe.get_tag_name("<t>Tag</t>")
......
......@@ -56,6 +56,9 @@ class ConditionalModuleTest(unittest.TestCase):
'''Get a dummy system'''
return DummySystem(load_error_modules)
def setUp(self):
self.test_system = test_system()
def get_course(self, name):
"""Get a test course by directory name. If there's more than one, error."""
print "Importing {0}".format(name)
......@@ -85,14 +88,14 @@ class ConditionalModuleTest(unittest.TestCase):
location = descriptor.location
instance_state = instance_states.get(location.category, None)
print "inner_get_module, location=%s, inst_state=%s" % (location, instance_state)
return descriptor.xmodule_constructor(test_system)(instance_state, shared_state)
return descriptor.xmodule_constructor(self.test_system)(instance_state, shared_state)
location = Location(["i4x", "edX", "cond_test", "conditional", "condone"])
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None):
return text
test_system.replace_urls = replace_urls
test_system.get_module = inner_get_module
self.test_system.replace_urls = replace_urls
self.test_system.get_module = inner_get_module
module = inner_get_module(location)
print "module: ", module
......
......@@ -19,9 +19,14 @@ class ContentTest(unittest.TestCase):
content = StaticContent('loc', 'name', 'content_type', 'data')
self.assertIsNone(content.thumbnail_location)
def test_generate_thumbnail_nonimage(self):
def test_generate_thumbnail_image(self):
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)
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)
......@@ -53,13 +53,13 @@ class SelfAssessmentTest(unittest.TestCase):
'skip_basic_checks' : False,
}
self.module = SelfAssessmentModule(test_system, self.location,
self.module = SelfAssessmentModule(test_system(), self.location,
self.definition, self.descriptor,
static_data,
state, metadata=self.metadata)
def test_get_html(self):
html = self.module.get_html(test_system)
html = self.module.get_html(self.module.system)
self.assertTrue("This is sample prompt text" in html)
def test_self_assessment_flow(self):
......@@ -82,10 +82,11 @@ class SelfAssessmentTest(unittest.TestCase):
self.assertEqual(self.module.get_score()['score'], 0)
self.module.save_answer({'student_answer': "I am an answer"}, test_system)
self.module.save_answer({'student_answer': "I am an answer"},
self.module.system)
self.assertEqual(self.module.state, self.module.ASSESSING)
self.module.save_assessment(mock_query_dict, test_system)
self.module.save_assessment(mock_query_dict, self.module.system)
self.assertEqual(self.module.state, self.module.DONE)
......@@ -94,7 +95,8 @@ class SelfAssessmentTest(unittest.TestCase):
self.assertEqual(self.module.state, self.module.INITIAL)
# if we now assess as right, skip the REQUEST_HINT state
self.module.save_answer({'student_answer': 'answer 4'}, test_system)
self.module.save_answer({'student_answer': 'answer 4'},
self.module.system)
responses['assessment'] = '1'
self.module.save_assessment(mock_query_dict, test_system)
self.module.save_assessment(mock_query_dict, self.module.system)
self.assertEqual(self.module.state, self.module.DONE)
......@@ -379,7 +379,11 @@ class XmlDescriptor(XModuleDescriptor):
if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy:
val = val_for_xml(attr)
#logging.debug('location.category = {0}, attr = {1}'.format(self.location.category, attr))
xml_object.set(attr, val)
try:
xml_object.set(attr, val)
except Exception, e:
logging.exception('Failed to serialize metadata attribute {0} with value {1}. This could mean data loss!!! Exception: {2}'.format(attr, val, e))
pass
if self.export_to_file():
# Write the definition to a file
......
......@@ -88,7 +88,7 @@ if Backbone?
if @$('section.discussion').length
@$('section.discussion').replaceWith($discussion)
else
$(".discussion-module").append($discussion)
@$el.append($discussion)
@newPostForm = $('.new-post-article')
@threadviews = @discussion.map (thread) ->
new DiscussionThreadInlineView el: @$("article#thread_#{thread.id}"), model: thread
......
(function () {
var debug = false;
var module = {
debug: debug,
inputSelector: '.annotation-input',
tagSelector: '.tag',
tagsSelector: '.tags',
commentSelector: 'textarea.comment',
valueSelector: 'input.value', // stash tag selections and comment here as a JSON string...
singleSelect: true,
init: function() {
var that = this;
if(this.debug) { console.log('annotation input loaded: '); }
$(this.inputSelector).each(function(index, el) {
if(!$(el).data('listening')) {
$(el).delegate(that.tagSelector, 'click', $.proxy(that.onClickTag, that));
$(el).delegate(that.commentSelector, 'change', $.proxy(that.onChangeComment, that));
$(el).data('listening', 'yes');
}
});
},
onChangeComment: function(e) {
var value_el = this.findValueEl(e.target);
var current_value = this.loadValue(value_el);
var target_value = $(e.target).val();
current_value.comment = target_value;
this.storeValue(value_el, current_value);
},
onClickTag: function(e) {
var target_el = e.target, target_value, target_index;
var value_el, current_value;
value_el = this.findValueEl(e.target);
current_value = this.loadValue(value_el);
target_value = $(e.target).data('id');
if(!$(target_el).hasClass('selected')) {
if(this.singleSelect) {
current_value.options = [target_value]
} else {
current_value.options.push(target_value);
}
} else {
if(this.singleSelect) {
current_value.options = []
} else {
target_index = current_value.options.indexOf(target_value);
if(target_index !== -1) {
current_value.options.splice(target_index, 1);
}
}
}
this.storeValue(value_el, current_value);
if(this.singleSelect) {
$(target_el).closest(this.tagsSelector)
.find(this.tagSelector)
.not(target_el)
.removeClass('selected')
}
$(target_el).toggleClass('selected');
},
findValueEl: function(target_el) {
var input_el = $(target_el).closest(this.inputSelector);
return $(this.valueSelector, input_el);
},
loadValue: function(value_el) {
var json = $(value_el).val();
var result = JSON.parse(json);
if(result === null) {
result = {};
}
if(!result.hasOwnProperty('options')) {
result.options = [];
}
if(!result.hasOwnProperty('comment')) {
result.comment = '';
}
return result;
},
storeValue: function(value_el, new_value) {
var json = JSON.stringify(new_value);
$(value_el).val(json);
}
}
module.init();
}).call(this);
......@@ -11,9 +11,14 @@
}
prev_id = "#" + this.id + "_preview";
preview_div = $(prev_id)
preview_div = $(prev_id);
$.get("/preview/chemcalc/", {"formula" : this.value}, create_handler(preview_div));
// find the closest parent problems-wrapper and use that url
url = $(this).closest('.problems-wrapper').data('url');
// grab the input id from the input
input_id = $(this).data('input-id')
Problem.inputAjax(url, input_id, 'preview_chemcalc', {"formula" : this.value}, create_handler(preview_div));
}
inputs = $('.chemicalequationinput input');
......
(function () {
var timeout = 1000;
function initializeApplet(applet) {
console.log("Initializing " + applet);
waitForApplet(applet);
}
waitForGenex();
function waitForApplet(applet) {
if (applet.isActive && applet.isActive()) {
console.log("Applet is ready.");
var answerStr = applet.checkAnswer();
console.log(answerStr);
var input = $('.editageneinput input');
console.log(input);
input.val(answerStr);
} else if (timeout > 30 * 1000) {
console.error("Applet did not load on time.");
} else {
console.log("Waiting for applet...");
setTimeout(function() { waitForApplet(applet); }, timeout);
function waitForGenex() {
if (typeof(genex) !== "undefined" && genex) {
genex.onInjectionDone("genex");
}
else {
setTimeout(function() { waitForGenex(); }, timeout);
}
}
var applets = $('.editageneinput object');
applets.each(function(i, el) { initializeApplet(el); });
//NOTE:
// Genex uses six global functions:
// genexSetDNASequence (exported from GWT)
// genexSetClickEvent (exported from GWT)
// genexSetKeyEvent (exported from GWT)
// genexSetProblemNumber (exported from GWT)
//
// It calls genexIsReady with a deferred command when it has finished
// initialization and has drawn itself
// genexStoreAnswer(answer) is called when the GWT [Store Answer] button
// is clicked
genexIsReady = function() {
//Load DNA sequence
var dna_sequence = $('#dna_sequence').val();
genexSetDNASequence(dna_sequence);
//Now load mouse and keyboard handlers
genexSetClickEvent();
genexSetKeyEvent();
//Now load problem
var genex_problem_number = $('#genex_problem_number').val();
genexSetProblemNumber(genex_problem_number);
};
genexStoreAnswer = function(ans) {
var problem = $('#genex_container').parents('.problem');
var input_field = problem.find('input[type="hidden"][name!="dna_sequence"][name!="genex_problem_number"]');
input_field.val(ans);
};
}).call(this);
.genex-button {
margin-right: -8px;
height: 40px !important;
}
.genex-label {
/*font: normal normal normal 10pt/normal 'Open Sans', Verdana, Geneva, sans-serif !important;*/
/*padding: 4px 0px 0px 10px !important;*/
font-family: sans-serif !important;
font-size: 13px !important;
font-style: normal !important;
font-variant: normal !important;
font-weight: bold !important;
padding-top: 6px !important;
margin-left: 18px;
}
.gwt-HTML {
cursor: default;
overflow-x: auto !important;
overflow-y: auto !important;
background-color: rgb(248, 248, 248) !important;
}
.genex-scrollpanel {
word-wrap: normal !important;
white-space: pre !important;
}
pre, #dna-strand {
font-family: 'courier new', courier !important;
font-size: 13px !important;
font-style: normal !important;
font-variant: normal !important;
font-weight: normal !important;
border-style: none !important;
background-color: rgb(248, 248, 248) !important;
word-wrap: normal !important;
white-space: pre !important;
overflow-x: visible !important;
overflow-y: visible !important;
}
.gwt-DialogBox .Caption {
background: #F1F1F1;
padding: 4px 8px 4px 4px;
cursor: default;
font-family: Arial Unicode MS, Arial, sans-serif;
font-weight: bold;
border-bottom: 1px solid #bbbbbb;
border-top: 1px solid #D2D2D2;
}
.gwt-DialogBox .dialogContent {
}
.gwt-DialogBox .dialogMiddleCenter {
padding: 3px;
background: white;
}
.gwt-DialogBox .dialogBottomCenter {
}
.gwt-DialogBox .dialogMiddleLeft {
}
.gwt-DialogBox .dialogMiddleRight {
}
.gwt-DialogBox .dialogTopLeftInner {
width: 10px;
height: 8px;
zoom: 1;
}
.gwt-DialogBox .dialogTopRightInner {
width: 12px;
zoom: 1;
}
.gwt-DialogBox .dialogBottomLeftInner {
width: 10px;
height: 12px;
zoom: 1;
}
.gwt-DialogBox .dialogBottomRightInner {
width: 12px;
height: 12px;
zoom: 1;
}
.gwt-DialogBox .dialogTopLeft {
}
.gwt-DialogBox .dialogTopRight {
}
.gwt-DialogBox .dialogBottomLeft {
}
.gwt-DialogBox .dialogBottomRight {
}
* html .gwt-DialogBox .dialogTopLeftInner {
width: 10px;
overflow: hidden;
}
* html .gwt-DialogBox .dialogTopRightInner {
width: 12px;
overflow: hidden;
}
* html .gwt-DialogBox .dialogBottomLeftInner {
width: 10px;
height: 12px;
overflow: hidden;
}
* html .gwt-DialogBox .dialogBottomRightInner {
width: 12px;
height: 12px;
overflow: hidden;
}
\ No newline at end of file
function genex(){var P='',xb='" for "gwt:onLoadErrorFn"',vb='" for "gwt:onPropertyErrorFn"',ib='"><\/script>',Z='#',Xb='.cache.html',_='/',lb='//',Qb='026A6180B5959B8660E084245FEE5E9E',Rb='1F433010E1134C95BF6CB43F552F3019',Sb='2DDA730EDABB80B88A6B0DFA3AFEACA2',Tb='4EEB1DCF4B30D366C27968D1B5C0BD04',Ub='5033ABB047340FB9346B622E2CC7107D',Wb=':',pb='::',dc='<script defer="defer">genex.onInjectionDone(\'genex\')<\/script>',hb='<script id="',sb='=',$='?',ub='Bad handler "',Vb='DF3D3A7FAEE63D711CF2D95BDB3F538C',cc='DOMContentLoaded',jb='SCRIPT',gb='__gwt_marker_genex',kb='base',cb='baseUrl',T='begin',S='bootstrap',bb='clear.cache.gif',rb='content',Y='end',Kb='gecko',Lb='gecko1_8',Q='genex',Yb='genex.css',eb='genex.nocache.js',ob='genex::',U='gwt.codesvr=',V='gwt.hosted=',W='gwt.hybrid',wb='gwt:onLoadErrorFn',tb='gwt:onPropertyErrorFn',qb='gwt:property',bc='head',Ob='hosted.html?genex',ac='href',Jb='ie6',Ib='ie8',Hb='ie9',yb='iframe',ab='img',zb="javascript:''",Zb='link',Nb='loadExternalRefs',mb='meta',Bb='moduleRequested',X='moduleStartup',Gb='msie',nb='name',Db='opera',Ab='position:absolute;width:0;height:0;border:none',$b='rel',Fb='safari',db='script',Pb='selectingPermutation',R='startup',_b='stylesheet',fb='undefined',Mb='unknown',Cb='user.agent',Eb='webkit';var m=window,n=document,o=m.__gwtStatsEvent?function(a){return m.__gwtStatsEvent(a)}:null,p=m.__gwtStatsSessionId?m.__gwtStatsSessionId:null,q,r,s,t=P,u={},v=[],w=[],x=[],y=0,z,A;o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:S,millis:(new Date).getTime(),type:T});if(!m.__gwt_stylesLoaded){m.__gwt_stylesLoaded={}}if(!m.__gwt_scriptsLoaded){m.__gwt_scriptsLoaded={}}function B(){var b=false;try{var c=m.location.search;return (c.indexOf(U)!=-1||(c.indexOf(V)!=-1||m.external&&m.external.gwtOnLoad))&&c.indexOf(W)==-1}catch(a){}B=function(){return b};return b}
function C(){if(q&&r){var b=n.getElementById(Q);var c=b.contentWindow;if(B()){c.__gwt_getProperty=function(a){return H(a)}}genex=null;c.gwtOnLoad(z,Q,t,y);o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:X,millis:(new Date).getTime(),type:Y})}}
function D(){function e(a){var b=a.lastIndexOf(Z);if(b==-1){b=a.length}var c=a.indexOf($);if(c==-1){c=a.length}var d=a.lastIndexOf(_,Math.min(c,b));return d>=0?a.substring(0,d+1):P}
function f(a){if(a.match(/^\w+:\/\//)){}else{var b=n.createElement(ab);b.src=a+bb;a=e(b.src)}return a}
function g(){var a=F(cb);if(a!=null){return a}return P}
function h(){var a=n.getElementsByTagName(db);for(var b=0;b<a.length;++b){if(a[b].src.indexOf(eb)!=-1){return e(a[b].src)}}return P}
function i(){var a;if(typeof isBodyLoaded==fb||!isBodyLoaded()){var b=gb;var c;n.write(hb+b+ib);c=n.getElementById(b);a=c&&c.previousSibling;while(a&&a.tagName!=jb){a=a.previousSibling}if(c){c.parentNode.removeChild(c)}if(a&&a.src){return e(a.src)}}return P}
function j(){var a=n.getElementsByTagName(kb);if(a.length>0){return a[a.length-1].href}return P}
function k(){var a=n.location;return a.href==a.protocol+lb+a.host+a.pathname+a.search+a.hash}
var l=g();if(l==P){l=h()}if(l==P){l=i()}if(l==P){l=j()}if(l==P&&k()){l=e(n.location.href)}l=f(l);t=l;return l}
function E(){var b=document.getElementsByTagName(mb);for(var c=0,d=b.length;c<d;++c){var e=b[c],f=e.getAttribute(nb),g;if(f){f=f.replace(ob,P);if(f.indexOf(pb)>=0){continue}if(f==qb){g=e.getAttribute(rb);if(g){var h,i=g.indexOf(sb);if(i>=0){f=g.substring(0,i);h=g.substring(i+1)}else{f=g;h=P}u[f]=h}}else if(f==tb){g=e.getAttribute(rb);if(g){try{A=eval(g)}catch(a){alert(ub+g+vb)}}}else if(f==wb){g=e.getAttribute(rb);if(g){try{z=eval(g)}catch(a){alert(ub+g+xb)}}}}}}
function F(a){var b=u[a];return b==null?null:b}
function G(a,b){var c=x;for(var d=0,e=a.length-1;d<e;++d){c=c[a[d]]||(c[a[d]]=[])}c[a[e]]=b}
function H(a){var b=w[a](),c=v[a];if(b in c){return b}var d=[];for(var e in c){d[c[e]]=e}if(A){A(a,d,b)}throw null}
var I;function J(){if(!I){I=true;var a=n.createElement(yb);a.src=zb;a.id=Q;a.style.cssText=Ab;a.tabIndex=-1;n.body.appendChild(a);o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:X,millis:(new Date).getTime(),type:Bb});a.contentWindow.location.replace(t+L)}}
w[Cb]=function(){var b=navigator.userAgent.toLowerCase();var c=function(a){return parseInt(a[1])*1000+parseInt(a[2])};if(function(){return b.indexOf(Db)!=-1}())return Db;if(function(){return b.indexOf(Eb)!=-1}())return Fb;if(function(){return b.indexOf(Gb)!=-1&&n.documentMode>=9}())return Hb;if(function(){return b.indexOf(Gb)!=-1&&n.documentMode>=8}())return Ib;if(function(){var a=/msie ([0-9]+)\.([0-9]+)/.exec(b);if(a&&a.length==3)return c(a)>=6000}())return Jb;if(function(){return b.indexOf(Kb)!=-1}())return Lb;return Mb};v[Cb]={gecko1_8:0,ie6:1,ie8:2,ie9:3,opera:4,safari:5};genex.onScriptLoad=function(){if(I){r=true;C()}};genex.onInjectionDone=function(){q=true;o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:Nb,millis:(new Date).getTime(),type:Y});C()};E();D();var K;var L;if(B()){if(m.external&&(m.external.initModule&&m.external.initModule(Q))){m.location.reload();return}L=Ob;K=P}o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:S,millis:(new Date).getTime(),type:Pb});if(!B()){try{G([Fb],Qb);G([Lb],Rb);G([Hb],Sb);G([Jb],Tb);G([Db],Ub);G([Ib],Vb);K=x[H(Cb)];var M=K.indexOf(Wb);if(M!=-1){y=Number(K.substring(M+1));K=K.substring(0,M)}L=K+Xb}catch(a){return}}var N;function O(){if(!s){s=true;if(!__gwt_stylesLoaded[Yb]){var a=n.createElement(Zb);__gwt_stylesLoaded[Yb]=a;a.setAttribute($b,_b);a.setAttribute(ac,t+Yb);n.getElementsByTagName(bc)[0].appendChild(a)}C();if(n.removeEventListener){n.removeEventListener(cc,O,false)}if(N){clearInterval(N)}}}
if(n.addEventListener){n.addEventListener(cc,function(){J();O()},false)}var N=setInterval(function(){if(/loaded|complete/.test(n.readyState)){J();O()}},50);o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:S,millis:(new Date).getTime(),type:Y});o&&o({moduleName:Q,sessionId:p,subSystem:R,evtGroup:Nb,millis:(new Date).getTime(),type:T});n.write(dc)}
genex();
\ No newline at end of file
......@@ -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.
* `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.
* - `pdf_textbooks`
- have pdf-based textbooks on tabs in the courseware. See below for details on config.
Available metadata
......@@ -508,13 +510,15 @@ If you want to customize the courseware tabs displayed for your course, specify
"url_slug": "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.
* 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 `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.
* 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
* - `course_info`
- Parameter `name`.
* - `wiki`
- arameter `name`.
- Parameter `name`.
* - `discussion`
- Parameter `name`.
* - `external_link`
- Parameters `name`, `link`.
* - `textbooks`
- 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`
- Parameter `name`.
* - `static_tab`
......@@ -541,6 +547,139 @@ If you want to customize the courseware tabs displayed for your course, specify
* - `staff_grading`
- No parameters. If specified, displays the staff grading tab for instructors.
*********
Textbooks
*********
Support is currently provided for image-based and PDF-based textbooks. In addition to enabling the display of textbooks in tabs (see above), specific information about the location of textbook content must be configured.
Image-based Textbooks
=====================
Configuration
-------------
Image-based textbooks are configured at the course level in the XML markup. Here is an example:
.. code-block:: xml
<course>
<textbook title="Textbook 1" book_url="https://www.example.com/textbook_1/" />
<textbook title="Textbook 2" book_url="https://www.example.com/textbook_2/" />
<chapter url_name="Overview">
<chapter url_name="First week">
</course>
Each `textbook` element is displayed on a different tab. The `title` attribute is used as the tab's name, and the `book_url` attribute points to the remote directory that contains the images of the text. Note the trailing slash on the end of the `book_url` attribute.
The images must be stored in the same directory as the `book_url`, with filenames matching `pXXX.png`, where `XXX` is a three-digit number representing the page number (with leading zeroes as necessary). Pages start at `p001.png`.
Each textbook must also have its own table of contents. This is read from the `book_url` location, by appending `toc.xml`. This file contains a `table_of_contents` parent element, with `entry` elements nested below it. Each `entry` has attributes for `name`, `page_label`, and `page`, as well as an optional `chapter` attribute. An arbitrary number of levels of nesting of `entry` elements within other `entry` elements is supported, but you're likely to only want two levels. The `page` represents the actual page to link to, while the `page_label` matches the displayed page number on that page. Here's an example:
.. code-block:: xml
<table_of_contents>
<entry page="1" page_label="i" name="Title" />
<entry page="2" page_label="ii" name="Preamble">
<entry page="2" page_label="ii" name="Copyright"/>
<entry page="3" page_label="iii" name="Brief Contents"/>
<entry page="5" page_label="v" name="Contents"/>
<entry page="9" page_label="1" name="About the Authors"/>
<entry page="10" page_label="2" name="Acknowledgments"/>
<entry page="11" page_label="3" name="Dedication"/>
<entry page="12" page_label="4" name="Preface"/>
</entry>
<entry page="15" page_label="7" name="Introduction to edX" chapter="1">
<entry page="15" page_label="7" name="edX in the Modern World"/>
<entry page="18" page_label="10" name="The edX Method"/>
<entry page="18" page_label="10" name="A Description of edX"/>
<entry page="29" page_label="21" name="A Brief History of edX"/>
<entry page="51" page_label="43" name="Introduction to edX"/>
<entry page="56" page_label="48" name="Endnotes"/>
</entry>
<entry page="73" page_label="65" name="Art and Photo Credits" chapter="30">
<entry page="73" page_label="65" name="Molecular Models"/>
<entry page="73" page_label="65" name="Photo Credits"/>
</entry>
<entry page="77" page_label="69" name="Index" />
</table_of_contents>
Linking from Content
--------------------
It is possible to add links to specific pages in a textbook by using a URL that encodes the index of the textbook and the page number. The URL is of the form `/course/book/${bookindex}/$page}`. If the page is omitted from the URL, the first page is assumed.
You can use a `customtag` to create a template for such links. For example, you can create a `book` template in the `customtag` directory, containing:
.. code-block:: xml
<img src="/static/images/icons/textbook_icon.png"/> More information given in <a href="/course/book/${book}/${page}">the text</a>.
The course content can then link to page 25 using the `customtag` element:
.. code-block:: xml
<customtag book="0" page="25" impl="book"/>
PDF-based Textbooks
===================
Configuration
-------------
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/thiscourse/book1/book1.pdf" },
{"tab_title": "Textbook 2",
"chapters": [
{ "title": "Chapter 1", "url": "https://www.example.com/thiscourse/book2/Chapter1.pdf" },
{ "title": "Chapter 2", "url": "https://www.example.com/thiscourse/book2/Chapter2.pdf" },
{ "title": "Chapter 3", "url": "https://www.example.com/thiscourse/book2/Chapter3.pdf" },
{ "title": "Chapter 4", "url": "https://www.example.com/thiscourse/book2/Chapter4.pdf" },
{ "title": "Chapter 5", "url": "https://www.example.com/thiscourse/book2/Chapter5.pdf" },
{ "title": "Chapter 6", "url": "https://www.example.com/thiscourse/book2/Chapter6.pdf" },
{ "title": "Chapter 7", "url": "https://www.example.com/thiscourse/book2/Chapter7.pdf" }
]
}
]
Some notes:
* It is not a good idea to include a top-level URL and chapter-level URLs in the same textbook configuration.
Linking from Content
--------------------
It is possible to add links to specific pages in a textbook by using a URL that encodes the index of the textbook, the chapter (if chapters are used), and the page number. For a book with no chapters, the URL is of the form `/course/pdfbook/${bookindex}/$page}`. For a book with chapters, use `/course/pdfbook/${bookindex}/chapter/${chapter}/${page}`. If the page is omitted from the URL, the first page is assumed.
For example, for the book with no chapters configured above, page 25 can be reached using the URL `/course/pdfbook/0/25`. Reaching page 19 in the third chapter of the second book is accomplished with `/course/pdfbook/1/chapter/3/19`.
You can use a `customtag` to create a template for such links. For example, you can create a `pdfbook` template in the `customtag` directory, containing:
.. code-block:: xml
<img src="/static/images/icons/textbook_icon.png"/> More information given in <a href="/course/pdfbook/${book}/${page}">the text</a>.
And a `pdfchapter` template containing:
.. code-block:: xml
<img src="/static/images/icons/textbook_icon.png"/> More information given in <a href="/course/pdfbook/${book}/chapter/${chapter}/${page}">the text</a>.
The example pages can then be linked using the `customtag` element:
.. code-block:: xml
<customtag book="0" page="25" impl="pdfbook"/>
<customtag book="1" chapter="3" page="19" impl="pdfchapter"/>
*************************************
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.
......@@ -83,9 +83,58 @@ the slider.
If no targets are provided, then a draggable can be dragged and placed anywhere
on the base image.
correct answer format
Targets on draggables
---------------------
Sometimes it is not enough to have targets only on the base image, and all of the
draggables on these targets. If a complex problem exists where a draggable must
become itself a target (or many targets), then the following extended syntax
can be used: ::
<draggable {attribute list}>
<target {attribute list} />
<target {attribute list} />
<target {attribute list} />
...
</draggable>
The attribute list in the tags above ('draggable' and 'target') is the same as for
normal 'draggable' and 'target' tags. The only difference is when you will be
specifying inner target position coordinates. Using the 'x' and 'y' attributes you
are setting the offset of the inner target from the upper-left corner of the
parent draggable (that contains the inner target).
Limitations of targets on draggables
------------------------------------
1.) Currently there is a limitation to the level of nesting of targets.
Even though you can pile up a large number of draggables on targets that themselves
are on draggables, the Drag and Drop instance will be graded only in the case if
there is a maximum of two levels of targets. The first level are the "base" targets.
They are attached to the base image. The second level are the targets defined on
draggables.
2.) Another limitation is that the target bounds are not checked against
other targets.
For now, it is the responsibility of the person who is constructing the course
material to make sure that there is no overlapping of targets. It is also preferable
that targets on draggables are smaller than the actual parent draggable. Technically
this is not necessary, but from the usability perspective it is desirable.
3.) You can have targets on draggables only in the case when there are base targets
defined (base targets are attached to the base image).
If you do not have base targets, then you can only have a single level of nesting
(draggables on the base image). In this case the client side will be reporting (x,y)
positions of each draggables on the base image.
Correct answer format
---------------------
(NOTE: For specifying answers for targets on draggables please see next section.)
There are two correct answer formats: short and long
If short from correct answer is mapping of 'draggable_id' to 'target_id'::
......@@ -180,7 +229,7 @@ Rules are: exact, anyof, unordered_equal, anyof+number, unordered_equal+number
'rule': 'unordered_equal'
}]
- And sometimes you want to allow drag only two 'b' draggables, in these case you sould use 'anyof+number' of 'unordered_equal+number' rule::
- And sometimes you want to allow drag only two 'b' draggables, in these case you should use 'anyof+number' of 'unordered_equal+number' rule::
correct_answer = [
{
......@@ -204,6 +253,54 @@ for same number of draggables, anyof is equal to unordered_equal
If we have can_reuse=true, than one must use only long form of correct answer.
Answer format for targets on draggables
---------------------------------------
As with the cases described above, an answer must provide precise positioning for
each draggable (on which targets it must reside). In the case when a draggable must
be placed on a target that itself is on a draggable, then the answer must contain
the chain of target-draggable-target. It is best to understand this on an example.
Suppose we have three draggables - 'up', 's', and 'p'. Draggables 's', and 'p' have targets
on themselves. More specifically, 'p' has three targets - '1', '2', and '3'. The first
requirement is that 's', and 'p' are positioned on specific targets on the base image.
The second requirement is that draggable 'up' is positioned on specific targets of
draggable 'p'. Below is an excerpt from a problem.::
<draggable id="up" icon="/static/images/images_list/lcao-mo/up.png" can_reuse="true" />
<draggable id="s" icon="/static/images/images_list/lcao-mo/orbital_single.png" label="s orbital" can_reuse="true" >
<target id="1" x="0" y="0" w="32" h="32"/>
</draggable>
<draggable id="p" icon="/static/images/images_list/lcao-mo/orbital_triple.png" can_reuse="true" label="p orbital" >
<target id="1" x="0" y="0" w="32" h="32"/>
<target id="2" x="34" y="0" w="32" h="32"/>
<target id="3" x="68" y="0" w="32" h="32"/>
</draggable>
...
correct_answer = [
{
'draggables': ['p'],
'targets': ['p-left-target', 'p-right-target'],
'rule': 'unordered_equal'
},
{
'draggables': ['s'],
'targets': ['s-left-target', 's-right-target'],
'rule': 'unordered_equal'
},
{
'draggables': ['up'],
'targets': ['p-left-target[p][1]', 'p-left-target[p][2]', 'p-right-target[p][2]', 'p-right-target[p][3]',],
'rule': 'unordered_equal'
}
]
Note that it is a requirement to specify rules for all draggables, even if some draggable gets included
in more than one chain.
Grading logic
-------------
......@@ -321,3 +418,8 @@ Draggables can be reused
------------------------
.. literalinclude:: drag-n-drop-demo2.xml
Examples of targets on draggables
------------------------
.. literalinclude:: drag-n-drop-demo3.xml
......@@ -24,6 +24,7 @@ Specific Problem Types
course_data_formats/drag_and_drop/drag_and_drop_input.rst
course_data_formats/graphical_slider_tool/graphical_slider_tool.rst
course_data_formats/custom_response.rst
Internal Data Formats
......
......@@ -49,7 +49,7 @@ def course_wiki_redirect(request, course_id):
if not course_slug:
log.exception("This course is improperly configured. The slug cannot be empty.")
valid_slug = False
if re.match('^[-\w\.]+$', course_slug) == None:
if re.match('^[-\w\.]+$', course_slug) is None:
log.exception("This course is improperly configured. The slug can only contain letters, numbers, periods or hyphens.")
valid_slug = False
......
......@@ -140,7 +140,7 @@ def grade(student, request, course, student_module_cache=None, keep_raw_scores=F
grading_context = course.grading_context
raw_scores = []
if student_module_cache == None:
if student_module_cache is None:
student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors'])
totaled_scores = {}
......@@ -270,7 +270,7 @@ def progress_summary(student, request, course, student_module_cache):
# would be simpler
course_module = get_module(student, request,
course.location, student_module_cache,
course.id)
course.id, depth=None)
if not course_module:
# This student must not have access to the course.
return None
......
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'StudentModuleHistory'
db.create_table('courseware_studentmodulehistory', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('student_module', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['courseware.StudentModule'])),
('version', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('created', self.gf('django.db.models.fields.DateTimeField')(db_index=True)),
('state', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
('grade', self.gf('django.db.models.fields.FloatField')(null=True, blank=True)),
('max_grade', self.gf('django.db.models.fields.FloatField')(null=True, blank=True)),
))
db.send_create_signal('courseware', ['StudentModuleHistory'])
def backwards(self, orm):
# Deleting model 'StudentModuleHistory'
db.delete_table('courseware_studentmodulehistory')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'courseware.offlinecomputedgrade': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'courseware.offlinecomputedgradelog': {
'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'})
},
'courseware.studentmodule': {
'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}),
'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}),
'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}),
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'courseware.studentmodulehistory': {
'Meta': {'object_name': 'StudentModuleHistory'},
'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}),
'version': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
}
}
complete_apps = ['courseware']
\ No newline at end of file
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Changing field 'StudentModuleHistory.version'
db.alter_column('courseware_studentmodulehistory', 'version', self.gf('django.db.models.fields.CharField')(max_length=255, null=True))
def backwards(self, orm):
# User chose to not deal with backwards NULL issues for 'StudentModuleHistory.version'
raise RuntimeError("Cannot reverse this migration. 'StudentModuleHistory.version' and its values cannot be restored.")
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'courseware.offlinecomputedgrade': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'courseware.offlinecomputedgradelog': {
'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'})
},
'courseware.studentmodule': {
'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}),
'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}),
'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}),
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'courseware.studentmodulehistory': {
'Meta': {'object_name': 'StudentModuleHistory'},
'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}),
'version': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '255', 'null': 'True', 'db_index': 'True'})
}
}
complete_apps = ['courseware']
\ No newline at end of file
......@@ -12,8 +12,10 @@ file and check it in at the same time as your model changes. To do that,
ASSUMPTIONS: modules have unique IDs, even across different module_types
"""
from django.db import models
from django.contrib.auth.models import User
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
class StudentModule(models.Model):
"""
......@@ -60,6 +62,37 @@ class StudentModule(models.Model):
self.student.username, self.module_state_key, str(self.state)[:20]])
class StudentModuleHistory(models.Model):
"""Keeps a complete history of state changes for a given XModule for a given
Student. Right now, we restrict this to problems so that the table doesn't
explode in size."""
HISTORY_SAVING_TYPES = {'problem'}
class Meta:
get_latest_by = "created"
student_module = models.ForeignKey(StudentModule, db_index=True)
version = models.CharField(max_length=255, null=True, blank=True, db_index=True)
# This should be populated from the modified field in StudentModule
created = models.DateTimeField(db_index=True)
state = models.TextField(null=True, blank=True)
grade = models.FloatField(null=True, blank=True)
max_grade = models.FloatField(null=True, blank=True)
@receiver(post_save, sender=StudentModule)
def save_history(sender, instance, **kwargs):
if instance.module_type in StudentModuleHistory.HISTORY_SAVING_TYPES:
history_entry = StudentModuleHistory(student_module=instance,
version=None,
created=instance.modified,
state=instance.state,
grade=instance.grade,
max_grade=instance.max_grade)
history_entry.save()
# TODO (cpennington): Remove these once the LMS switches to using XModuleDescriptors
......
......@@ -16,7 +16,6 @@ from django.views.decorators.csrf import csrf_exempt
from requests.auth import HTTPBasicAuth
from capa.xqueue_interface import XQueueInterface
from capa.chem import chemcalc
from courseware.access import has_access
from mitxmako.shortcuts import render_to_string
from models import StudentModule, StudentModuleCache
......@@ -559,42 +558,6 @@ def modx_dispatch(request, dispatch, location, course_id):
return HttpResponse(ajax_return)
def preview_chemcalc(request):
"""
Render an html preview of a chemical formula or equation. The fact that
this is here is a bit of hack. See the note in lms/urls.py about why it's
here. (Victor is to blame.)
request should be a GET, with a key 'formula' and value 'some formula string'.
Returns a json dictionary:
{
'preview' : 'the-preview-html' or ''
'error' : 'the-error' or ''
}
"""
if request.method != "GET":
raise Http404
result = {'preview': '',
'error': ''}
formula = request.GET.get('formula')
if formula is None:
result['error'] = "No formula specified."
return HttpResponse(json.dumps(result))
try:
result['preview'] = chemcalc.render_to_html(formula)
except pyparsing.ParseException as p:
result['error'] = "Couldn't parse formula: {0}".format(p)
except Exception:
# this is unexpected, so log
log.warning("Error while previewing chemical formula", exc_info=True)
result['error'] = "Error while rendering preview"
return HttpResponse(json.dumps(result))
def get_score_bucket(grade, max_grade):
"""
......
import unittest
import logging
import time
from mock import Mock
from mock import Mock, MagicMock, patch
from django.conf import settings
from django.test import TestCase
from xmodule.course_module import CourseDescriptor
from xmodule.error_module import ErrorDescriptor
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
from factories import CourseEnrollmentAllowedFactory
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 courseware.models import StudentModule, StudentModuleCache
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 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_get_instance_module(self):
mock_user = MagicMock()
mock_user.is_authenticated.return_value = False
self.assertIsNone(render.get_instance_module('dummy', mock_user, 'dummy',
'dummy'))
mock_user_2 = MagicMock()
mock_user_2.is_authenticated.return_value = True
mock_module = MagicMock()
mock_module.descriptor.stores_state = False
self.assertIsNone(render.get_instance_module('dummy', mock_user_2,
mock_module, 'dummy'))
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)
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)
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)
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)
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()))
from django.test import TestCase
from mock import MagicMock
import courseware.tabs as tabs
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
class ProgressTestCase(TestCase):
def setUp(self):
self.mockuser1 = MagicMock()
self.mockuser0 = MagicMock()
self.course = MagicMock()
self.mockuser1.is_authenticated.return_value = True
self.mockuser0.is_authenticated.return_value = False
self.course.id = 'edX/full/6.002_Spring_2012'
self.tab = {'name': 'same'}
self.active_page1 = 'progress'
self.active_page0 = 'stagnation'
def test_progress(self):
self.assertEqual(tabs._progress(self.tab, self.mockuser0, self.course,
self.active_page0), [])
self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course,
self.active_page1)[0].name, 'same')
self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course,
self.active_page1)[0].link,
reverse('progress', args = [self.course.id]))
self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course,
self.active_page0)[0].is_active, False)
self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course,
self.active_page1)[0].is_active, True)
class WikiTestCase(TestCase):
def setUp(self):
self.user = MagicMock()
self.course = MagicMock()
self.course.id = 'edX/full/6.002_Spring_2012'
self.tab = {'name': 'same'}
self.active_page1 = 'wiki'
self.active_page0 = 'miki'
@override_settings(WIKI_ENABLED=True)
def test_wiki_enabled(self):
self.assertEqual(tabs._wiki(self.tab, self.user,
self.course, self.active_page1)[0].name,
'same')
self.assertEqual(tabs._wiki(self.tab, self.user,
self.course, self.active_page1)[0].link,
reverse('course_wiki', args=[self.course.id]))
self.assertEqual(tabs._wiki(self.tab, self.user,
self.course, self.active_page1)[0].is_active,
True)
self.assertEqual(tabs._wiki(self.tab, self.user,
self.course, self.active_page0)[0].is_active,
False)
@override_settings(WIKI_ENABLED=False)
def test_wiki_enabled_false(self):
self.assertEqual(tabs._wiki(self.tab, self.user,
self.course, self.active_page1), [])
class ExternalLinkTestCase(TestCase):
def setUp(self):
self.user = MagicMock()
self.course = MagicMock()
self.tabby = {'name': 'same', 'link': 'blink'}
self.active_page0 = None
self.active_page00 = True
def test_external_link(self):
self.assertEqual(tabs._external_link(self.tabby, self.user,
self.course, self.active_page0)[0].name,
'same')
self.assertEqual(tabs._external_link(self.tabby, self.user,
self.course, self.active_page0)[0].link,
'blink')
self.assertEqual(tabs._external_link(self.tabby, self.user,
self.course, self.active_page0)[0].is_active,
False)
self.assertEqual(tabs._external_link(self.tabby, self.user,
self.course, self.active_page00)[0].is_active,
False)
class StaticTabTestCase(TestCase):
def setUp(self):
self.user = MagicMock()
self.course = MagicMock()
self.tabby = {'name': 'same', 'url_slug': 'schmug'}
self.course.id = 'edX/full/6.002_Spring_2012'
self.active_page1 = 'static_tab_schmug'
self.active_page0 = 'static_tab_schlug'
def test_static_tab(self):
self.assertEqual(tabs._static_tab(self.tabby, self.user,
self.course, self.active_page1)[0].name,
'same')
self.assertEqual(tabs._static_tab(self.tabby, self.user,
self.course, self.active_page1)[0].link,
reverse('static_tab', args = [self.course.id,
self.tabby['url_slug']]))
self.assertEqual(tabs._static_tab(self.tabby, self.user,
self.course, self.active_page1)[0].is_active,
True)
self.assertEqual(tabs._static_tab(self.tabby, self.user,
self.course, self.active_page0)[0].is_active,
False)
class TextbooksTestCase(TestCase):
def setUp(self):
self.mockuser1 = MagicMock()
self.mockuser0 = MagicMock()
self.course = MagicMock()
self.tab = MagicMock()
A = MagicMock()
T = MagicMock()
self.mockuser1.is_authenticated.return_value = True
self.mockuser0.is_authenticated.return_value = False
self.course.id = 'edX/full/6.002_Spring_2012'
self.active_page0 = 'textbook/0'
self.active_page1 = 'textbook/1'
self.active_pageX = 'you_shouldnt_be_seein_this'
A.title = 'Algebra'
T.title = 'Topology'
self.course.textbooks = [A, T]
@override_settings(MITX_FEATURES={'ENABLE_TEXTBOOK': True})
def test_textbooks1(self):
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page0)[0].name,
'Algebra')
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page0)[0].link,
reverse('book', args=[self.course.id, 0]))
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page0)[0].is_active,
True)
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_pageX)[0].is_active,
False)
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page1)[1].name,
'Topology')
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page1)[1].link,
reverse('book', args=[self.course.id, 1]))
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_page1)[1].is_active,
True)
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_pageX)[1].is_active,
False)
@override_settings(MITX_FEATURES={'ENABLE_TEXTBOOK': False})
def test_textbooks0(self):
self.assertEqual(tabs._textbooks(self.tab, self.mockuser1,
self.course, self.active_pageX), [])
self.assertEqual(tabs._textbooks(self.tab, self.mockuser0,
self.course, self.active_pageX), [])
class KeyCheckerTestCase(TestCase):
def setUp(self):
self.expected_keys1 = ['a', 'b']
self.expected_keys0 = ['a', 'v', 'g']
self.dictio = {'a': 1, 'b': 2, 'c': 3}
def test_key_checker(self):
self.assertIsNone(tabs.key_checker(self.expected_keys1)(self.dictio))
self.assertRaises(tabs.InvalidTabsException,
tabs.key_checker(self.expected_keys0), self.dictio)
class NullValidatorTestCase(TestCase):
def setUp(self):
self.d = {}
def test_null_validator(self):
self.assertIsNone(tabs.null_validator(self.d))
class ValidateTabsTestCase(TestCase):
def setUp(self):
self.courses = [MagicMock() for i in range(0,5)]
self.courses[0].tabs = None
self.courses[1].tabs = [{'type':'courseware'}, {'type': 'fax'}]
self.courses[2].tabs = [{'type':'shadow'}, {'type': 'course_info'}]
self.courses[3].tabs = [{'type':'courseware'},{'type':'course_info', 'name': 'alice'},
{'type': 'wiki', 'name':'alice'}, {'type':'discussion', 'name': 'alice'},
{'type':'external_link', 'name': 'alice', 'link':'blink'},
{'type':'textbooks'}, {'type':'progress', 'name': 'alice'},
{'type':'static_tab', 'name':'alice', 'url_slug':'schlug'},
{'type': 'staff_grading'}]
self.courses[4].tabs = [{'type':'courseware'},{'type': 'course_info'}, {'type': 'flying'}]
def test_validate_tabs(self):
self.assertIsNone(tabs.validate_tabs(self.courses[0]))
self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[1])
self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[2])
self.assertIsNone(tabs.validate_tabs(self.courses[3]))
self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[4])
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