fix merge conflicts with master

This commit is contained in:
ihoover
2013-07-19 16:03:13 -04:00
267 changed files with 10788 additions and 1726 deletions

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ node_modules
autodeploy.properties
.ws_migrations_complete
.vagrant/
logs

View File

@@ -1,25 +1,25 @@
[main]
host = https://www.transifex.com
[edx-studio.django-partial]
[edx-platform.django-partial]
file_filter = conf/locale/<lang>/LC_MESSAGES/django-partial.po
source_file = conf/locale/en/LC_MESSAGES/django-partial.po
source_lang = en
type = PO
[edx-studio.djangojs]
[edx-platform.djangojs]
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs.po
source_file = conf/locale/en/LC_MESSAGES/djangojs.po
source_lang = en
type = PO
[edx-studio.mako]
[edx-platform.mako]
file_filter = conf/locale/<lang>/LC_MESSAGES/mako.po
source_file = conf/locale/en/LC_MESSAGES/mako.po
source_lang = en
type = PO
[edx-studio.messages]
[edx-platform.messages]
file_filter = conf/locale/<lang>/LC_MESSAGES/messages.po
source_file = conf/locale/en/LC_MESSAGES/messages.po
source_lang = en

View File

@@ -81,3 +81,4 @@ Felix Sun <felixsun@mit.edu>
Adam Palay <adam@edx.org>
Ian Hoover <ihoover@edx.org>
Mukul Goyal <miki@edx.org>
Robert Marks <rmarks@edx.org>

View File

@@ -5,10 +5,13 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Common: Added *experimental* support for jsinput type.
Common: Added setting to specify Celery Broker vhost
Common: Utilize new XBlock bulk save API in LMS and CMS.
Studio: Add table for tracking course creator permissions (not yet used).
Update rake django-admin[syncdb] and rake django-admin[migrate] so they
run for both LMS and CMS.
@@ -21,6 +24,8 @@ Studio: Added support for uploading and managing PDF textbooks
Common: Student information is now passed to the tracking log via POST instead of GET.
Blades: Added functionality and tests for new capa input type: choicetextresponse.
Common: Add tests for documentation generation to test suite
Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems
@@ -43,6 +48,13 @@ history of background tasks for a given problem and student.
Blades: Small UX fix on capa multiple-choice problems. Make labels only
as wide as the text to reduce accidental choice selections.
Studio:
- use xblock field defaults to initialize all new instances' fields and
only use templates as override samples.
- create new instances via in memory create_xmodule and related methods rather
than cloning a db record.
- have an explicit method for making a draft copy as distinct from making a new module.
Studio: Remove XML from the video component editor. All settings are
moved to be edited as metadata.

View File

@@ -239,7 +239,6 @@ CMS templates. Fortunately, `rake` will do all of this for you! Just run:
$ rake django-admin[syncdb]
$ rake django-admin[migrate]
$ rake cms:update_templates
If you are running these commands using the [`zsh`](http://www.zsh.org/) shell,
zsh will assume that you are doing

View File

@@ -20,8 +20,8 @@ def get_course_updates(location):
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
template = Location(['i4x', 'edx', "templates", 'course_info', "Empty"])
course_updates = modulestore('direct').clone_item(template, Location(location))
modulestore('direct').create_and_save_xmodule(location)
course_updates = modulestore('direct').get_item(location)
# current db rep: {"_id" : locationjson, "definition" : { "data" : "<ol>[<li><h2>date</h2>content</li>]</ol>"} "metadata" : ignored}
location_base = course_updates.location.url()

View File

@@ -208,7 +208,7 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
def i_created_a_video_component(step):
world.create_component_instance(
step, '.large-video-icon',
'i4x://edx/templates/video/default',
'video',
'.xmodule_VideoModule'
)

View File

@@ -7,9 +7,9 @@ from terrain.steps import reload_the_page
@world.absorb
def create_component_instance(step, component_button_css, instance_id, expected_css):
def create_component_instance(step, component_button_css, category, expected_css, boilerplate=None):
click_new_component_button(step, component_button_css)
click_component_from_menu(instance_id, expected_css)
click_component_from_menu(category, boilerplate, expected_css)
@world.absorb
@@ -19,7 +19,7 @@ def click_new_component_button(step, component_button_css):
@world.absorb
def click_component_from_menu(instance_id, expected_css):
def click_component_from_menu(category, boilerplate, expected_css):
"""
Creates a component from `instance_id`. For components with more
than one template, clicks on `elem_css` to create the new
@@ -27,11 +27,13 @@ def click_component_from_menu(instance_id, expected_css):
as the user clicks the appropriate button, so we assert that the
expected component is present.
"""
elem_css = "a[data-location='%s']" % instance_id
if boilerplate:
elem_css = "a[data-category='{}'][data-boilerplate='{}']".format(category, boilerplate)
else:
elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category)
elements = world.css_find(elem_css)
assert(len(elements) == 1)
if elements[0]['id'] == instance_id: # If this is a component with multiple templates
world.css_click(elem_css)
assert_equal(len(elements), 1)
world.css_click(elem_css)
assert_equal(1, len(world.css_find(expected_css)))

View File

@@ -8,7 +8,7 @@ from lettuce import world, step
def i_created_discussion_tag(step):
world.create_component_instance(
step, '.large-discussion-icon',
'i4x://edx/templates/discussion/Discussion_Tag',
'discussion',
'.xmodule_DiscussionModule'
)
@@ -17,14 +17,14 @@ def i_created_discussion_tag(step):
def i_see_only_the_settings_and_values(step):
world.verify_all_setting_entries(
[
['Category', "Week 1", True],
['Display Name', "Discussion Tag", True],
['Subcategory', "Topic-Level Student-Visible Label", True]
['Category', "Week 1", False],
['Display Name', "Discussion Tag", False],
['Subcategory', "Topic-Level Student-Visible Label", False]
])
@step('creating a discussion takes a single click')
def discussion_takes_a_single_click(step):
assert(not world.is_css_present('.xmodule_DiscussionModule'))
world.css_click("a[data-location='i4x://edx/templates/discussion/Discussion_Tag']")
world.css_click("a[data-category='discussion']")
assert(world.is_css_present('.xmodule_DiscussionModule'))

View File

@@ -7,11 +7,11 @@ from lettuce import world, step
@step('I have created a Blank HTML Page$')
def i_created_blank_html_page(step):
world.create_component_instance(
step, '.large-html-icon', 'i4x://edx/templates/html/Blank_HTML_Page',
step, '.large-html-icon', 'html',
'.xmodule_HtmlModule'
)
@step('I see only the HTML display name setting$')
def i_see_only_the_html_display_name(step):
world.verify_all_setting_entries([['Display Name', "Blank HTML Page", True]])
world.verify_all_setting_entries([['Display Name', "Blank HTML Page", False]])

View File

@@ -18,8 +18,9 @@ def i_created_blank_common_problem(step):
world.create_component_instance(
step,
'.large-problem-icon',
'i4x://edx/templates/problem/Blank_Common_Problem',
'.xmodule_CapaModule'
'problem',
'.xmodule_CapaModule',
'blank_common.yaml'
)
@@ -35,8 +36,8 @@ def i_see_five_settings_with_values(step):
[DISPLAY_NAME, "Blank Common Problem", True],
[MAXIMUM_ATTEMPTS, "", False],
[PROBLEM_WEIGHT, "", False],
[RANDOMIZATION, "Never", True],
[SHOW_ANSWER, "Finished", True]
[RANDOMIZATION, "Never", False],
[SHOW_ANSWER, "Finished", False]
])
@@ -94,7 +95,7 @@ def my_change_to_randomization_is_persisted(step):
def i_can_revert_to_default_for_randomization(step):
world.revert_setting_entry(RANDOMIZATION)
world.save_component_and_reopen(step)
world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Always", False)
world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Never", False)
@step('I can set the weight to "(.*)"?')
@@ -156,7 +157,7 @@ def create_latex_problem(step):
world.click_new_component_button(step, '.large-problem-icon')
# Go to advanced tab.
world.css_click('#ui-id-2')
world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule')
world.click_component_from_menu("problem", "latex_problem.yaml", '.xmodule_CapaModule')
@step('I edit and compile the High Level Source')
@@ -203,7 +204,7 @@ def verify_modified_display_name_with_special_chars():
def verify_unset_display_name():
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False)
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'Blank Advanced Problem', False)
def set_weight(weight):

View File

@@ -22,7 +22,7 @@ def have_a_course_with_1_section(step):
section = world.ItemFactory.create(parent_location=course.location)
subsection1 = world.ItemFactory.create(
parent_location=section.location,
template='i4x://edx/templates/sequential/Empty',
category='sequential',
display_name='Subsection One',)
@@ -33,18 +33,18 @@ def have_a_course_with_two_sections(step):
section = world.ItemFactory.create(parent_location=course.location)
subsection1 = world.ItemFactory.create(
parent_location=section.location,
template='i4x://edx/templates/sequential/Empty',
category='sequential',
display_name='Subsection One',)
section2 = world.ItemFactory.create(
parent_location=course.location,
display_name='Section Two',)
subsection2 = world.ItemFactory.create(
parent_location=section2.location,
template='i4x://edx/templates/sequential/Empty',
category='sequential',
display_name='Subsection Alpha',)
subsection3 = world.ItemFactory.create(
parent_location=section2.location,
template='i4x://edx/templates/sequential/Empty',
category='sequential',
display_name='Subsection Beta',)

View File

@@ -7,7 +7,7 @@ from lettuce import world, step
@step('I see the correct settings and default values$')
def i_see_the_correct_settings_and_values(step):
world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False],
['Display Name', 'default', True],
['Display Name', 'Video Title', False],
['Download Track', '', False],
['Download Video', '', False],
['Show Captions', 'True', False],

View File

@@ -14,7 +14,7 @@ def does_not_autoplay(_step):
@step('creating a video takes a single click')
def video_takes_a_single_click(_step):
assert(not world.is_css_present('.xmodule_VideoModule'))
world.css_click("a[data-location='i4x://edx/templates/video/default']")
world.css_click("a[data-category='video']")
assert(world.is_css_present('.xmodule_VideoModule'))

View File

@@ -0,0 +1,55 @@
from django.core.management.base import BaseCommand, CommandError
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.django import modulestore
from json import dumps
from xmodule.modulestore.inheritance import own_metadata
from django.conf import settings
filter_list = ['xml_attributes', 'checklists']
class Command(BaseCommand):
help = '''Write out to stdout a structural and metadata information about a course in a flat dictionary serialized
in a JSON format. This can be used for analytics.'''
def handle(self, *args, **options):
if len(args) < 2 or len(args) > 3:
raise CommandError("dump_course_structure requires two or more arguments: <location> <outfile> |<db>|")
course_id = args[0]
outfile = args[1]
# use a user-specified database name, if present
# this is useful for doing dumps from databases restored from prod backups
if len(args) == 3:
settings.MODULESTORE['direct']['OPTIONS']['db'] = args[2]
loc = CourseDescriptor.id_to_location(course_id)
store = modulestore()
course = None
try:
course = store.get_item(loc, depth=4)
except:
print 'Could not find course at {0}'.format(course_id)
return
info = {}
def dump_into_dict(module, info):
filtered_metadata = dict((key, value) for key, value in own_metadata(module).iteritems()
if key not in filter_list)
info[module.location.url()] = {
'category': module.location.category,
'children': module.children if hasattr(module, 'children') else [],
'metadata': filtered_metadata
}
for child in module.get_children():
dump_into_dict(child, info)
dump_into_dict(course, info)
with open(outfile, 'w') as f:
f.write(dumps(info))

View File

@@ -1,10 +0,0 @@
from xmodule.templates import update_templates
from xmodule.modulestore.django import modulestore
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(modulestore('direct'))

View File

@@ -3,13 +3,13 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import Location
def get_module_info(store, location, parent_location=None, rewrite_static_links=False):
def get_module_info(store, location, rewrite_static_links=False):
try:
module = store.get_item(location)
except ItemNotFoundError:
# create a new one
template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
module = store.clone_item(template_location, location)
store.create_and_save_xmodule(location)
module = store.get_item(location)
data = module.data
if rewrite_static_links:
@@ -29,7 +29,8 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
'id': module.location.url(),
'data': data,
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
'metadata': module._model_data._kvs._metadata
# what's the intent here? all metadata incl inherited & namespaced?
'metadata': module.xblock_kvs._metadata
}
@@ -37,14 +38,11 @@ def set_module_info(store, location, post_data):
module = None
try:
module = store.get_item(location)
except:
pass
if module is None:
# new module at this location
# presume that we have an 'Empty' template
template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
module = store.clone_item(template_location, location)
except ItemNotFoundError:
# new module at this location: almost always used for the course about pages; thus, no parent. (there
# are quite a handful of about page types available for a course and only the overview is pre-created)
store.create_and_save_xmodule(location)
module = store.get_item(location)
if post_data.get('data') is not None:
data = post_data['data']
@@ -79,4 +77,4 @@ def set_module_info(store, location, post_data):
# commit to datastore
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
store.update_metadata(location, module._model_data._kvs._metadata)
store.update_metadata(location, module.xblock_kvs._metadata)

View File

@@ -46,6 +46,8 @@ class ChecklistTestCase(CourseTestCase):
# Now delete the checklists from the course and verify they get repopulated (for courses
# created before checklists were introduced).
self.course.checklists = None
# Save the changed `checklists` to the underlying KeyValueStore before updating the modulestore
self.course.save()
modulestore = get_modulestore(self.course.location)
modulestore.update_metadata(self.course.location, own_metadata(self.course))
self.assertEqual(self.get_persisted_checklists(), None)

View File

@@ -24,12 +24,11 @@ from auth.authz import add_user_to_creator_group
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore import Location
from xmodule.modulestore import Location, mongo
from xmodule.modulestore.store_utilities import clone_course
from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore, _CONTENTSTORE
from xmodule.templates import update_templates
from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
from xmodule.modulestore.inheritance import own_metadata
@@ -88,6 +87,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.user.is_active = True
# Staff has access to view all courses
self.user.is_staff = True
# Save the data that we've just changed to the db.
self.user.save()
self.client = Client()
@@ -118,6 +119,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
course.advanced_modules = component_types
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
course.save()
store.update_metadata(course.location, own_metadata(course))
# just pick one vertical
@@ -135,7 +140,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha',
'Word cloud',
'Annotation',
'Open Ended Response',
'Open Response Assessment',
'Peer Grading Interface'])
def test_advanced_components_require_two_clicks(self):
@@ -183,7 +188,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
draft_store.clone_item(html_module.location, html_module.location)
draft_store.convert_to_draft(html_module.location)
# now query get_items() to get this location with revision=None, this should just
# return back a single item (not 2)
@@ -215,7 +220,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertEqual(html_module.lms.graceperiod, course.lms.graceperiod)
self.assertNotIn('graceperiod', own_metadata(html_module))
draft_store.clone_item(html_module.location, html_module.location)
draft_store.convert_to_draft(html_module.location)
# refetch to check metadata
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
@@ -233,13 +238,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertNotIn('graceperiod', own_metadata(html_module))
# put back in draft and change metadata and see if it's now marked as 'own_metadata'
draft_store.clone_item(html_module.location, html_module.location)
draft_store.convert_to_draft(html_module.location)
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
new_graceperiod = timedelta(hours=1)
self.assertNotIn('graceperiod', own_metadata(html_module))
html_module.lms.graceperiod = new_graceperiod
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
html_module.save()
self.assertIn('graceperiod', own_metadata(html_module))
self.assertEqual(html_module.lms.graceperiod, new_graceperiod)
@@ -255,7 +263,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
draft_store.publish(html_module.location, 0)
# and re-read and verify 'own-metadata'
draft_store.clone_item(html_module.location, html_module.location)
draft_store.convert_to_draft(html_module.location)
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
self.assertIn('graceperiod', own_metadata(html_module))
@@ -278,7 +286,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
)
# put into draft
modulestore('draft').clone_item(problem.location, problem.location)
modulestore('draft').convert_to_draft(problem.location)
# make sure we can query that item and verify that it is a draft
draft_problem = modulestore('draft').get_item(
@@ -309,12 +317,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
ItemFactory.create(parent_location=course_location,
template="i4x://edx/templates/static_tab/Empty",
display_name="Static_1")
ItemFactory.create(parent_location=course_location,
template="i4x://edx/templates/static_tab/Empty",
display_name="Static_2")
ItemFactory.create(
parent_location=course_location,
category="static_tab",
display_name="Static_1")
ItemFactory.create(
parent_location=course_location,
category="static_tab",
display_name="Static_2")
course = module_store.get_item(Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]))
@@ -371,7 +381,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
chapterloc = ItemFactory.create(parent_location=course_location, display_name="Chapter").location
ItemFactory.create(parent_location=chapterloc, template='i4x://edx/templates/sequential/Empty', display_name="Sequential")
ItemFactory.create(parent_location=chapterloc, category='sequential', display_name="Sequential")
sequential = direct_store.get_item(Location(['i4x', 'edX', '999', 'sequential', 'Sequential', None]))
chapter = direct_store.get_item(Location(['i4x', 'edX', '999', 'chapter', 'Chapter', None]))
@@ -574,7 +584,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
def test_clone_course(self):
course_data = {
'template': 'i4x://edx/templates/course/Empty',
'org': 'MITx',
'number': '999',
'display_name': 'Robot Super Course',
@@ -614,10 +623,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
location = Location('i4x://MITx/999/chapter/neuvo')
self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty',
location)
direct_store.clone_item('i4x://edx/templates/chapter/Empty', location)
self.assertRaises(InvalidVersionError, draft_store.clone_item, location, location)
# Ensure draft mongo store does not allow us to create chapters either directly or via convert to draft
self.assertRaises(InvalidVersionError, draft_store.create_and_save_xmodule, location)
direct_store.create_and_save_xmodule(location)
self.assertRaises(InvalidVersionError, draft_store.convert_to_draft, location)
self.assertRaises(InvalidVersionError, draft_store.update_item, location, 'chapter data')
@@ -652,9 +661,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
vertical = module_store.get_item(Location(['i4x', 'edX', 'toy',
'vertical', 'vertical_test', None]), depth=1)
draft_store.clone_item(vertical.location, vertical.location)
draft_store.convert_to_draft(vertical.location)
for child in vertical.get_children():
draft_store.clone_item(child.location, child.location)
draft_store.convert_to_draft(child.location)
# delete the course
delete_course(module_store, content_store, location, commit=True)
@@ -687,26 +696,35 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
import_from_xml(module_store, 'common/test/data/', ['toy'])
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
# get a vertical (and components in it) to put into 'draft'
vertical = module_store.get_item(Location(['i4x', 'edX', 'toy',
'vertical', 'vertical_test', None]), depth=1)
draft_store.clone_item(vertical.location, vertical.location)
# get a vertical (and components in it) to copy into an orphan sub dag
vertical = module_store.get_item(
Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]),
depth=1
)
# We had a bug where orphaned draft nodes caused export to fail. This is here to cover that case.
draft_store.clone_item(vertical.location, Location(['i4x', 'edX', 'toy',
'vertical', 'no_references', 'draft']))
vertical.location = mongo.draft.as_draft(vertical.location.replace(name='no_references'))
draft_store.save_xmodule(vertical)
orphan_vertical = draft_store.get_item(vertical.location)
self.assertEqual(orphan_vertical.location.name, 'no_references')
# get the original vertical (and components in it) to put into 'draft'
vertical = module_store.get_item(
Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]),
depth=1)
self.assertEqual(len(orphan_vertical.children), len(vertical.children))
draft_store.convert_to_draft(vertical.location)
for child in vertical.get_children():
draft_store.clone_item(child.location, child.location)
draft_store.convert_to_draft(child.location)
root_dir = path(mkdtemp_clean())
# now create a private vertical
private_vertical = draft_store.clone_item(vertical.location,
Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None]))
# now create a new/different private (draft only) vertical
vertical.location = mongo.draft.as_draft(Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None]))
draft_store.save_xmodule(vertical)
private_vertical = draft_store.get_item(vertical.location)
vertical = None # blank out b/c i destructively manipulated its location 2 lines above
# add private to list of children
# add the new private to list of children
sequential = module_store.get_item(Location(['i4x', 'edX', 'toy',
'sequential', 'vertical_sequential', None]))
private_location_no_draft = private_vertical.location.replace(revision=None)
@@ -792,6 +810,34 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
shutil.rmtree(root_dir)
def test_export_course_with_metadata_only_video(self):
module_store = modulestore('direct')
draft_store = modulestore('draft')
content_store = contentstore()
import_from_xml(module_store, 'common/test/data/', ['toy'])
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
# create a new video module and add it as a child to a vertical
# this re-creates a bug whereby since the video template doesn't have
# anything in 'data' field, the export was blowing up
verticals = module_store.get_items(['i4x', 'edX', 'toy', 'vertical', None, None])
self.assertGreater(len(verticals), 0)
parent = verticals[0]
ItemFactory.create(parent_location=parent.location, category="video", display_name="untitled")
root_dir = path(mkdtemp_clean())
print 'Exporting to tempdir = {0}'.format(root_dir)
# export out to a tempdir
export_to_xml(module_store, content_store, location, root_dir, 'test_export', draft_modulestore=draft_store)
shutil.rmtree(root_dir)
def test_course_handouts_rewrites(self):
module_store = modulestore('direct')
@@ -846,6 +892,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# add a bool piece of unknown metadata so we can verify we don't throw an exception
metadata['new_metadata'] = True
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
course.save()
module_store.update_metadata(location, metadata)
print 'Exporting to tempdir = {0}'.format(root_dir)
@@ -885,7 +934,6 @@ class ContentStoreTest(ModuleStoreTestCase):
self.client.login(username=uname, password=password)
self.course_data = {
'template': 'i4x://edx/templates/course/Empty',
'org': 'MITx',
'number': '999',
'display_name': 'Robot Super Course',
@@ -1029,17 +1077,17 @@ class ContentStoreTest(ModuleStoreTestCase):
html=True
)
def test_clone_item(self):
def test_create_item(self):
"""Test cloning an item. E.g. creating a new section"""
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
section_data = {
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
'template': 'i4x://edx/templates/chapter/Empty',
'category': 'chapter',
'display_name': 'Section One',
}
resp = self.client.post(reverse('clone_item'), section_data)
resp = self.client.post(reverse('create_item'), section_data)
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
@@ -1054,14 +1102,14 @@ class ContentStoreTest(ModuleStoreTestCase):
problem_data = {
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
'template': 'i4x://edx/templates/problem/Blank_Common_Problem'
'category': 'problem'
}
resp = self.client.post(reverse('clone_item'), problem_data)
resp = self.client.post(reverse('create_item'), problem_data)
self.assertEqual(resp.status_code, 200)
payload = parse_json(resp)
problem_loc = payload['id']
problem_loc = Location(payload['id'])
problem = get_modulestore(problem_loc).get_item(problem_loc)
# should be a CapaDescriptor
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
@@ -1194,10 +1242,9 @@ class ContentStoreTest(ModuleStoreTestCase):
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
new_component_location = Location('i4x', 'edX', '999', 'discussion', 'new_component')
source_template_location = Location('i4x', 'edx', 'templates', 'discussion', 'Discussion_Tag')
# crate a new module and add it as a child to a vertical
module_store.clone_item(source_template_location, new_component_location)
module_store.create_and_save_xmodule(new_component_location)
new_discussion_item = module_store.get_item(new_component_location)
@@ -1218,10 +1265,9 @@ class ContentStoreTest(ModuleStoreTestCase):
module_store.modulestore_update_signal.connect(_signal_hander)
new_component_location = Location('i4x', 'edX', '999', 'html', 'new_component')
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
# crate a new module
module_store.clone_item(source_template_location, new_component_location)
module_store.create_and_save_xmodule(new_component_location)
finally:
module_store.modulestore_update_signal = None
@@ -1239,14 +1285,14 @@ class ContentStoreTest(ModuleStoreTestCase):
# let's assert on the metadata_inheritance on an existing vertical
for vertical in verticals:
self.assertEqual(course.lms.xqa_key, vertical.lms.xqa_key)
self.assertEqual(course.start, vertical.lms.start)
self.assertGreater(len(verticals), 0)
new_component_location = Location('i4x', 'edX', 'toy', 'html', 'new_component')
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
# crate a new module and add it as a child to a vertical
module_store.clone_item(source_template_location, new_component_location)
module_store.create_and_save_xmodule(new_component_location)
parent = verticals[0]
module_store.update_children(parent.location, parent.children + [new_component_location.url()])
@@ -1256,6 +1302,8 @@ class ContentStoreTest(ModuleStoreTestCase):
# check for grace period definition which should be defined at the course level
self.assertEqual(parent.lms.graceperiod, new_module.lms.graceperiod)
self.assertEqual(parent.lms.start, new_module.lms.start)
self.assertEqual(course.start, new_module.lms.start)
self.assertEqual(course.lms.xqa_key, new_module.lms.xqa_key)
@@ -1263,6 +1311,7 @@ class ContentStoreTest(ModuleStoreTestCase):
# now let's define an override at the leaf node level
#
new_module.lms.graceperiod = timedelta(1)
new_module.save()
module_store.update_metadata(new_module.location, own_metadata(new_module))
# flush the cache and refetch
@@ -1271,29 +1320,25 @@ class ContentStoreTest(ModuleStoreTestCase):
self.assertEqual(timedelta(1), new_module.lms.graceperiod)
def test_default_metadata_inheritance(self):
course = CourseFactory.create()
vertical = ItemFactory.create(parent_location=course.location)
course.children.append(vertical)
# in memory
self.assertIsNotNone(course.start)
self.assertEqual(course.start, vertical.lms.start)
self.assertEqual(course.textbooks, [])
self.assertIn('GRADER', course.grading_policy)
self.assertIn('GRADE_CUTOFFS', course.grading_policy)
self.assertGreaterEqual(len(course.checklists), 4)
class TemplateTestCase(ModuleStoreTestCase):
def test_template_cleanup(self):
# by fetching
module_store = modulestore('direct')
# insert a bogus template in the store
bogus_template_location = Location('i4x', 'edx', 'templates', 'html', 'bogus')
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
module_store.clone_item(source_template_location, bogus_template_location)
verify_create = module_store.get_item(bogus_template_location)
self.assertIsNotNone(verify_create)
# now run cleanup
update_templates(modulestore('direct'))
# now try to find dangling template, it should not be in DB any longer
asserted = False
try:
verify_create = module_store.get_item(bogus_template_location)
except ItemNotFoundError:
asserted = True
self.assertTrue(asserted)
fetched_course = module_store.get_item(course.location)
fetched_item = module_store.get_item(vertical.location)
self.assertIsNotNone(fetched_course.start)
self.assertEqual(course.start, fetched_course.start)
self.assertEqual(fetched_course.start, fetched_item.lms.start)
self.assertEqual(course.textbooks, fetched_course.textbooks)
# is this test too strict? i.e., it requires the dicts to be ==
self.assertEqual(course.checklists, fetched_course.checklists)

View File

@@ -19,6 +19,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
from models.settings.course_metadata import CourseMetadata
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.django import modulestore
from xmodule.fields import Date
from .utils import CourseTestCase
@@ -36,7 +37,6 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start))
self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end))
self.assertIsNone(details.syllabus, "syllabus somehow initialized" + str(details.syllabus))
self.assertEqual(details.overview, "", "overview somehow initialized" + details.overview)
self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video))
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
@@ -49,7 +49,6 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ")
self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ")
self.assertIsNone(jsondetails['syllabus'], "syllabus somehow initialized")
self.assertEqual(jsondetails['overview'], "", "overview somehow initialized")
self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized")
self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
@@ -291,6 +290,71 @@ class CourseGradingTest(CourseTestCase):
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
def test_update_cutoffs_from_json(self):
test_grader = CourseGradingModel.fetch(self.course.location)
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
# Unlike other tests, need to actually perform a db fetch for this test since update_cutoffs_from_json
# simply returns the cutoffs you send into it, rather than returning the db contents.
altered_grader = CourseGradingModel.fetch(self.course.location)
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update")
test_grader.grade_cutoffs['D'] = 0.3
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
altered_grader = CourseGradingModel.fetch(self.course.location)
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D")
test_grader.grade_cutoffs['Pass'] = 0.75
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
altered_grader = CourseGradingModel.fetch(self.course.location)
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'")
def test_delete_grace_period(self):
test_grader = CourseGradingModel.fetch(self.course.location)
CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
altered_grader = CourseGradingModel.fetch(self.course.location)
self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update")
test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30}
CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
altered_grader = CourseGradingModel.fetch(self.course.location)
self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period")
test_grader.grace_period = {'hours': 1, 'minutes': 10, 'seconds': 0}
# Now delete the grace period
CourseGradingModel.delete_grace_period(test_grader.course_location)
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
altered_grader = CourseGradingModel.fetch(self.course.location)
# Once deleted, the grace period should simply be None
self.assertEqual(None, altered_grader.grace_period, "Delete grace period")
def test_update_section_grader_type(self):
# Get the descriptor and the section_grader_type and assert they are the default values
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
self.assertEqual('Not Graded', section_grader_type['graderType'])
self.assertEqual(None, descriptor.lms.format)
self.assertEqual(False, descriptor.lms.graded)
# Change the default grader type to Homework, which should also mark the section as graded
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Homework'})
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
self.assertEqual('Homework', section_grader_type['graderType'])
self.assertEqual('Homework', descriptor.lms.format)
self.assertEqual(True, descriptor.lms.graded)
# Change the grader type back to Not Graded, which should also unmark the section as graded
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Not Graded'})
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
self.assertEqual('Not Graded', section_grader_type['graderType'])
self.assertEqual(None, descriptor.lms.format)
self.assertEqual(False, descriptor.lms.graded)
class CourseMetadataEditingTest(CourseTestCase):
"""
@@ -352,7 +416,7 @@ class CourseMetadataEditingTest(CourseTestCase):
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
# check for deletion effectiveness
self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in')
self.assertEqual('finished', test_model['showanswer'], 'showanswer field still in')
self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in')

View File

@@ -36,8 +36,11 @@ class CourseUpdateTest(CourseTestCase):
'provided_id': payload['id']})
content += '<div>div <p>p<br/></p></div>'
payload['content'] = content
# POST requests were coming in w/ these header values causing an error; so, repro error here
resp = self.client.post(first_update_url, json.dumps(payload),
"application/json")
"application/json",
HTTP_X_HTTP_METHOD_OVERRIDE="PUT",
REQUEST_METHOD="POST")
self.assertHTMLEqual(content, json.loads(resp.content)['content'],
"iframe w/ div")

View File

@@ -35,7 +35,6 @@ class InternationalizationTest(ModuleStoreTestCase):
self.user.save()
self.course_data = {
'template': 'i4x://edx/templates/course/Empty',
'org': 'MITx',
'number': '999',
'display_name': 'Robot Super Course',

View File

@@ -1,6 +1,11 @@
from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
from xmodule.capa_module import CapaDescriptor
import json
from xmodule.modulestore.django import modulestore
import datetime
from pytz import UTC
class DeleteItem(CourseTestCase):
@@ -11,14 +16,228 @@ class DeleteItem(CourseTestCase):
def testDeleteStaticPage(self):
# Add static tab
data = {
data = json.dumps({
'parent_location': 'i4x://mitX/333/course/Dummy_Course',
'template': 'i4x://edx/templates/static_tab/Empty'
}
'category': 'static_tab'
})
resp = self.client.post(reverse('clone_item'), data)
resp = self.client.post(reverse('create_item'), data,
content_type="application/json")
self.assertEqual(resp.status_code, 200)
# Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore).
resp = self.client.post(reverse('delete_item'), resp.content, "application/json")
self.assertEqual(resp.status_code, 200)
class TestCreateItem(CourseTestCase):
"""
Test the create_item handler thoroughly
"""
def response_id(self, response):
"""
Get the id from the response payload
:param response:
"""
parsed = json.loads(response.content)
return parsed['id']
def test_create_nicely(self):
"""
Try the straightforward use cases
"""
# create a chapter
display_name = 'Nicely created'
resp = self.client.post(
reverse('create_item'),
json.dumps({
'parent_location': self.course.location.url(),
'display_name': display_name,
'category': 'chapter'
}),
content_type="application/json"
)
self.assertEqual(resp.status_code, 200)
# get the new item and check its category and display_name
chap_location = self.response_id(resp)
new_obj = modulestore().get_item(chap_location)
self.assertEqual(new_obj.category, 'chapter')
self.assertEqual(new_obj.display_name, display_name)
self.assertEqual(new_obj.location.org, self.course.location.org)
self.assertEqual(new_obj.location.course, self.course.location.course)
# get the course and ensure it now points to this one
course = modulestore().get_item(self.course.location)
self.assertIn(chap_location, course.children)
# use default display name
resp = self.client.post(
reverse('create_item'),
json.dumps({
'parent_location': chap_location,
'category': 'vertical'
}),
content_type="application/json"
)
self.assertEqual(resp.status_code, 200)
vert_location = self.response_id(resp)
# create problem w/ boilerplate
template_id = 'multiplechoice.yaml'
resp = self.client.post(
reverse('create_item'),
json.dumps({
'parent_location': vert_location,
'category': 'problem',
'boilerplate': template_id
}),
content_type="application/json"
)
self.assertEqual(resp.status_code, 200)
prob_location = self.response_id(resp)
problem = modulestore('draft').get_item(prob_location)
# ensure it's draft
self.assertTrue(problem.is_draft)
# check against the template
template = CapaDescriptor.get_template(template_id)
self.assertEqual(problem.data, template['data'])
self.assertEqual(problem.display_name, template['metadata']['display_name'])
self.assertEqual(problem.markdown, template['metadata']['markdown'])
def test_create_item_negative(self):
"""
Negative tests for create_item
"""
# non-existent boilerplate: creates a default
resp = self.client.post(
reverse('create_item'),
json.dumps(
{'parent_location': self.course.location.url(),
'category': 'problem',
'boilerplate': 'nosuchboilerplate.yaml'
}),
content_type="application/json"
)
self.assertEqual(resp.status_code, 200)
class TestEditItem(CourseTestCase):
"""
Test contentstore.views.item.save_item
"""
def response_id(self, response):
"""
Get the id from the response payload
:param response:
"""
parsed = json.loads(response.content)
return parsed['id']
def setUp(self):
""" Creates the test course structure and a couple problems to 'edit'. """
super(TestEditItem, self).setUp()
# create a chapter
display_name = 'chapter created'
resp = self.client.post(
reverse('create_item'),
json.dumps(
{'parent_location': self.course.location.url(),
'display_name': display_name,
'category': 'chapter'
}),
content_type="application/json"
)
chap_location = self.response_id(resp)
resp = self.client.post(
reverse('create_item'),
json.dumps(
{'parent_location': chap_location,
'category': 'sequential'
}),
content_type="application/json"
)
self.seq_location = self.response_id(resp)
# create problem w/ boilerplate
template_id = 'multiplechoice.yaml'
resp = self.client.post(
reverse('create_item'),
json.dumps({'parent_location': self.seq_location,
'category': 'problem',
'boilerplate': template_id
}),
content_type="application/json"
)
self.problems = [self.response_id(resp)]
def test_delete_field(self):
"""
Sending null in for a field 'deletes' it
"""
self.client.post(
reverse('save_item'),
json.dumps({
'id': self.problems[0],
'metadata': {'rerandomize': 'onreset'}
}),
content_type="application/json"
)
problem = modulestore('draft').get_item(self.problems[0])
self.assertEqual(problem.rerandomize, 'onreset')
self.client.post(
reverse('save_item'),
json.dumps({
'id': self.problems[0],
'metadata': {'rerandomize': None}
}),
content_type="application/json"
)
problem = modulestore('draft').get_item(self.problems[0])
self.assertEqual(problem.rerandomize, 'never')
def test_null_field(self):
"""
Sending null in for a field 'deletes' it
"""
problem = modulestore('draft').get_item(self.problems[0])
self.assertIsNotNone(problem.markdown)
self.client.post(
reverse('save_item'),
json.dumps({
'id': self.problems[0],
'nullout': ['markdown']
}),
content_type="application/json"
)
problem = modulestore('draft').get_item(self.problems[0])
self.assertIsNone(problem.markdown)
def test_date_fields(self):
"""
Test setting due & start dates on sequential
"""
sequential = modulestore().get_item(self.seq_location)
self.assertIsNone(sequential.lms.due)
self.client.post(
reverse('save_item'),
json.dumps({
'id': self.seq_location,
'metadata': {'due': '2010-11-22T04:00Z'}
}),
content_type="application/json"
)
sequential = modulestore().get_item(self.seq_location)
self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
self.client.post(
reverse('save_item'),
json.dumps({
'id': self.seq_location,
'metadata': {'start': '2010-09-12T14:00Z'}
}),
content_type="application/json"
)
sequential = modulestore().get_item(self.seq_location)
self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
self.assertEqual(sequential.lms.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC))

View File

@@ -62,6 +62,9 @@ class TextbookIndexTestCase(CourseTestCase):
}
]
self.course.pdf_textbooks = content
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
self.course.save()
store = get_modulestore(self.course.location)
store.update_metadata(self.course.location, own_metadata(self.course))
@@ -220,6 +223,9 @@ class TextbookByIdTestCase(CourseTestCase):
'tid': 2,
})
self.course.pdf_textbooks = [self.textbook1, self.textbook2]
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
self.course.save()
self.store = get_modulestore(self.course.location)
self.store.update_metadata(self.course.location, own_metadata(self.course))
self.url_nonexist = reverse('textbook_by_id', kwargs={

View File

@@ -54,7 +54,6 @@ class CourseTestCase(ModuleStoreTestCase):
self.client.login(username=uname, password=password)
self.course = CourseFactory.create(
template='i4x://edx/templates/course/Empty',
org='MITx',
number='999',
display_name='Robot Super Course',

View File

@@ -19,14 +19,14 @@ NOTES_PANEL = {"name": _("My Notes"), "type": "notes"}
EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]])
def get_modulestore(location):
def get_modulestore(category_or_location):
"""
Returns the correct modulestore to use for modifying the specified location
"""
if not isinstance(location, Location):
location = Location(location)
if isinstance(category_or_location, Location):
category_or_location = category_or_location.category
if location.category in DIRECT_ONLY_CATEGORIES:
if category_or_location in DIRECT_ONLY_CATEGORIES:
return modulestore('direct')
else:
return modulestore()

View File

@@ -13,7 +13,7 @@ from django_future.csrf import ensure_csrf_cookie
from django.core.urlresolvers import reverse
from django.core.servers.basehttp import FileWrapper
from django.core.files.temp import NamedTemporaryFile
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_POST, require_http_methods
from mitxmako.shortcuts import render_to_response
from cache_toolbox.core import del_cached_content
@@ -249,6 +249,7 @@ def remove_asset(request, org, course, name):
@ensure_csrf_cookie
@require_http_methods(("GET", "POST", "PUT"))
@login_required
def import_course(request, org, course, name):
"""
@@ -256,7 +257,7 @@ def import_course(request, org, course, name):
"""
location = get_location_and_verify_access(request, org, course, name)
if request.method == 'POST':
if request.method in ('POST', 'PUT'):
filename = request.FILES['course-data'].name
if not filename.endswith('.tar.gz'):

View File

@@ -7,11 +7,11 @@ from django.views.decorators.http import require_http_methods
from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response
from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import own_metadata
from ..utils import get_modulestore, get_url_reverse
from .access import get_location_and_verify_access
from xmodule.course_module import CourseDescriptor
__all__ = ['get_checklists', 'update_checklist']
@@ -28,13 +28,11 @@ def get_checklists(request, org, course, name):
modulestore = get_modulestore(location)
course_module = modulestore.get_item(location)
new_course_template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
template_module = modulestore.get_item(new_course_template)
# If course was created before checklists were introduced, copy them over from the template.
copied = False
if not course_module.checklists:
course_module.checklists = template_module.checklists
course_module.checklists = CourseDescriptor.checklists.default
copied = True
checklists, modified = expand_checklist_action_urls(course_module)

View File

@@ -26,6 +26,8 @@ from models.settings.course_grading import CourseGradingModel
from .requests import _xmodule_recurse
from .access import has_access
from xmodule.x_module import XModuleDescriptor
from xblock.plugin import PluginMissingError
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
'ADVANCED_COMPONENT_POLICY_KEY',
@@ -101,7 +103,7 @@ def edit_subsection(request, location):
return render_to_response('edit_subsection.html',
{'subsection': item,
'context_course': course,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'new_unit_category': 'vertical',
'lms_link': lms_link,
'preview_link': preview_link,
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
@@ -134,10 +136,26 @@ def edit_unit(request, location):
item = modulestore().get_item(location, depth=1)
except ItemNotFoundError:
return HttpResponseBadRequest()
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
component_templates = defaultdict(list)
for category in COMPONENT_TYPES:
component_class = XModuleDescriptor.load_class(category)
# add the default template
component_templates[category].append((
component_class.display_name.default or 'Blank',
category,
False, # No defaults have markdown (hardcoded current default)
None # no boilerplate for overrides
))
# add boilerplates
for template in component_class.templates():
component_templates[category].append((
template['metadata'].get('display_name'),
category,
template['metadata'].get('markdown') is not None,
template.get('template_id')
))
# 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
@@ -145,29 +163,29 @@ def edit_unit(request, location):
course_advanced_keys = course.advanced_modules
# 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)
for category in course_advanced_keys:
if category in ADVANCED_COMPONENT_TYPES:
# Do I need to allow for boilerplates or just defaults on the class? i.e., can an advanced
# have more than one entry in the menu? one for default and others for prefilled boilerplates?
try:
component_class = XModuleDescriptor.load_class(category)
component_templates['advanced'].append((
component_class.display_name.default or category,
category,
False,
None # don't override default data
))
except PluginMissingError:
# dhm: I got this once but it can happen any time the course author configures
# an advanced component which does not exist on the server. This code here merely
# prevents any authors from trying to instantiate the non-existent component type
# by not showing it in the menu
pass
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:
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_with_default,
template.location.url(),
hasattr(template, 'markdown') and template.markdown is not None
))
components = [
component.location.url()
for component
@@ -219,7 +237,7 @@ def edit_unit(request, location):
'subsection': containing_subsection,
'release_date': get_default_time_display(containing_subsection.lms.start) if containing_subsection.lms.start is not None else None,
'section': containing_section,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
'new_unit_category': 'vertical',
'unit_state': unit_state,
'published_date': get_default_time_display(item.cms.published_date) if item.cms.published_date is not None else None
})
@@ -227,6 +245,7 @@ def edit_unit(request, location):
@expect_json
@login_required
@require_http_methods(("GET", "POST", "PUT"))
@ensure_csrf_cookie
def assignment_type_update(request, org, course, category, name):
'''
@@ -238,7 +257,7 @@ def assignment_type_update(request, org, course, category, name):
if request.method == 'GET':
return JsonResponse(CourseGradingModel.get_section_grader_type(location))
elif request.method == 'POST': # post or put, doesn't matter.
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
return JsonResponse(CourseGradingModel.update_section_grader_type(location, request.POST))
@@ -253,7 +272,7 @@ def create_draft(request):
# This clones the existing item location to a draft location (the draft is implicit,
# because modulestore is a Draft modulestore)
modulestore().clone_item(location, location)
modulestore().convert_to_draft(location)
return HttpResponse()

View File

@@ -1,10 +1,9 @@
"""
Views related to operations on course objects
"""
#pylint: disable=W0402
import json
import random
import string
import string # pylint: disable=W0402
from django.contrib.auth.decorators import login_required
from django_future.csrf import ensure_csrf_cookie
@@ -43,8 +42,8 @@ from .component import (
ADVANCED_COMPONENT_POLICY_KEY)
from django_comment_common.utils import seed_permissions_roles
import datetime
from django.utils.timezone import UTC
from xmodule.html_module import AboutDescriptor
__all__ = ['course_index', 'create_new_course', 'course_info',
'course_info_updates', 'get_course_settings',
'course_config_graders_page',
@@ -82,10 +81,11 @@ def course_index(request, org, course, name):
'sections': sections,
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
'parent_location': course.location,
'new_section_template': Location('i4x', 'edx', 'templates', 'chapter', 'Empty'),
'new_subsection_template': Location('i4x', 'edx', 'templates', 'sequential', 'Empty'), # for now they are the same, but the could be different at some point...
'new_section_category': 'chapter',
'new_subsection_category': 'sequential',
'upload_asset_callback_url': upload_asset_callback_url,
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty')
'new_unit_category': 'vertical',
'category': 'vertical'
})
@@ -98,12 +98,6 @@ def create_new_course(request):
if not is_user_in_creator_group(request.user):
raise PermissionDenied()
# This logic is repeated in xmodule/modulestore/tests/factories.py
# so if you change anything here, you need to also change it there.
# TODO: write a test that creates two courses, one with the factory and
# the other with this method, then compare them to make sure they are
# equivalent.
template = Location(request.POST['template'])
org = request.POST.get('org')
number = request.POST.get('number')
display_name = request.POST.get('display_name')
@@ -121,29 +115,31 @@ def create_new_course(request):
existing_course = modulestore('direct').get_item(dest_location)
except ItemNotFoundError:
pass
if existing_course is not None:
return JsonResponse({'ErrMsg': 'There is already a course defined with this name.'})
course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None]
courses = modulestore().get_items(course_search_location)
if len(courses) > 0:
return JsonResponse({'ErrMsg': 'There is already a course defined with the same organization and course number.'})
new_course = modulestore('direct').clone_item(template, dest_location)
# instantiate the CourseDescriptor and then persist it
# note: no system to pass
if display_name is None:
metadata = {}
else:
metadata = {'display_name': display_name}
modulestore('direct').create_and_save_xmodule(dest_location, metadata=metadata)
new_course = modulestore('direct').get_item(dest_location)
# clone a default 'about' module as well
about_template_location = Location(['i4x', 'edx', 'templates', 'about', 'overview'])
dest_about_location = dest_location._replace(category='about', name='overview')
modulestore('direct').clone_item(about_template_location, dest_about_location)
if display_name is not None:
new_course.display_name = display_name
# set a default start date to now
new_course.start = datetime.datetime.now(UTC())
# clone a default 'about' overview module as well
dest_about_location = dest_location.replace(category='about', name='overview')
overview_template = AboutDescriptor.get_template('overview.yaml')
modulestore('direct').create_and_save_xmodule(
dest_about_location,
system=new_course.system,
definition_data=overview_template.get('data')
)
initialize_course_tabs(new_course)
@@ -179,6 +175,7 @@ def course_info(request, org, course, name, provided_id=None):
@expect_json
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
@login_required
@ensure_csrf_cookie
def course_info_updates(request, org, course, provided_id=None):
@@ -209,7 +206,7 @@ def course_info_updates(request, org, course, provided_id=None):
except:
return HttpResponseBadRequest("Failed to delete",
content_type="text/plain")
elif request.method == 'POST':
elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
try:
return JsonResponse(update_course_updates(location, request.POST, provided_id))
except:
@@ -303,7 +300,7 @@ def course_settings_updates(request, org, course, name, section):
if request.method == 'GET':
# Cannot just do a get w/o knowing the course name :-(
return JsonResponse(manager.fetch(Location(['i4x', org, course, 'course', name])), encoder=CourseSettingsEncoder)
elif request.method == 'POST': # post or put, doesn't matter.
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
return JsonResponse(manager.update_from_json(request.POST), encoder=CourseSettingsEncoder)
@@ -482,7 +479,7 @@ def textbook_index(request, org, course, name):
if request.is_ajax():
if request.method == 'GET':
return JsonResponse(course_module.pdf_textbooks)
elif request.method == 'POST':
elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
try:
textbooks = validate_textbooks_json(request.body)
except TextbookValidationError as err:
@@ -498,6 +495,9 @@ def textbook_index(request, org, course, name):
if not any(tab['type'] == 'pdf_textbooks' for tab in course_module.tabs):
course_module.tabs.append({"type": "pdf_textbooks"})
course_module.pdf_textbooks = textbooks
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
course_module.save()
store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse(course_module.pdf_textbooks)
else:
@@ -544,6 +544,9 @@ def create_textbook(request, org, course, name):
tabs = course_module.tabs
tabs.append({"type": "pdf_textbooks"})
course_module.tabs = tabs
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
course_module.save()
store.update_metadata(course_module.location, own_metadata(course_module))
resp = JsonResponse(textbook, status=201)
resp["Location"] = reverse("textbook_by_id", kwargs={
@@ -577,7 +580,7 @@ def textbook_by_id(request, org, course, name, tid):
if not textbook:
return JsonResponse(status=404)
return JsonResponse(textbook)
elif request.method in ('POST', 'PUT'):
elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
try:
new_textbook = validate_textbook_json(request.body)
except TextbookValidationError as err:
@@ -587,10 +590,13 @@ def textbook_by_id(request, org, course, name, tid):
i = course_module.pdf_textbooks.index(textbook)
new_textbooks = course_module.pdf_textbooks[0:i]
new_textbooks.append(new_textbook)
new_textbooks.extend(course_module.pdf_textbooks[i+1:])
new_textbooks.extend(course_module.pdf_textbooks[i + 1:])
course_module.pdf_textbooks = new_textbooks
else:
course_module.pdf_textbooks.append(new_textbook)
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
course_module.save()
store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse(new_textbook, status=201)
elif request.method == 'DELETE':
@@ -598,7 +604,8 @@ def textbook_by_id(request, org, course, name, tid):
return JsonResponse(status=404)
i = course_module.pdf_textbooks.index(textbook)
new_textbooks = course_module.pdf_textbooks[0:i]
new_textbooks.extend(course_module.pdf_textbooks[i+1:])
new_textbooks.extend(course_module.pdf_textbooks[i + 1:])
course_module.pdf_textbooks = new_textbooks
course_module.save()
store.update_metadata(course_module.location, own_metadata(course_module))
return JsonResponse()

View File

@@ -13,16 +13,26 @@ from util.json_request import expect_json
from ..utils import get_modulestore
from .access import has_access
from .requests import _xmodule_recurse
from xmodule.x_module import XModuleDescriptor
__all__ = ['save_item', 'clone_item', 'delete_item']
__all__ = ['save_item', 'create_item', 'delete_item']
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
@login_required
@expect_json
def save_item(request):
"""
Will carry a json payload with these possible fields
:id (required): the id
:data (optional): the new value for the data
:metadata (optional): new values for the metadata fields.
Any whose values are None will be deleted not set to None! Absent ones will be left alone
:nullout (optional): which metadata fields to set to None
"""
# The nullout is a bit of a temporary copout until we can make module_edit.coffee and the metadata editors a
# little smarter and able to pass something more akin to {unset: [field, field]}
item_location = request.POST['id']
# check permissions for this user within this course
@@ -42,59 +52,98 @@ def save_item(request):
children = request.POST['children']
store.update_children(item_location, children)
# cdodge: also commit any metadata which might have been passed along in the
# POST from the client, if it is there
# NOTE, that the postback is not the complete metadata, as there's system metadata which is
# not presented to the end-user for editing. So let's fetch the original and
# 'apply' the submitted metadata, so we don't end up deleting system metadata
if request.POST.get('metadata') is not None:
posted_metadata = request.POST['metadata']
# fetch original
# cdodge: also commit any metadata which might have been passed along
if request.POST.get('nullout') is not None or request.POST.get('metadata') is not None:
# the postback is not the complete metadata, as there's system metadata which is
# not presented to the end-user for editing. So let's fetch the original and
# 'apply' the submitted metadata, so we don't end up deleting system metadata
existing_item = modulestore().get_item(item_location)
for metadata_key in request.POST.get('nullout', []):
# [dhm] see comment on _get_xblock_field
_get_xblock_field(existing_item, metadata_key).write_to(existing_item, None)
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it'
for metadata_key, value in posted_metadata.items():
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
# the intent is to make it None, use the nullout field
for metadata_key, value in request.POST.get('metadata', {}).items():
# [dhm] see comment on _get_xblock_field
field = _get_xblock_field(existing_item, metadata_key)
if posted_metadata[metadata_key] is None:
# remove both from passed in collection as well as the collection read in from the modulestore
if metadata_key in existing_item._model_data:
del existing_item._model_data[metadata_key]
del posted_metadata[metadata_key]
if value is None:
field.delete_from(existing_item)
else:
existing_item._model_data[metadata_key] = value
value = field.from_json(value)
field.write_to(existing_item, value)
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
existing_item.save()
# commit to datastore
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
store.update_metadata(item_location, own_metadata(existing_item))
return HttpResponse()
# [DHM] A hack until we implement a permanent soln. Proposed perm solution is to make namespace fields also top level
# fields in xblocks rather than requiring dereference through namespace but we'll need to consider whether there are
# plausible use cases for distinct fields w/ same name in different namespaces on the same blocks.
# The idea is that consumers of the xblock, and particularly the web client, shouldn't know about our internal
# representation (namespaces as means of decorating all modules).
# Given top-level access, the calls can simply be setattr(existing_item, field, value) ...
# Really, this method should be elsewhere (e.g., xblock). We also need methods for has_value (v is_default)...
def _get_xblock_field(xblock, field_name):
"""
A temporary function to get the xblock field either from the xblock or one of its namespaces by name.
:param xblock:
:param field_name:
"""
def find_field(fields):
for field in fields:
if field.name == field_name:
return field
found = find_field(xblock.fields)
if found:
return found
for namespace in xblock.namespaces:
found = find_field(getattr(xblock, namespace).fields)
if found:
return found
@login_required
@expect_json
def clone_item(request):
def create_item(request):
parent_location = Location(request.POST['parent_location'])
template = Location(request.POST['template'])
category = request.POST['category']
display_name = request.POST.get('display_name')
if not has_access(request.user, parent_location):
raise PermissionDenied()
parent = get_modulestore(template).get_item(parent_location)
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
parent = get_modulestore(category).get_item(parent_location)
dest_location = parent_location.replace(category=category, name=uuid4().hex)
new_item = get_modulestore(template).clone_item(template, dest_location)
# get the metadata, display_name, and definition from the request
metadata = {}
data = None
template_id = request.POST.get('boilerplate')
if template_id is not None:
clz = XModuleDescriptor.load_class(category)
if clz is not None:
template = clz.get_template(template_id)
if template is not None:
metadata = template.get('metadata', {})
data = template.get('data')
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
new_item.display_name = display_name
metadata['display_name'] = display_name
get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item))
get_modulestore(category).create_and_save_xmodule(dest_location, definition_data=data,
metadata=metadata, system=parent.system)
if new_item.location.category not in DETACHED_CATEGORIES:
get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()])
if category not in DETACHED_CATEGORIES:
get_modulestore(parent.location).update_children(parent_location, parent.children + [dest_location.url()])
return HttpResponse(json.dumps({'id': dest_location.url()}))

View File

@@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse
from django.contrib.auth.decorators import login_required
from mitxmako.shortcuts import render_to_response
from xmodule_modifiers import replace_static_urls, wrap_xmodule
from xmodule_modifiers import replace_static_urls, wrap_xmodule, save_module # pylint: disable=F0401
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from xmodule.exceptions import NotFoundError, ProcessingError
@@ -47,6 +47,8 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
# Let the module handle the AJAX
try:
ajax_return = instance.handle_ajax(dispatch, request.POST)
# Save any module data that has changed to the underlying KeyValueStore
instance.save()
except NotFoundError:
log.exception("Module indicating to user that request doesn't exist")
@@ -166,6 +168,11 @@ def load_preview_module(request, preview_id, descriptor):
course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
)
module.get_html = save_module(
module.get_html,
module
)
return module

View File

@@ -76,6 +76,9 @@ def reorder_static_tabs(request):
# OK, re-assemble the static tabs in the new order
course.tabs = reordered_tabs
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
course.save()
modulestore('direct').update_metadata(course.location, own_metadata(course))
return HttpResponse()

View File

@@ -27,6 +27,7 @@ def index(request):
# filter out courses that we don't have access too
def course_filter(course):
return (has_access(request.user, course.location)
# TODO remove this condition when templates purged from db
and course.location.course != 'templates'
and course.location.org != ''
and course.location.course != ''
@@ -34,7 +35,6 @@ def index(request):
courses = filter(course_filter, courses)
return render_to_response('index.html', {
'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'),
'courses': [(course.display_name,
get_url_reverse('CourseOutline', course),
get_lms_link_for_item(course.location, course_id=course.location.course_id))

View File

@@ -122,6 +122,10 @@ class CourseDetails(object):
descriptor.enrollment_end = converted
if dirty:
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor))
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed

View File

@@ -7,9 +7,12 @@ class CourseGradingModel(object):
"""
Basically a DAO and Model combo for CRUD operations pertaining to grading policy.
"""
# Within this class, allow access to protected members of client classes.
# This comes up when accessing kvs data and caches during kvs saves and modulestore writes.
# pylint: disable=W0212
def __init__(self, course_descriptor):
self.course_location = course_descriptor.location
self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
self.grade_cutoffs = course_descriptor.grade_cutoffs
self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor)
@@ -81,15 +84,18 @@ class CourseGradingModel(object):
Decode the json into CourseGradingModel and save any changes. Returns the modified model.
Probably not the usual path for updates as it's too coarse grained.
"""
course_location = jsondict['course_location']
course_location = Location(jsondict['course_location'])
descriptor = get_modulestore(course_location).get_item(course_location)
graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']]
descriptor.raw_grader = graders_parsed
descriptor.grade_cutoffs = jsondict['grade_cutoffs']
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_item(course_location, descriptor.xblock_kvs._data)
CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period'])
return CourseGradingModel.fetch(course_location)
@@ -116,6 +122,9 @@ class CourseGradingModel(object):
else:
descriptor.raw_grader.append(grader)
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
@@ -131,6 +140,10 @@ class CourseGradingModel(object):
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.grade_cutoffs = cutoffs
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
return cutoffs
@@ -156,6 +169,10 @@ class CourseGradingModel(object):
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.lms.graceperiod = grace_timedelta
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
@staticmethod
@@ -172,23 +189,12 @@ class CourseGradingModel(object):
del descriptor.raw_grader[index]
# force propagation to definition
descriptor.raw_grader = descriptor.raw_grader
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
# NOTE cannot delete cutoffs. May be useful to reset
@staticmethod
def delete_cutoffs(course_location, cutoffs):
"""
Resets the cutoffs to the defaults
"""
if not isinstance(course_location, Location):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.grade_cutoffs = descriptor.defaut_grading_policy['GRADE_CUTOFFS']
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
return descriptor.grade_cutoffs
@staticmethod
def delete_grace_period(course_location):
"""
@@ -199,6 +205,10 @@ class CourseGradingModel(object):
descriptor = get_modulestore(course_location).get_item(course_location)
del descriptor.lms.graceperiod
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
@staticmethod
@@ -209,7 +219,7 @@ class CourseGradingModel(object):
descriptor = get_modulestore(location).get_item(location)
return {"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
"location": location,
"id": 99 # just an arbitrary value to
"id": 99 # just an arbitrary value to
}
@staticmethod
@@ -225,6 +235,9 @@ class CourseGradingModel(object):
del descriptor.lms.format
del descriptor.lms.graded
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata)
@staticmethod
@@ -232,7 +245,7 @@ class CourseGradingModel(object):
# 5 hours 59 minutes 59 seconds => converted to iso format
rawgrace = descriptor.lms.graceperiod
if rawgrace:
hours_from_days = rawgrace.days*24
hours_from_days = rawgrace.days * 24
seconds = rawgrace.seconds
hours_from_seconds = int(seconds / 3600)
hours = hours_from_days + hours_from_seconds

View File

@@ -76,6 +76,9 @@ class CourseMetadata(object):
setattr(descriptor.lms, key, value)
if dirty:
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_metadata(course_location,
own_metadata(descriptor))
@@ -97,6 +100,10 @@ class CourseMetadata(object):
elif hasattr(descriptor.lms, key):
delattr(descriptor.lms, key)
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_metadata(course_location,
own_metadata(descriptor))

View File

@@ -56,14 +56,15 @@ class CMS.Views.ModuleEdit extends Backbone.View
changedMetadata: ->
return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata())
cloneTemplate: (parent, template) ->
$.post("/clone_item", {
parent_location: parent
template: template
}, (data) =>
@model.set(id: data.id)
@$el.data('id', data.id)
@render()
createItem: (parent, payload) ->
payload.parent_location = parent
$.post(
"/create_item"
payload
(data) =>
@model.set(id: data.id)
@$el.data('id', data.id)
@render()
)
render: ->

View File

@@ -55,9 +55,9 @@ class CMS.Views.TabsEdit extends Backbone.View
editor.$el.removeClass('new')
, 500)
editor.cloneTemplate(
editor.createItem(
@model.get('id'),
'i4x://edx/templates/static_tab/Empty'
{category: 'static_tab'}
)
analytics.track "Added Static Page",

View File

@@ -89,9 +89,9 @@ class CMS.Views.UnitEdit extends Backbone.View
@$newComponentItem.before(editor.$el)
editor.cloneTemplate(
editor.createItem(
@$el.data('id'),
$(event.currentTarget).data('location')
$(event.currentTarget).data()
)
analytics.track "Added a Component",

View File

@@ -253,17 +253,13 @@ function syncReleaseDate(e) {
}
function getEdxTimeFromDateTimeVals(date_val, time_val) {
var edxTimeStr = null;
if (date_val != '') {
if (time_val == '') time_val = '00:00';
// Note, we are using date.js utility which has better parsing abilities than the built in JS date parsing
var date = Date.parse(date_val + " " + time_val);
edxTimeStr = date.toString('yyyy-MM-ddTHH:mm');
return new Date(date_val + " " + time_val + "Z");
}
return edxTimeStr;
else return null;
}
function getEdxTimeFromDateTimeInputs(date_id, time_id) {
@@ -338,7 +334,7 @@ function createNewUnit(e) {
e.preventDefault();
var parent = $(this).data('parent');
var template = $(this).data('template');
var category = $(this).data('category');
analytics.track('Created a Unit', {
'course': course_location_analytics,
@@ -346,9 +342,9 @@ function createNewUnit(e) {
});
$.post('/clone_item', {
$.post('/create_item', {
'parent_location': parent,
'template': template,
'category': category,
'display_name': 'New Unit'
},
@@ -551,7 +547,7 @@ function saveNewSection(e) {
var $saveButton = $(this).find('.new-section-name-save');
var parent = $saveButton.data('parent');
var template = $saveButton.data('template');
var category = $saveButton.data('category');
var display_name = $(this).find('.new-section-name').val();
analytics.track('Created a Section', {
@@ -559,9 +555,9 @@ function saveNewSection(e) {
'display_name': display_name
});
$.post('/clone_item', {
$.post('/create_item', {
'parent_location': parent,
'template': template,
'category': category,
'display_name': display_name,
},
@@ -595,7 +591,6 @@ function saveNewCourse(e) {
e.preventDefault();
var $newCourse = $(this).closest('.new-course');
var template = $(this).find('.new-course-save').data('template');
var org = $newCourse.find('.new-course-org').val();
var number = $newCourse.find('.new-course-number').val();
var display_name = $newCourse.find('.new-course-name').val();
@@ -612,7 +607,6 @@ function saveNewCourse(e) {
});
$.post('/create_new_course', {
'template': template,
'org': org,
'number': number,
'display_name': display_name
@@ -646,7 +640,7 @@ function addNewSubsection(e) {
var parent = $(this).parents("section.branch").data("id");
$saveButton.data('parent', parent);
$saveButton.data('template', $(this).data('template'));
$saveButton.data('category', $(this).data('category'));
$newSubsection.find('.new-subsection-form').bind('submit', saveNewSubsection);
$cancelButton.bind('click', cancelNewSubsection);
@@ -659,7 +653,7 @@ function saveNewSubsection(e) {
e.preventDefault();
var parent = $(this).find('.new-subsection-name-save').data('parent');
var template = $(this).find('.new-subsection-name-save').data('template');
var category = $(this).find('.new-subsection-name-save').data('category');
var display_name = $(this).find('.new-subsection-name-input').val();
analytics.track('Created a Subsection', {
@@ -668,9 +662,9 @@ function saveNewSubsection(e) {
});
$.post('/clone_item', {
$.post('/create_item', {
'parent_location': parent,
'template': template,
'category': category,
'display_name': display_name
},

View File

@@ -11,7 +11,7 @@
<section class="content content-header">
<header>
## "edX Studio" should not be translated
<h1>${_('Welcome to')}<span class="logo">edX Studio</span></h1>
<h1>${_('Welcome to')}<span class="logo">&nbsp;edX Studio</span></h1>
<p class="tagline">${_("Studio helps manage your courses online, so you can focus on teaching them")}</p>
</header>
</section>

View File

@@ -25,8 +25,8 @@
</div>
</div>
<div class="row">
<input type="submit" value="${_('Save')}" class="new-course-save" data-template="${new_course_template}" />
<input type="button" value="${_('Cancel')}" class="new-course-cancel" />
<input type="submit" value="${_('Save')}" class="new-course-save"/>
<input type="button" value="${_('Cancel')}" class="new-course-cancel" />
</div>
</form>
</div>

View File

@@ -1,4 +1,4 @@
<%! from django.utils.translation import ugettext as _ %>
/<%! from django.utils.translation import ugettext as _ %>
<%inherit file="base.html" />
<%!
import logging
@@ -66,7 +66,8 @@
<h3 class="section-name">
<form class="section-name-form">
<input type="text" value="${_('New Section Name')}" class="new-section-name" />
<input type="submit" class="new-section-name-save" data-parent="${parent_location}" data-template="${new_section_template}" value="${_('Save')}" />
<input type="submit" class="new-section-name-save" data-parent="${parent_location}"
data-category="${new_section_category}" value="${_('Save')}" />
<input type="button" class="new-section-name-cancel" value="${_('Cancel')}" /></h3>
</form>
</div>
@@ -83,8 +84,9 @@
<span class="section-name-span">Click here to set the section name</span>
<form class="section-name-form">
<input type="text" value="${_('New Section Name')}" class="new-section-name" />
<input type="submit" class="new-section-name-save" data-parent="${parent_location}" data-template="${new_section_template}" value="${_('Save')}" />
<input type="button" class="new-section-name-cancel" value="${_('Cancel')}" /></h3>
<input type="submit" class="new-section-name-save" data-parent="${parent_location}"
data-category="${new_section_category}" value="${_('Save')}" />
<input type="button" class="new-section-name-cancel" value="$(_('Cancel')}" /></h3>
</form>
</div>
<div class="item-actions">
@@ -181,7 +183,7 @@
</header>
<div class="subsection-list">
<div class="list-header">
<a href="#" class="new-subsection-item" data-template="${new_subsection_template}">
<a href="#" class="new-subsection-item" data-category="${new_subsection_category}">
<span class="new-folder-icon"></span>${_("New Subsection")}
</a>
</div>

View File

@@ -59,8 +59,8 @@
% if type == 'advanced' or len(templates) > 1:
<a href="#" class="multiple-templates" data-type="${type}">
% else:
% for __, location, __ in templates:
<a href="#" class="single-template" data-type="${type}" data-location="${location}">
% for __, category, __, __ in templates:
<a href="#" class="single-template" data-type="${type}" data-category="${category}">
% endfor
% endif
<span class="large-template-icon large-${type}-icon"></span>
@@ -74,49 +74,60 @@
% if len(templates) > 1 or type == 'advanced':
<div class="new-component-templates new-component-${type}">
% if type == "problem":
<div class="tab-group tabs">
<ul class="problem-type-tabs nav-tabs">
<li class="current">
<a class="link-tab" href="#tab1">${_("Common Problem Types")}</a>
</li>
<li>
<a class="link-tab" href="#tab2">${_("Advanced")}</a>
</li>
</ul>
% endif
<div class="tab current" id="tab1">
<ul class="new-component-template">
% for name, location, has_markdown in templates:
% if has_markdown or type != "problem":
<li class="editor-md">
<a href="#" id="${location}" data-location="${location}">
<span class="name"> ${name}</span>
</a>
</li>
% endif
%endfor
<div class="tab-group tabs">
<ul class="problem-type-tabs nav-tabs">
<li class="current">
<a class="link-tab" href="#tab1">${_("Common Problem Types")}</a>
</li>
<li>
<a class="link-tab" href="#tab2">${_("Advanced")}</a>
</li>
</ul>
</div>
% if type == "problem":
<div class="tab" id="tab2">
<ul class="new-component-template">
% for name, location, has_markdown in templates:
% if not has_markdown:
<li class="editor-manual">
<a href="#" id="${location}" data-location="${location}">
<span class="name"> ${name}</span>
</a>
</li>
% endif
% endfor
</ul>
</div>
</div>
% endif
<a href="#" class="cancel-button">${_("Cancel")}</a>
</div>
% endif
% endif
<div class="tab current" id="tab1">
<ul class="new-component-template">
% for name, category, has_markdown, boilerplate_name in sorted(templates):
% if has_markdown or type != "problem":
% if boilerplate_name is None:
<li class="editor-md empty">
<a href="#" data-category="${category}">
<span class="name">${name}</span>
</a>
</li>
% else:
<li class="editor-md">
<a href="#" data-category="${category}"
data-boilerplate="${boilerplate_name}">
<span class="name">${name}</span>
</a>
</li>
% endif
% endif
%endfor
</ul>
</div>
% if type == "problem":
<div class="tab" id="tab2">
<ul class="new-component-template">
% for name, category, has_markdown, boilerplate_name in sorted(templates):
% if not has_markdown:
<li class="editor-manual">
<a href="#" data-category="${category}"
data-boilerplate="${boilerplate_name}">
<span class="name">${name}</span>
</a>
</li>
% endif
% endfor
</ul>
</div>
</div>
% endif
<a href="#" class="cancel-button">Cancel</a>
</div>
% endif
% endfor
</li>
</ol>

View File

@@ -34,7 +34,7 @@ This def will enumerate through a passed in subsection and list all of the units
</li>
% endfor
<li>
<a href="#" class="new-unit-item" data-template="${create_new_unit_template}" data-parent="${subsection.location}">
<a href="#" class="new-unit-item" data-category="${new_unit_category}" data-parent="${subsection.location}">
<span class="new-unit-icon"></span>New Unit
</a>
</li>

View File

@@ -17,7 +17,7 @@ urlpatterns = ('', # nopep8
url(r'^preview_component/(?P<location>.*?)$', 'contentstore.views.preview_component', name='preview_component'),
url(r'^save_item$', 'contentstore.views.save_item', name='save_item'),
url(r'^delete_item$', 'contentstore.views.delete_item', name='delete_item'),
url(r'^clone_item$', 'contentstore.views.clone_item', name='clone_item'),
url(r'^create_item$', 'contentstore.views.create_item', name='create_item'),
url(r'^create_draft$', 'contentstore.views.create_draft', name='create_draft'),
url(r'^publish_draft$', 'contentstore.views.publish_draft', name='publish_draft'),
url(r'^unpublish_unit$', 'contentstore.views.unpublish_unit', name='unpublish_unit'),

View File

@@ -12,7 +12,6 @@ from django.contrib.sessions.middleware import SessionMiddleware
from student.models import CourseEnrollment
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.templates import update_templates
from urllib import quote_plus
@@ -84,5 +83,4 @@ def clear_courses():
# from the bash shell to drop it:
# $ mongo test_xmodule --eval "db.dropDatabase()"
modulestore().collection.drop()
update_templates(modulestore('direct'))
contentstore().fs_files.drop()

View File

@@ -23,15 +23,15 @@ class TestXmoduleModfiers(ModuleStoreTestCase):
number='313', display_name='histogram test')
section = ItemFactory.create(
parent_location=course.location, display_name='chapter hist',
template='i4x://edx/templates/chapter/Empty')
category='chapter')
problem = ItemFactory.create(
parent_location=section.location, display_name='problem hist 1',
template='i4x://edx/templates/problem/Blank_Common_Problem')
category='problem')
problem.has_score = False # don't trip trying to retrieve db data
late_problem = ItemFactory.create(
parent_location=section.location, display_name='problem hist 2',
template='i4x://edx/templates/problem/Blank_Common_Problem')
category='problem')
late_problem.lms.start = datetime.datetime.now(UTC) + datetime.timedelta(days=32)
late_problem.has_score = False

View File

@@ -89,6 +89,21 @@ def grade_histogram(module_id):
return grades
def save_module(get_html, module):
"""
Updates the given get_html function for the given module to save the fields
after rendering.
"""
@wraps(get_html)
def _get_html():
"""Cache the rendered output, save, then return the output."""
rendered_html = get_html()
module.save()
return rendered_html
return _get_html
def add_histogram(get_html, module, user):
"""
Updates the supplied module with a new get_html function that wraps
@@ -120,7 +135,7 @@ def add_histogram(get_html, module, user):
# doesn't like symlinks)
filepath = filename
data_dir = osfs.root_path.rsplit('/')[-1]
giturl = getattr(module.lms, 'giturl', '') or 'https://github.com/MITx'
giturl = module.lms.giturl or 'https://github.com/MITx'
edit_link = "%s/%s/tree/master/%s" % (giturl, data_dir, filepath)
else:
edit_link = False

View File

@@ -460,10 +460,10 @@ class JSInput(InputTypeBase):
DO NOT USE! HAS NOT BEEN TESTED BEYOND 700X PROBLEMS, AND MAY CHANGE IN
BACKWARDS-INCOMPATIBLE WAYS.
Inputtype for general javascript inputs. Intended to be used with
customresponse.
customresponse.
Loads in a sandboxed iframe to help prevent css and js conflicts between
frame and top-level window.
frame and top-level window.
iframe sandbox whitelist:
- allow-scripts
- allow-popups
@@ -474,9 +474,9 @@ class JSInput(InputTypeBase):
window elements.
Example:
<jsinput html_file="/static/test.html"
gradefn="grade"
height="500"
<jsinput html_file="/static/test.html"
gradefn="grade"
height="500"
width="400"/>
See the documentation in the /doc/public folder for more information.
@@ -500,7 +500,7 @@ class JSInput(InputTypeBase):
Attribute('width', "400"), # iframe width
Attribute('height', "300")] # iframe height
def _extra_context(self):
context = {
@@ -510,11 +510,12 @@ class JSInput(InputTypeBase):
return context
registry.register(JSInput)
#-----------------------------------------------------------------------------
class TextLine(InputTypeBase):
"""
A text line input. Can do math preview if "math"="1" is specified.
@@ -1368,3 +1369,209 @@ class AnnotationInput(InputTypeBase):
return extra_context
registry.register(AnnotationInput)
class ChoiceTextGroup(InputTypeBase):
"""
Groups of radiobutton/checkboxes with text inputs.
Examples:
RadioButton problem
<problem>
<startouttext/>
A person rolls a standard die 100 times and records the results.
On the first roll they received a "1". Given this information
select the correct choice and fill in numbers to make it accurate.
<endouttext/>
<choicetextresponse>
<radiotextgroup>
<choice correct="false">The lowest number rolled was:
<decoy_input/> and the highest number rolled was:
<decoy_input/> .</choice>
<choice correct="true">The lowest number rolled was <numtolerance_input answer="1"/>
and there is not enough information to determine the highest number rolled.
</choice>
<choice correct="false">There is not enough information to determine the lowest
number rolled, and the highest number rolled was:
<decoy_input/> .
</choice>
</radiotextgroup>
</choicetextresponse>
</problem>
CheckboxProblem:
<problem>
<startouttext/>
A person randomly selects 100 times, with replacement, from the list of numbers \(\sqrt{2}\) , 2, 3, 4 ,5 ,6
and records the results. The first number they pick is \(\sqrt{2}\) Given this information
select the correct choices and fill in numbers to make them accurate.
<endouttext/>
<choicetextresponse>
<checkboxtextgroup>
<choice correct="true">
The lowest number selected was <numtolerance_input answer="1.4142" tolerance="0.01"/>
</choice>
<choice correct="false">
The highest number selected was <decoy_input/> .
</choice>
<choice correct="true">There is not enough information given to determine the highest number
which was selected.
</choice>
<choice correct="false">There is not enough information given to determine the lowest number
selected.
</choice>
</checkboxtextgroup>
</choicetextresponse>
</problem>
In the preceding examples the <decoy_input/> is used to generate a textinput html element
in the problem's display. Since it is inside of an incorrect choice, no answer given
for it will be correct, and thus specifying an answer for it is not needed.
"""
template = "choicetext.html"
tags = ['radiotextgroup', 'checkboxtextgroup']
def setup(self):
"""
Performs setup for the initial rendering of the problem.
`self.html_input_type` determines whether this problem is displayed
with radiobuttons or checkboxes
If the initial value of `self.value` is '' change it to {} so that
the template has an empty dictionary to work with.
sets the value of self.choices to be equal to the return value of
`self.extract_choices`
"""
self.text_input_values = {}
if self.tag == 'radiotextgroup':
self.html_input_type = "radio"
elif self.tag == 'checkboxtextgroup':
self.html_input_type = "checkbox"
else:
raise Exception("ChoiceGroup: unexpected tag {0}".format(self.tag))
if self.value == '':
# Make `value` an empty dictionary, if it currently has an empty
# value. This is necessary because the template expects a
# dictionary.
self.value = {}
self.choices = self.extract_choices(self.xml)
@classmethod
def get_attributes(cls):
"""
Returns a list of `Attribute` for this problem type
"""
return [
Attribute("show_correctness", "always"),
Attribute("submitted_message", "Answer received.")
]
def _extra_context(self):
"""
Returns a dictionary of extra content necessary for rendering this InputType.
`input_type` is either 'radio' or 'checkbox' indicating whether the choices for
this problem will have radiobuttons or checkboxes.
"""
return {
'input_type': self.html_input_type,
'choices': self.choices
}
@staticmethod
def extract_choices(element):
"""
Extracts choices from the xml for this problem type.
If we have xml that is as follows(choice names will have been assigned
by now)
<radiotextgroup>
<choice correct = "true" name ="1_2_1_choiceinput_0bc">
The number
<numtolerance_input name = "1_2_1_choiceinput0_numtolerance_input_0" answer="5"/>
Is the mean of the list.
</choice>
<choice correct = "false" name = "1_2_1_choiceinput_1bc>
False demonstration choice
</choice>
</radiotextgroup>
Choices are used for rendering the problem properly
The function will setup choices as follows:
choices =[
("1_2_1_choiceinput_0bc",
[{'type': 'text', 'contents': "The number", 'tail_text': '',
'value': ''
},
{'type': 'textinput',
'contents': "1_2_1_choiceinput0_numtolerance_input_0",
'tail_text': 'Is the mean of the list',
'value': ''
}
]
),
("1_2_1_choiceinput_1bc",
[{'type': 'text', 'contents': "False demonstration choice",
'tail_text': '',
'value': ''
}
]
)
]
"""
choices = []
for choice in element:
if choice.tag != 'choice':
raise Exception(
"[capa.inputtypes.extract_choices] Expected a <choice>" +
"tag; got {0} instead".format(choice.tag)
)
components = []
choice_text = ''
if choice.text is not None:
choice_text += choice.text
# Initialize our dict for the next content
adder = {
'type': 'text',
'contents': choice_text,
'tail_text': '',
'value': ''
}
components.append(adder)
for elt in choice:
# for elements in the choice e.g. <text> <numtolerance_input>
adder = {
'type': 'text',
'contents': '',
'tail_text': '',
'value': ''
}
tag_type = elt.tag
# If the current `elt` is a <numtolerance_input> set the
# `adder`type to 'numtolerance_input', and 'contents' to
# the `elt`'s name.
# Treat decoy_inputs and numtolerance_inputs the same in order
# to prevent students from reading the Html and figuring out
# which inputs are valid
if tag_type in ('numtolerance_input', 'decoy_input'):
# We set this to textinput, so that we get a textinput html
# element.
adder['type'] = 'textinput'
adder['contents'] = elt.get('name')
else:
adder['contents'] = elt.text
# Add any tail text("is the mean" in the example)
adder['tail_text'] = elt.tail if elt.tail else ''
components.append(adder)
# Add the tuple for the current choice to the list of choices
choices.append((choice.get("name"), components))
return choices
registry.register(ChoiceTextGroup)

View File

@@ -2097,6 +2097,333 @@ class AnnotationResponse(LoncapaResponse):
return option_ids[0]
return None
class ChoiceTextResponse(LoncapaResponse):
"""
Allows for multiple choice responses with text inputs
Desired semantics match those of NumericalResponse and
ChoiceResponse.
"""
response_tag = 'choicetextresponse'
max_inputfields = 1
allowed_inputfields = ['choicetextgroup',
'checkboxtextgroup',
'radiotextgroup'
]
def setup_response(self):
"""
Sets up three dictionaries for use later:
`correct_choices`: These are the correct binary choices(radio/checkbox)
`correct_inputs`: These are the numerical/string answers for required
inputs.
`answer_values`: This is a dict, keyed by the name of the binary choice
which contains the correct answers for the text inputs separated by
commas e.g. "1, 0.5"
`correct_choices` and `correct_inputs` are used for grading the problem
and `answer_values` is used for displaying correct answers.
"""
context = self.context
self.correct_choices = {}
self.assign_choice_names()
self.correct_inputs = {}
self.answer_values = {self.answer_id: []}
correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]',
id=self.xml.get('id'))
for node in correct_xml:
# For each correct choice, set the `parent_name` to the
# current choice's name
parent_name = node.get('name')
# Add the name of the correct binary choice to the
# correct choices list as a key. The value is not important.
self.correct_choices[parent_name] = {'answer': ''}
# Add the name of the parent to the list of correct answers
self.answer_values[self.answer_id].append(parent_name)
answer_list = []
# Loop over <numtolerance_input> elements inside of the correct choices
for child in node:
answer = child.get('answer', None)
if not answer:
# If the question creator does not specify an answer for a
# <numtolerance_input> inside of a correct choice, raise an error
raise LoncapaProblemError(
"Answer not provided for numtolerance_input"
)
# Contextualize the answer to allow script generated answers.
answer = contextualize_text(answer, context)
input_name = child.get('name')
# Contextualize the tolerance to value.
tolerance = contextualize_text(
child.get('tolerance', '0'),
context
)
# Add the answer and tolerance information for the current
# numtolerance_input to `correct_inputs`
self.correct_inputs[input_name] = {
'answer': answer,
'tolerance': tolerance
}
# Add the correct answer for this input to the list for show
answer_list.append(answer)
# Turn the list of numtolerance_input answers into a comma separated string.
self.answer_values[parent_name] = ', '.join(answer_list)
# Turn correct choices into a set. Allows faster grading.
self.correct_choices = set(self.correct_choices.keys())
def assign_choice_names(self):
"""
Initialize name attributes in <choice> and <numtolerance_input> tags
for this response.
Example:
Assuming for simplicity that `self.answer_id` = '1_2_1'
Before the function is called `self.xml` =
<radiotextgroup>
<choice correct = "true">
The number
<numtolerance_input answer="5"/>
Is the mean of the list.
</choice>
<choice correct = "false">
False demonstration choice
</choice>
</radiotextgroup>
After this is called the choices and numtolerance_inputs will have a name
attribute initialized and self.xml will be:
<radiotextgroup>
<choice correct = "true" name ="1_2_1_choiceinput_0bc">
The number
<numtolerance_input name = "1_2_1_choiceinput0_numtolerance_input_0"
answer="5"/>
Is the mean of the list.
</choice>
<choice correct = "false" name = "1_2_1_choiceinput_1bc>
False demonstration choice
</choice>
</radiotextgroup>
"""
for index, choice in enumerate(
self.xml.xpath('//*[@id=$id]//choice', id=self.xml.get('id'))
):
# Set the name attribute for <choices>
# "bc" is appended at the end to indicate that this is a
# binary choice as opposed to a numtolerance_input, this convention
# is used when grading the problem
choice.set(
"name",
self.answer_id + "_choiceinput_" + str(index) + "bc"
)
# Set Name attributes for <numtolerance_input> elements
# Look for all <numtolerance_inputs> inside this choice.
numtolerance_inputs = choice.findall('numtolerance_input')
# Look for all <decoy_input> inside this choice
decoys = choice.findall('decoy_input')
# <decoy_input> would only be used in choices which do not contain
# <numtolerance_input>
inputs = numtolerance_inputs if numtolerance_inputs else decoys
# Give each input inside of the choice a name combining
# The ordinality of the choice, and the ordinality of the input
# within that choice e.g. 1_2_1_choiceinput_0_numtolerance_input_1
for ind, child in enumerate(inputs):
child.set(
"name",
self.answer_id + "_choiceinput_" + str(index) +
"_numtolerance_input_" + str(ind)
)
def get_score(self, student_answers):
"""
Returns a `CorrectMap` showing whether `student_answers` are correct.
`student_answers` contains keys for binary inputs(radiobutton,
checkbox) and numerical inputs. Keys ending with 'bc' are binary
choice inputs otherwise they are text fields.
This method first separates the two
types of answers and then grades them in separate methods.
The student is only correct if they have both the binary inputs and
numerical inputs correct.
"""
answer_dict = student_answers.get(self.answer_id, "")
binary_choices, numtolerance_inputs = self._split_answers_dict(answer_dict)
# Check the binary choices first.
choices_correct = self._check_student_choices(binary_choices)
inputs_correct = self._check_student_inputs(numtolerance_inputs)
# Only return correct if the student got both the binary
# and numtolerance_inputs are correct
correct = choices_correct and inputs_correct
return CorrectMap(
self.answer_id,
'correct' if correct else 'incorrect'
)
def get_answers(self):
"""
Returns a dictionary containing the names of binary choices as keys
and a string of answers to any numtolerance_inputs which they may have
e.g {choice_1bc : "answer1, answer2", choice_2bc : ""}
"""
return self.answer_values
def _split_answers_dict(self, a_dict):
"""
Returns two dicts:
`binary_choices` : dictionary {input_name: input_value} for
the binary choices which the student selected.
and
`numtolerance_choices` : a dictionary {input_name: input_value}
for the numtolerance_inputs inside of choices which were selected
Determines if an input is inside of a binary input by looking at
the beginning of it's name.
For example. If a binary_choice was named '1_2_1_choiceinput_0bc'
All of the numtolerance_inputs in it would have an idea that begins
with '1_2_1_choice_input_0_numtolerance_input'
Splits the name of the numtolerance_input at the occurence of
'_numtolerance_input_' and appends 'bc' to the end to get the name
of the choice it is contained in.
Example:
`a_dict` = {
'1_2_1_choiceinput_0bc': '1_2_1_choiceinput_0bc',
'1_2_1_choiceinput_0_numtolerance_input_0': '1',
'1_2_1_choiceinput_0_numtolerance_input_1': '2'
'1_2_1_choiceinput_1_numtolerance_input_0': '3'
}
In this case, the binary choice is '1_2_1_choiceinput_0bc', and
the numtolerance_inputs associated with it are
'1_2_1_choiceinput_0_numtolerance_input_0', and
'1_2_1_choiceinput_0_numtolerance_input_1'.
so the two return dictionaries would be
`binary_choices` = {'1_2_1_choiceinput_0bc': '1_2_1_choiceinput_0bc'}
and
`numtolerance_choices` ={
'1_2_1_choiceinput_0_numtolerance_input_0': '1',
'1_2_1_choiceinput_0_numtolerance_input_1': '2'
}
The entry '1_2_1_choiceinput_1_numtolerance_input_0': '3' is discarded
because it was not inside of a selected binary choice, and no validation
should be performed on numtolerance_inputs inside of non-selected choices.
"""
# Initialize the two dictionaries that are returned
numtolerance_choices = {}
binary_choices = {}
# `selected_choices` is a list of binary choices which were "checked/selected"
# when the student submitted the problem.
# Keys in a_dict ending with 'bc' refer to binary choices.
selected_choices = [key for key in a_dict if key.endswith("bc")]
for key in selected_choices:
binary_choices[key] = a_dict[key]
# Convert the name of a numtolerance_input into the name of the binary
# choice that it is contained within, and append it to the list if
# the numtolerance_input's parent binary_choice is contained in
# `selected_choices`.
selected_numtolerance_inputs = [
key for key in a_dict if key.partition("_numtolerance_input_")[0] + "bc"
in selected_choices
]
for key in selected_numtolerance_inputs:
numtolerance_choices[key] = a_dict[key]
return (binary_choices, numtolerance_choices)
def _check_student_choices(self, choices):
"""
Compares student submitted checkbox/radiobutton answers against
the correct answers. Returns True or False.
True if all of the correct choices are selected and no incorrect
choices are selected.
"""
student_choices = set(choices)
required_selected = len(self.correct_choices - student_choices) == 0
no_extra_selected = len(student_choices - self.correct_choices) == 0
correct = required_selected and no_extra_selected
return correct
def _check_student_inputs(self, numtolerance_inputs):
"""
Compares student submitted numerical answers against the correct
answers and tolerances.
`numtolerance_inputs` is a dictionary {answer_name : answer_value}
Performs numerical validation by means of calling
`compare_with_tolerance()` on all of `numtolerance_inputs`
Performs a call to `compare_with_tolerance` even on values for
decoy_inputs. This is used to validate their numericality and
raise an error if the student entered a non numerical expression.
Returns True if and only if all student inputs are correct.
"""
inputs_correct = True
for answer_name, answer_value in numtolerance_inputs.iteritems():
# If `self.corrrect_inputs` does not contain an entry for
# `answer_name`, this means that answer_name is a decoy
# input's value, and validation of its numericality is the
# only thing of interest from the later call to
# `compare_with_tolerance`.
params = self.correct_inputs.get(answer_name, {'answer': 0})
correct_ans = params['answer']
# Set the tolerance to '0' if it was not specified in the xml
tolerance = params.get('tolerance', '0')
# Make sure that the staff answer is a valid number
try:
correct_ans = complex(correct_ans)
except ValueError:
log.debug(
"Content error--answer" +
"'{0}' is not a valid complex number".format(correct_ans)
)
raise StudentInputError(
"The Staff answer could not be interpreted as a number."
)
# Compare the student answer to the staff answer/ or to 0
# if all that is important is verifying numericality
try:
partial_correct = compare_with_tolerance(
evaluator(dict(), dict(), answer_value),
correct_ans,
tolerance
)
except:
# Use the traceback-preserving version of re-raising with a
# different type
_, _, trace = sys.exc_info()
raise StudentInputError(
"Could not interpret '{0}' as a number{1}".format(
cgi.escape(answer_value),
trace
)
)
# Ignore the results of the comparisons which were just for
# Numerical Validation.
if answer_name in self.correct_inputs and not partial_correct:
# If any input is not correct, set the return value to False
inputs_correct = False
return inputs_correct
#-----------------------------------------------------------------------------
# TEMPORARY: List of all response subclasses
@@ -2116,4 +2443,5 @@ __all__ = [CodeResponse,
MultipleChoiceResponse,
TrueFalseResponse,
JavascriptResponse,
AnnotationResponse]
AnnotationResponse,
ChoiceTextResponse]

View File

@@ -0,0 +1,76 @@
<% element_checked = False %>
% for choice_id, _ in choices:
<%choice_id = choice_id %>
%if choice_id in value:
<% element_checked = True %>
%endif
%endfor
<section id="choicetextinput_${id}" class="choicetextinput">
<form class="choicetextgroup capa_inputtype" id="inputtype_${id}">
<div class="script_placeholder" data-src="/static/js/capa/choicetextinput.js"/>
<div class="indicator_container">
% if input_type == 'checkbox' or not element_checked:
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif status == 'correct':
<span class="correct" id="status_${id}"></span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}"></span>
% elif status == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
% endif
% endif
</div>
<fieldset>
% for choice_id, choice_description in choices:
<%choice_id= choice_id %>
<section id="forinput${choice_id}"
% if input_type == 'radio' and choice_id in value :
<%
if status == 'correct':
correctness = 'correct'
elif status == 'incorrect':
correctness = 'incorrect'
else:
correctness = None
%>
% if correctness:
class="choicetextgroup_${correctness}"
% endif
% endif
>
<input class="ctinput" type="${input_type}" name="choiceinput_${id}" id="${choice_id}" value="${choice_id}"
% if choice_id in value:
checked="true"
% endif
/>
% for content_node in choice_description:
% if content_node['type'] == 'text':
<span class="mock_label">
${content_node['contents']}
</span>
% else:
<% my_id = content_node.get('contents','') %>
<% my_val = value.get(my_id,'') %>
<input class="ctinput" type="text" name="${content_node['contents']}" id="${content_node['contents']}" value="${my_val|h} "/>
%endif
<span class="mock_label">
${content_node['tail_text']}
</span>
% endfor
<p id="answer_${choice_id}" class="answer"></p>
</section>
% endfor
<span id="answer_${id}"></span>
</fieldset>
<input class= "choicetextvalue" type="hidden" name="input_${id}{}" id="input_${id}" value="${value|h}" />
% if show_correctness == "never" and (value or status not in ['unsubmitted']):
<div class="capa_alert">${submitted_message}</div>
%endif
</form>
</section>

View File

@@ -779,3 +779,109 @@ class SymbolicResponseXMLFactory(ResponseXMLFactory):
def create_input_element(self, **kwargs):
return ResponseXMLFactory.textline_input_xml(**kwargs)
class ChoiceTextResponseXMLFactory(ResponseXMLFactory):
""" Factory for producing <choicetextresponse> xml """
def create_response_element(self, **kwargs):
""" Create a <choicetextresponse> element """
return etree.Element("choicetextresponse")
def create_input_element(self, **kwargs):
""" Create a <checkboxgroup> element.
choices can be specified in the following format:
[("true", [{"answer": "5", "tolerance": 0}]),
("false", [{"answer": "5", "tolerance": 0}])
]
This indicates that the first checkbox/radio is correct and it
contains a numtolerance_input with an answer of 5 and a tolerance of 0
It also indicates that the second has a second incorrect radiobutton
or checkbox with a numtolerance_input.
"""
choices = kwargs.get('choices', [("true", {})])
choice_inputs = []
# Ensure that the first element of choices is an ordered
# collection. It will start as a list, a tuple, or not a Container.
if type(choices[0]) not in [list, tuple]:
choices = [choices]
for choice in choices:
correctness, answers = choice
numtolerance_inputs = []
# If the current `choice` contains any("answer": number)
# elements, turn those into numtolerance_inputs
if answers:
# `answers` will be a list or tuple of answers or a single
# answer, representing the answers for numtolerance_inputs
# inside of this specific choice.
# Make sure that `answers` is an ordered collection for
# convenience.
if type(answers) not in [list, tuple]:
answers = [answers]
numtolerance_inputs = [
self._create_numtolerance_input_element(answer)
for answer in answers
]
choice_inputs.append(
self._create_choice_element(
correctness=correctness,
inputs=numtolerance_inputs
)
)
# Default type is 'radiotextgroup'
input_type = kwargs.get('type', 'radiotextgroup')
input_element = etree.Element(input_type)
for ind, choice in enumerate(choice_inputs):
# Give each choice text equal to it's position(0,1,2...)
choice.text = "choice_{0}".format(ind)
input_element.append(choice)
return input_element
def _create_choice_element(self, **kwargs):
"""
Creates a choice element for a choictextproblem.
Defaults to a correct choice with no numtolerance_input
"""
text = kwargs.get('text', '')
correct = kwargs.get('correctness', "true")
inputs = kwargs.get('inputs', [])
choice_element = etree.Element("choice")
choice_element.set("correct", correct)
choice_element.text = text
for inp in inputs:
# Add all of the inputs as children of this choice
choice_element.append(inp)
return choice_element
def _create_numtolerance_input_element(self, params):
"""
Creates a <numtolerance_input/> or <decoy_input/> element with
optionally specified tolerance and answer.
"""
answer = params['answer'] if 'answer' in params else None
# If there is not an answer specified, Then create a <decoy_input/>
# otherwise create a <numtolerance_input/> and set its tolerance
# and answer attributes.
if answer:
text_input = etree.Element("numtolerance_input")
text_input.set('answer', answer)
# If tolerance was specified, was specified use it, otherwise
# Set the tolerance to "0"
text_input.set(
'tolerance',
params['tolerance'] if 'tolerance' in params else "0"
)
else:
text_input = etree.Element("decoy_input")
return text_input

View File

@@ -714,3 +714,170 @@ class DragAndDropTemplateTest(TemplateTestCase):
# escaping the HTML. We should be able to traverse the XML tree.
xpath = "//div[@class='drag_and_drop_problem_json']/p/b"
self.assert_has_text(xml, xpath, 'HTML')
class ChoiceTextGroupTemplateTest(TemplateTestCase):
"""Test mako template for `<choicetextgroup>` input"""
TEMPLATE_NAME = 'choicetext.html'
VALUE_DICT = {'1_choiceinput_0bc': '1_choiceinput_0bc', '1_choiceinput_0_textinput_0': '0',
'1_choiceinput_1_textinput_0': '0'}
EMPTY_DICT = {'1_choiceinput_0_textinput_0': '',
'1_choiceinput_1_textinput_0': ''}
BOTH_CHOICE_CHECKBOX = {'1_choiceinput_0bc': 'choiceinput_0',
'1_choiceinput_1bc': 'choiceinput_1',
'1_choiceinput_0_textinput_0': '0',
'1_choiceinput_1_textinput_0': '0'}
WRONG_CHOICE_CHECKBOX = {'1_choiceinput_1bc': 'choiceinput_1',
'1_choiceinput_0_textinput_0': '0',
'1_choiceinput_1_textinput_0': '0'}
def setUp(self):
choices = [('1_choiceinput_0bc',
[{'tail_text': '', 'type': 'text', 'value': '', 'contents': ''},
{'tail_text': '', 'type': 'textinput', 'value': '', 'contents': 'choiceinput_0_textinput_0'}]),
('1_choiceinput_1bc', [{'tail_text': '', 'type': 'text', 'value': '', 'contents': ''},
{'tail_text': '', 'type': 'textinput', 'value': '', 'contents': 'choiceinput_1_textinput_0'}])]
self.context = {'id': '1',
'choices': choices,
'status': 'correct',
'input_type': 'radio',
'value': self.VALUE_DICT}
super(ChoiceTextGroupTemplateTest, self).setUp()
def test_grouping_tag(self):
"""
Tests whether we are using a section or a label to wrap choice elements.
Section is used for checkbox, so inputting text does not deselect
"""
input_tags = ('radio', 'checkbox')
self.context['status'] = 'correct'
xpath = "//section[@id='forinput1_choiceinput_0bc']"
self.context['value'] = {}
for input_type in input_tags:
self.context['input_type'] = input_type
xml = self.render_to_xml(self.context)
self.assert_has_xpath(xml, xpath, self.context)
def test_problem_marked_correct(self):
"""Test conditions under which the entire problem
(not a particular option) is marked correct"""
self.context['status'] = 'correct'
self.context['input_type'] = 'checkbox'
self.context['value'] = self.VALUE_DICT
# Should mark the entire problem correct
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='correct']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
self.assert_no_xpath(xml, "//label[@class='choicetextgroup_incorrect']",
self.context)
self.assert_no_xpath(xml, "//label[@class='choicetextgroup_correct']",
self.context)
def test_problem_marked_incorrect(self):
"""Test all conditions under which the entire problem
(not a particular option) is marked incorrect"""
grouping_tags = {'radio': 'label', 'checkbox': 'section'}
conditions = [
{'status': 'incorrect', 'input_type': 'radio', 'value': {}},
{'status': 'incorrect', 'input_type': 'checkbox', 'value': self.WRONG_CHOICE_CHECKBOX},
{'status': 'incorrect', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX},
{'status': 'incorrect', 'input_type': 'checkbox', 'value': self.VALUE_DICT},
{'status': 'incomplete', 'input_type': 'radio', 'value': {}},
{'status': 'incomplete', 'input_type': 'checkbox', 'value': self.WRONG_CHOICE_CHECKBOX},
{'status': 'incomplete', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX},
{'status': 'incomplete', 'input_type': 'checkbox', 'value': self.VALUE_DICT}]
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='incorrect']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
grouping_tag = grouping_tags[test_conditions['input_type']]
self.assert_no_xpath(xml,
"//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag),
self.context)
self.assert_no_xpath(xml,
"//{0}[@class='choicetextgroup_correct']".format(grouping_tag),
self.context)
def test_problem_marked_unsubmitted(self):
"""Test all conditions under which the entire problem
(not a particular option) is marked unanswered"""
grouping_tags = {'radio': 'label', 'checkbox': 'section'}
conditions = [
{'status': 'unsubmitted', 'input_type': 'radio', 'value': {}},
{'status': 'unsubmitted', 'input_type': 'radio', 'value': self.EMPTY_DICT},
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': {}},
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.EMPTY_DICT},
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.VALUE_DICT},
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX}]
self.context['status'] = 'unanswered'
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='unanswered']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
grouping_tag = grouping_tags[test_conditions['input_type']]
self.assert_no_xpath(xml,
"//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag),
self.context)
self.assert_no_xpath(xml,
"//{0}[@class='choicetextgroup_correct']".format(grouping_tag),
self.context)
def test_option_marked_correct(self):
"""Test conditions under which a particular option
(not the entire problem) is marked correct."""
conditions = [
{'input_type': 'radio', 'value': self.VALUE_DICT}]
self.context['status'] = 'correct'
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//section[@id='forinput1_choiceinput_0bc' and\
@class='choicetextgroup_correct']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator_container']/span"
self.assert_no_xpath(xml, xpath, self.context)
def test_option_marked_incorrect(self):
"""Test conditions under which a particular option
(not the entire problem) is marked incorrect."""
conditions = [
{'input_type': 'radio', 'value': self.VALUE_DICT}]
self.context['status'] = 'incorrect'
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//section[@id='forinput1_choiceinput_0bc' and\
@class='choicetextgroup_incorrect']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator_container']/span"
self.assert_no_xpath(xml, xpath, self.context)

View File

@@ -860,3 +860,94 @@ class AnnotationInputTest(unittest.TestCase):
self.maxDiff = None
self.assertDictEqual(context, expected)
class TestChoiceText(unittest.TestCase):
"""
Tests for checkboxtextgroup inputs
"""
@staticmethod
def build_choice_element(node_type, contents, tail_text, value):
"""
Builds a content node for a choice.
"""
# When xml is being parsed numtolerance_input and decoy_input tags map to textinput type
# in order to provide the template with correct rendering information.
if node_type in ('numtolerance_input', 'decoy_input'):
node_type = 'textinput'
choice = {'type': node_type, 'contents': contents, 'tail_text': tail_text, 'value': value}
return choice
def check_group(self, tag, choice_tag, expected_input_type):
"""
Build a radio or checkbox group, parse it and check the resuls against the
expected output.
`tag` should be 'checkboxtextgroup' or 'radiotextgroup'
`choice_tag` is either 'choice' for proper xml, or any other value to trigger an error.
`expected_input_type` is either 'radio' or 'checkbox'.
"""
xml_str = """
<{tag}>
<{choice_tag} correct="false" name="choiceinput_0">this is<numtolerance_input name="choiceinput_0_textinput_0"/>false</{choice_tag}>
<choice correct="true" name="choiceinput_1">Is a number<decoy_input name="choiceinput_1_textinput_0"/><text>!</text></choice>
</{tag}>
""".format(tag=tag, choice_tag=choice_tag)
element = etree.fromstring(xml_str)
state = {
'value': '{}',
'id': 'choicetext_input',
'status': 'answered'
}
first_input = self.build_choice_element('numtolerance_input', 'choiceinput_0_textinput_0', 'false', '')
second_input = self.build_choice_element('decoy_input', 'choiceinput_1_textinput_0', '', '')
first_choice_content = self.build_choice_element('text', 'this is', '', '')
second_choice_content = self.build_choice_element('text', 'Is a number', '', '')
second_choice_text = self.build_choice_element('text', "!", '', '')
choices = [
('choiceinput_0', [first_choice_content, first_input]),
('choiceinput_1', [second_choice_content, second_input, second_choice_text])
]
expected = {
'msg': '',
'input_type': expected_input_type,
'choices': choices,
'show_correctness': 'always',
'submitted_message': 'Answer received.'
}
expected.update(state)
the_input = lookup_tag(tag)(test_system(), element, state)
context = the_input._get_render_context()
self.assertEqual(context, expected)
def test_radiotextgroup(self):
"""
Test that a properly formatted radiotextgroup problem generates
expected ouputs
"""
self.check_group('radiotextgroup', 'choice', 'radio')
def test_checkboxtextgroup(self):
"""
Test that a properly formatted checkboxtextgroup problem generates
expected ouput
"""
self.check_group('checkboxtextgroup', 'choice', 'checkbox')
def test_invalid_tag(self):
"""
Test to ensure that an unrecognized inputtype tag causes an error
"""
with self.assertRaises(Exception):
self.check_group('invalid', 'choice', 'checkbox')
def test_invalid_input_tag(self):
"""
Test to ensure having a tag other than <choice> inside of
a checkbox or radiotextgroup problem raises an error.
"""
with self.assertRaisesRegexp(Exception, "Error in xml"):
self.check_group('checkboxtextgroup', 'invalid', 'checkbox')

View File

@@ -1429,3 +1429,357 @@ class AnnotationResponseTest(ResponseTest):
msg="%s should be marked %s" % (answer_id, expected_correctness))
self.assertEqual(expected_points, actual_points,
msg="%s should have %d points" % (answer_id, expected_points))
class ChoiceTextResponseTest(ResponseTest):
"""
Class containing setup and tests for ChoiceText responsetype.
"""
from response_xml_factory import ChoiceTextResponseXMLFactory
xml_factory_class = ChoiceTextResponseXMLFactory
# `TEST_INPUTS` is a dictionary mapping from
# test_name to a representation of inputs for a test problem.
TEST_INPUTS = {
"1_choice_0_input_correct": [(True, [])],
"1_choice_0_input_incorrect": [(False, [])],
"1_choice_0_input_invalid_choice": [(False, []), (True, [])],
"1_choice_1_input_correct": [(True, ["123"])],
"1_input_script_correct": [(True, ["2"])],
"1_input_script_incorrect": [(True, ["3.25"])],
"1_choice_2_inputs_correct": [(True, ["123", "456"])],
"1_choice_2_inputs_tolerance": [(True, ["123 + .5", "456 + 9"])],
"1_choice_2_inputs_1_wrong": [(True, ["0", "456"])],
"1_choice_2_inputs_both_wrong": [(True, ["0", "0"])],
"1_choice_2_inputs_inputs_blank": [(True, ["", ""])],
"1_choice_2_inputs_empty": [(False, [])],
"1_choice_2_inputs_fail_tolerance": [(True, ["123 + 1.5", "456 + 9"])],
"1_choice_1_input_within_tolerance": [(True, ["122.5"])],
"1_choice_1_input_answer_incorrect": [(True, ["345"])],
"1_choice_1_input_choice_incorrect": [(False, ["123"])],
"2_choices_0_inputs_correct": [(False, []), (True, [])],
"2_choices_0_inputs_incorrect": [(True, []), (False, [])],
"2_choices_0_inputs_blank": [(False, []), (False, [])],
"2_choices_1_input_1_correct": [(False, []), (True, ["123"])],
"2_choices_1_input_1_incorrect": [(True, []), (False, ["123"])],
"2_choices_1_input_input_wrong": [(False, []), (True, ["321"])],
"2_choices_1_input_1_blank": [(False, []), (False, [])],
"2_choices_1_input_2_correct": [(True, []), (False, ["123"])],
"2_choices_1_input_2_incorrect": [(False, []), (True, ["123"])],
"2_choices_2_inputs_correct": [(True, ["123"]), (False, [])],
"2_choices_2_inputs_wrong_choice": [(False, ["123"]), (True, [])],
"2_choices_2_inputs_wrong_input": [(True, ["321"]), (False, [])]
}
# `TEST_SCENARIOS` is a dictionary of the form
# {Test_Name" : (Test_Problem_name, correctness)}
# correctness represents whether the problem should be graded as
# correct or incorrect when the test is run.
TEST_SCENARIOS = {
"1_choice_0_input_correct": ("1_choice_0_input", "correct"),
"1_choice_0_input_incorrect": ("1_choice_0_input", "incorrect"),
"1_choice_0_input_invalid_choice": ("1_choice_0_input", "incorrect"),
"1_input_script_correct": ("1_input_script", "correct"),
"1_input_script_incorrect": ("1_input_script", "incorrect"),
"1_choice_2_inputs_correct": ("1_choice_2_inputs", "correct"),
"1_choice_2_inputs_tolerance": ("1_choice_2_inputs", "correct"),
"1_choice_2_inputs_1_wrong": ("1_choice_2_inputs", "incorrect"),
"1_choice_2_inputs_both_wrong": ("1_choice_2_inputs", "incorrect"),
"1_choice_2_inputs_inputs_blank": ("1_choice_2_inputs", "incorrect"),
"1_choice_2_inputs_empty": ("1_choice_2_inputs", "incorrect"),
"1_choice_2_inputs_fail_tolerance": ("1_choice_2_inputs", "incorrect"),
"1_choice_1_input_correct": ("1_choice_1_input", "correct"),
"1_choice_1_input_within_tolerance": ("1_choice_1_input", "correct"),
"1_choice_1_input_answer_incorrect": ("1_choice_1_input", "incorrect"),
"1_choice_1_input_choice_incorrect": ("1_choice_1_input", "incorrect"),
"2_choices_0_inputs_correct": ("2_choices_0_inputs", "correct"),
"2_choices_0_inputs_incorrect": ("2_choices_0_inputs", "incorrect"),
"2_choices_0_inputs_blank": ("2_choices_0_inputs", "incorrect"),
"2_choices_1_input_1_correct": ("2_choices_1_input_1", "correct"),
"2_choices_1_input_1_incorrect": ("2_choices_1_input_1", "incorrect"),
"2_choices_1_input_input_wrong": ("2_choices_1_input_1", "incorrect"),
"2_choices_1_input_1_blank": ("2_choices_1_input_1", "incorrect"),
"2_choices_1_input_2_correct": ("2_choices_1_input_2", "correct"),
"2_choices_1_input_2_incorrect": ("2_choices_1_input_2", "incorrect"),
"2_choices_2_inputs_correct": ("2_choices_2_inputs", "correct"),
"2_choices_2_inputs_wrong_choice": ("2_choices_2_inputs", "incorrect"),
"2_choices_2_inputs_wrong_input": ("2_choices_2_inputs", "incorrect")
}
# Dictionary that maps from problem_name to arguments for
# _make_problem, that will create the problem.
TEST_PROBLEM_ARGS = {
"1_choice_0_input": {"choices": ("true", {}), "script": ''},
"1_choice_1_input": {
"choices": ("true", {"answer": "123", "tolerance": "1"}),
"script": ''
},
"1_input_script": {
"choices": ("true", {"answer": "$computed_response", "tolerance": "1"}),
"script": "computed_response = math.sqrt(4)"
},
"1_choice_2_inputs": {
"choices": [
(
"true", (
{"answer": "123", "tolerance": "1"},
{"answer": "456", "tolerance": "10"}
)
)
],
"script": ''
},
"2_choices_0_inputs": {
"choices": [("false", {}), ("true", {})],
"script": ''
},
"2_choices_1_input_1": {
"choices": [
("false", {}), ("true", {"answer": "123", "tolerance": "0"})
],
"script": ''
},
"2_choices_1_input_2": {
"choices": [("true", {}), ("false", {"answer": "123", "tolerance": "0"})],
"script": ''
},
"2_choices_2_inputs": {
"choices": [
("true", {"answer": "123", "tolerance": "0"}),
("false", {"answer": "999", "tolerance": "0"})
],
"script": ''
}
}
def _make_problem(self, choices, in_type='radiotextgroup', script=''):
"""
Convenience method to fill in default values for script and
type if needed, then call self.build_problem
"""
return self.build_problem(
choices=choices,
type=in_type,
script=script
)
def _make_answer_dict(self, choice_list):
"""
Convenience method to make generation of answers less tedious,
pass in an iterable argument with elements of the form: [bool, [ans,]]
Will generate an answer dict for those options
"""
answer_dict = {}
for index, choice_answers_pair in enumerate(choice_list):
# Choice is whether this choice is correct
# Answers contains a list of answers to textinpts for the choice
choice, answers = choice_answers_pair
if choice:
# Radio/Checkbox inputs in choicetext problems follow
# a naming convention that gives them names ending with "bc"
choice_id = "1_2_1_choiceinput_{index}bc".format(index=index)
choice_value = "choiceinput_{index}".format(index=index)
answer_dict[choice_id] = choice_value
# Build the names for the numtolerance_inputs and add their answers
# to `answer_dict`.
for ind, answer in enumerate(answers):
# In `answer_id` `index` represents the ordinality of the
# choice and `ind` represents the ordinality of the
# numtolerance_input inside the parent choice.
answer_id = "1_2_1_choiceinput_{index}_numtolerance_input_{ind}".format(
index=index,
ind=ind
)
answer_dict[answer_id] = answer
return answer_dict
def test_invalid_xml(self):
"""
Test that build problem raises errors for invalid options
"""
with self.assertRaises(Exception):
self.build_problem(type="invalidtextgroup")
def test_valid_xml(self):
"""
Test that `build_problem` builds valid xml
"""
self.build_problem()
self.assertTrue(True)
def test_unchecked_input_not_validated(self):
"""
Test that a student can have a non numeric answer in an unselected
choice without causing an error to be raised when the problem is
checked.
"""
two_choice_two_input = self._make_problem(
[
("true", {"answer": "123", "tolerance": "1"}),
("false", {})
],
"checkboxtextgroup"
)
self.assert_grade(
two_choice_two_input,
self._make_answer_dict([(True, ["1"]), (False, ["Platypus"])]),
"incorrect"
)
def test_interpret_error(self):
"""
Test that student answers that cannot be interpeted as numbers
cause the response type to raise an error.
"""
two_choice_two_input = self._make_problem(
[
("true", {"answer": "123", "tolerance": "1"}),
("false", {})
],
"checkboxtextgroup"
)
with self.assertRaisesRegexp(StudentInputError, "Could not interpret"):
# Test that error is raised for input in selected correct choice.
self.assert_grade(
two_choice_two_input,
self._make_answer_dict([(True, ["Platypus"])]),
"correct"
)
with self.assertRaisesRegexp(StudentInputError, "Could not interpret"):
# Test that error is raised for input in selected incorrect choice.
self.assert_grade(
two_choice_two_input,
self._make_answer_dict([(True, ["1"]), (True, ["Platypus"])]),
"correct"
)
def test_staff_answer_error(self):
broken_problem = self._make_problem(
[("true", {"answer": "Platypus", "tolerance": "0"}),
("true", {"answer": "edX", "tolerance": "0"})
],
"checkboxtextgroup"
)
with self.assertRaisesRegexp(
StudentInputError,
"The Staff answer could not be interpreted as a number."
):
self.assert_grade(
broken_problem,
self._make_answer_dict(
[(True, ["1"]), (True, ["1"])]
),
"correct"
)
def test_radio_grades(self):
"""
Test that confirms correct operation of grading when the inputtag is
radiotextgroup.
"""
for name, inputs in self.TEST_INPUTS.iteritems():
# Turn submission into the form expected when grading this problem.
submission = self._make_answer_dict(inputs)
# Lookup the problem_name, and the whether this test problem
# and inputs should be graded as correct or incorrect.
problem_name, correctness = self.TEST_SCENARIOS[name]
# Load the args needed to build the problem for this test.
problem_args = self.TEST_PROBLEM_ARGS[problem_name]
test_choices = problem_args["choices"]
test_script = problem_args["script"]
# Build the actual problem for the test.
test_problem = self._make_problem(test_choices, 'radiotextgroup', test_script)
# Make sure the actual grade matches the expected grade.
self.assert_grade(
test_problem,
submission,
correctness,
msg="{0} should be {1}".format(
name,
correctness
)
)
def test_checkbox_grades(self):
"""
Test that confirms correct operation of grading when the inputtag is
checkboxtextgroup.
"""
# Dictionary from name of test_scenario to (problem_name, correctness)
# Correctness is used to test whether the problem was graded properly
scenarios = {
"2_choices_correct": ("checkbox_two_choices", "correct"),
"2_choices_incorrect": ("checkbox_two_choices", "incorrect"),
"2_choices_2_inputs_correct": (
"checkbox_2_choices_2_inputs",
"correct"
),
"2_choices_2_inputs_missing_choice": (
"checkbox_2_choices_2_inputs",
"incorrect"
),
"2_choices_2_inputs_wrong_input": (
"checkbox_2_choices_2_inputs",
"incorrect"
)
}
# Dictionary scenario_name: test_inputs
inputs = {
"2_choices_correct": [(True, []), (True, [])],
"2_choices_incorrect": [(True, []), (False, [])],
"2_choices_2_inputs_correct": [(True, ["123"]), (True, ["456"])],
"2_choices_2_inputs_missing_choice": [
(True, ["123"]), (False, ["456"])
],
"2_choices_2_inputs_wrong_input": [
(True, ["123"]), (True, ["654"])
]
}
# Two choice zero input problem with both choices being correct.
checkbox_two_choices = self._make_problem(
[("true", {}), ("true", {})], "checkboxtextgroup"
)
# Two choice two input problem with both choices correct.
checkbox_two_choices_two_inputs = self._make_problem(
[("true", {"answer": "123", "tolerance": "0"}),
("true", {"answer": "456", "tolerance": "0"})
],
"checkboxtextgroup"
)
# Dictionary problem_name: problem
problems = {
"checkbox_two_choices": checkbox_two_choices,
"checkbox_2_choices_2_inputs": checkbox_two_choices_two_inputs
}
for name, inputs in inputs.iteritems():
submission = self._make_answer_dict(inputs)
# Load the test problem's name and desired correctness
problem_name, correctness = scenarios[name]
# Load the problem
problem = problems[problem_name]
# Make sure the actual grade matches the expected grade
self.assert_grade(
problem,
submission,
correctness,
msg="{0} should be {1}".format(name, correctness)
)

View File

@@ -80,8 +80,6 @@ class ABTestModule(ABTestFields, XModule):
class ABTestDescriptor(ABTestFields, RawDescriptor, XmlDescriptor):
module_class = ABTestModule
template_dir_name = "abtest"
@classmethod
def definition_from_xml(cls, xml_object, system):
"""

View File

@@ -6,12 +6,37 @@ from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xblock.core import Scope, String
import textwrap
log = logging.getLogger(__name__)
class AnnotatableFields(object):
data = String(help="XML data for the annotation", scope=Scope.content)
data = String(help="XML data for the annotation", scope=Scope.content,
default=textwrap.dedent(
"""\
<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>
"""))
display_name = String(
display_name="Display Name",
help="Display name for this module",
scope=Scope.settings,
default='Annotation',
)
class AnnotatableModule(AnnotatableFields, XModule):
@@ -125,5 +150,4 @@ class AnnotatableModule(AnnotatableFields, XModule):
class AnnotatableDescriptor(AnnotatableFields, RawDescriptor):
module_class = AnnotatableModule
template_dir_name = "annotatable"
mako_template = "widgets/raw-edit.html"

View File

@@ -77,6 +77,14 @@ class CapaFields(object):
"""
Define the possible fields for a Capa problem
"""
display_name = String(
display_name="Display Name",
help="This name appears in the horizontal navigation at the top of the page.",
scope=Scope.settings,
# it'd be nice to have a useful default but it screws up other things; so,
# use display_name_with_default for those
default="Blank Advanced Problem"
)
attempts = Integer(help="Number of attempts taken by the student on this problem",
default=0, scope=Scope.user_state)
max_attempts = Integer(
@@ -94,7 +102,8 @@ class CapaFields(object):
display_name="Show Answer",
help=("Defines when to show the answer to the problem. "
"A default value can be set in Advanced Settings."),
scope=Scope.settings, default="closed",
scope=Scope.settings,
default="finished",
values=[
{"display_name": "Always", "value": "always"},
{"display_name": "Answered", "value": "answered"},
@@ -106,21 +115,24 @@ class CapaFields(object):
)
force_save_button = Boolean(
help="Whether to force the save button to appear on the page",
scope=Scope.settings, default=False
scope=Scope.settings,
default=False
)
rerandomize = Randomization(
display_name="Randomization",
help="Defines how often inputs are randomized when a student loads the problem. "
"This setting only applies to problems that can have randomly generated numeric values. "
"A default value can be set in Advanced Settings.",
default="always", scope=Scope.settings, values=[
"This setting only applies to problems that can have randomly generated numeric values. "
"A default value can be set in Advanced Settings.",
default="never",
scope=Scope.settings,
values=[
{"display_name": "Always", "value": "always"},
{"display_name": "On Reset", "value": "onreset"},
{"display_name": "Never", "value": "never"},
{"display_name": "Per Student", "value": "per_student"}
]
)
data = String(help="XML data for the problem", scope=Scope.content)
data = String(help="XML data for the problem", scope=Scope.content, default="<problem></problem>")
correct_map = Dict(help="Dictionary with the correctness of current student answers",
scope=Scope.user_state, default={})
input_state = Dict(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state)
@@ -134,7 +146,7 @@ class CapaFields(object):
values={"min": 0, "step": .1},
scope=Scope.settings
)
markdown = String(help="Markdown source of this module", scope=Scope.settings)
markdown = String(help="Markdown source of this module", default=None, scope=Scope.settings)
source_code = String(
help="Source code for LaTeX and Word problems. This feature is not well-supported.",
scope=Scope.settings
@@ -541,6 +553,16 @@ class CapaModule(CapaFields, XModule):
'ungraded_response': self.handle_ungraded_response
}
generic_error_message = (
"We're sorry, there was an error with processing your request. "
"Please try reloading your page and trying again."
)
not_found_error_message = (
"The state of this problem has changed since you loaded this page. "
"Please refresh your page."
)
if dispatch not in handlers:
return 'Error'
@@ -548,9 +570,14 @@ class CapaModule(CapaFields, XModule):
try:
result = handlers[dispatch](data)
except NotFoundError as err:
_, _, traceback_obj = sys.exc_info()
raise ProcessingError, (not_found_error_message, err), traceback_obj
except Exception as err:
_, _, traceback_obj = sys.exc_info()
raise ProcessingError(err.message, traceback_obj)
raise ProcessingError, (generic_error_message, err), traceback_obj
after = self.get_progress()
@@ -749,6 +776,13 @@ class CapaModule(CapaFields, XModule):
then the output dict would contain {'1': ['test'] }
(the value is a list).
Some other inputs such as ChoiceTextInput expect a dict of values in the returned
dict If the key ends with '{}' then we will assume that the value is a json
encoded dict and deserialize it.
For example, if the `data` dict contains {'input_1{}': '{"1_2_1": 1}'}
then the output dict would contain {'1': {"1_2_1": 1} }
(the value is a dictionary)
Raises an exception if:
-A key in the `data` dictionary does not contain at least one underscore
@@ -775,11 +809,22 @@ class CapaModule(CapaFields, XModule):
# the same form input (e.g. checkbox inputs). The convention is that
# if the name ends with '[]' (which looks like an array), then the
# answer will be an array.
# if the name ends with '{}' (Which looks like a dict),
# then the answer will be a dict
is_list_key = name.endswith('[]')
name = name[:-2] if is_list_key else name
is_dict_key = name.endswith('{}')
name = name[:-2] if is_list_key or is_dict_key else name
if is_list_key:
val = data.getlist(key)
elif is_dict_key:
try:
val = json.loads(data[key])
# If the submission wasn't deserializable, raise an error.
except(KeyError, ValueError):
raise ValueError(
u"Invalid submission: {val} for {key}".format(val=data[key], key=key)
)
else:
val = data[key]
@@ -1101,6 +1146,19 @@ class CapaDescriptor(CapaFields, RawDescriptor):
path[8:],
]
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
"""
Augment regular translation w/ setting the pre-Studio defaults.
"""
problem = super(CapaDescriptor, cls).from_xml(xml_data, system, org, course)
# pylint: disable=W0212
if 'showanswer' not in problem._model_data:
problem.showanswer = "closed"
if 'rerandomize' not in problem._model_data:
problem.rerandomize = "always"
return problem
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(CapaDescriptor, self).non_editable_metadata_fields

View File

@@ -9,10 +9,11 @@ from xblock.core import Integer, Scope, String, List, Float, Boolean
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
from collections import namedtuple
from .fields import Date
import textwrap
log = logging.getLogger("mitx.courseware")
V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload",
V1_SETTINGS_ATTRIBUTES = ["display_name", "max_attempts", "graded", "accept_file_upload",
"skip_spelling_checks", "due", "graceperiod", "weight"]
V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state",
@@ -27,6 +28,126 @@ VERSION_TUPLES = {
}
DEFAULT_VERSION = 1
DEFAULT_DATA = textwrap.dedent("""\
<combinedopenended>
<prompt>
<h3>Censorship in the Libraries</h3>
<p>'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author
</p>
<p>
Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.
</p>
</prompt>
<rubric>
<rubric>
<category>
<description>
Ideas
</description>
<option>
Difficult for the reader to discern the main idea. Too brief or too repetitive to establish or maintain a focus.
</option>
<option>
Attempts a main idea. Sometimes loses focus or ineffectively displays focus.
</option>
<option>
Presents a unifying theme or main idea, but may include minor tangents. Stays somewhat focused on topic and task.
</option>
<option>
Presents a unifying theme or main idea without going off on tangents. Stays completely focused on topic and task.
</option>
</category>
<category>
<description>
Content
</description>
<option>
Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic.
</option>
<option>
Includes little information and few or no details. Explores only one or two facets of the topic.
</option>
<option>
Includes sufficient information and supporting details. (Details may not be fully developed; ideas may be listed.) Explores some facets of the topic.
</option>
<option>
Includes in-depth information and exceptional supporting details that are fully developed. Explores all facets of the topic.
</option>
</category>
<category>
<description>
Organization
</description>
<option>
Ideas organized illogically, transitions weak, and response difficult to follow.
</option>
<option>
Attempts to logically organize ideas. Attempts to progress in an order that enhances meaning, and demonstrates use of transitions.
</option>
<option>
Ideas organized logically. Progresses in an order that enhances meaning. Includes smooth transitions.
</option>
</category>
<category>
<description>
Style
</description>
<option>
Contains limited vocabulary, with many words used incorrectly. Demonstrates problems with sentence patterns.
</option>
<option>
Contains basic vocabulary, with words that are predictable and common. Contains mostly simple sentences (although there may be an attempt at more varied sentence patterns).
</option>
<option>
Includes vocabulary to make explanations detailed and precise. Includes varied sentence patterns, including complex sentences.
</option>
</category>
<category>
<description>
Voice
</description>
<option>
Demonstrates language and tone that may be inappropriate to task and reader.
</option>
<option>
Demonstrates an attempt to adjust language and tone to task and reader.
</option>
<option>
Demonstrates effective adjustment of language and tone to task and reader.
</option>
</category>
</rubric>
</rubric>
<task>
<selfassessment/></task>
<task>
<openended min_score_to_attempt="4" max_score_to_attempt="12" >
<openendedparam>
<initial_display>Enter essay here.</initial_display>
<answer_display>This is the answer.</answer_display>
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
</openendedparam>
</openended>
</task>
<task>
<openended min_score_to_attempt="9" max_score_to_attempt="12" >
<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" : "6.002x/Welcome/OETest"}</grader_payload>
</openendedparam>
</openended>
</task>
</combinedopenended>
""")
class VersionInteger(Integer):
@@ -51,47 +172,122 @@ class CombinedOpenEndedFields(object):
display_name = String(
display_name="Display Name",
help="This name appears in the horizontal navigation at the top of the page.",
default="Open Ended Grading", scope=Scope.settings
default="Open Response Assessment",
scope=Scope.settings
)
current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.user_state)
task_states = List(help="List of state dictionaries of each task within this module.", scope=Scope.user_state)
state = String(help="Which step within the current task that the student is on.", default="initial",
scope=Scope.user_state)
student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0,
scope=Scope.user_state)
ready_to_reset = Boolean(
help="If the problem is ready to be reset or not.", default=False,
current_task_number = Integer(
help="Current task that the student is on.",
default=0,
scope=Scope.user_state
)
attempts = Integer(
display_name="Maximum Attempts",
help="The number of times the student can try to answer this problem.", default=1,
scope=Scope.settings, values={"min" : 1 }
task_states = List(
help="List of state dictionaries of each task within this module.",
scope=Scope.user_state
)
state = String(
help="Which step within the current task that the student is on.",
default="initial",
scope=Scope.user_state
)
graded = Boolean(
display_name="Graded",
help='Defines whether the student gets credit for grading this problem.',
default=False,
scope=Scope.settings
)
student_attempts = Integer(
help="Number of attempts taken by the student on this problem",
default=0,
scope=Scope.user_state
)
ready_to_reset = Boolean(
help="If the problem is ready to be reset or not.",
default=False,
scope=Scope.user_state
)
max_attempts = Integer(
display_name="Maximum Attempts",
help="The number of times the student can try to answer this problem.",
default=1,
scope=Scope.settings,
values={"min" : 1 }
)
is_graded = Boolean(display_name="Graded", help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
accept_file_upload = Boolean(
display_name="Allow File Uploads",
help="Whether or not the student can submit files as a response.", default=False, scope=Scope.settings
help="Whether or not the student can submit files as a response.",
default=False,
scope=Scope.settings
)
skip_spelling_checks = Boolean(
display_name="Disable Quality Filter",
help="If False, the Quality Filter is enabled and submissions with poor spelling, short length, or poor grammar will not be peer reviewed.",
default=False, scope=Scope.settings
default=False,
scope=Scope.settings
)
due = Date(
help="Date that this problem is due by",
default=None,
scope=Scope.settings
)
due = Date(help="Date that this problem is due by", default=None, scope=Scope.settings)
graceperiod = String(
help="Amount of time after the due date that submissions will be accepted",
default=None,
scope=Scope.settings
)
version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings)
data = String(help="XML data for the problem", scope=Scope.content)
data = String(help="XML data for the problem", scope=Scope.content,
default=DEFAULT_DATA)
weight = Float(
display_name="Problem Weight",
help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.",
scope=Scope.settings, values={"min" : 0 , "step": ".1"}
scope=Scope.settings,
values={"min" : 0 , "step": ".1"},
default=1
)
markdown = String(
help="Markdown source of this module",
default=textwrap.dedent("""\
[prompt]
<h3>Censorship in the Libraries</h3>
<p>'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author
</p>
<p>
Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.
</p>
[prompt]
[rubric]
+ Ideas
- Difficult for the reader to discern the main idea. Too brief or too repetitive to establish or maintain a focus.
- Attempts a main idea. Sometimes loses focus or ineffectively displays focus.
- Presents a unifying theme or main idea, but may include minor tangents. Stays somewhat focused on topic and task.
- Presents a unifying theme or main idea without going off on tangents. Stays completely focused on topic and task.
+ Content
- Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic.
- Includes little information and few or no details. Explores only one or two facets of the topic.
- Includes sufficient information and supporting details. (Details may not be fully developed; ideas may be listed.) Explores some facets of the topic.
- Includes in-depth information and exceptional supporting details that are fully developed. Explores all facets of the topic.
+ Organization
- Ideas organized illogically, transitions weak, and response difficult to follow.
- Attempts to logically organize ideas. Attempts to progress in an order that enhances meaning, and demonstrates use of transitions.
- Ideas organized logically. Progresses in an order that enhances meaning. Includes smooth transitions.
+ Style
- Contains limited vocabulary, with many words used incorrectly. Demonstrates problems with sentence patterns.
- Contains basic vocabulary, with words that are predictable and common. Contains mostly simple sentences (although there may be an attempt at more varied sentence patterns).
- Includes vocabulary to make explanations detailed and precise. Includes varied sentence patterns, including complex sentences.
+ Voice
- Demonstrates language and tone that may be inappropriate to task and reader.
- Demonstrates an attempt to adjust language and tone to task and reader.
- Demonstrates effective adjustment of language and tone to task and reader.
[rubric]
[tasks]
(Self), ({4-12}AI), ({9-12}Peer)
[tasks]
"""),
scope=Scope.settings
)
markdown = String(help="Markdown source of this module", scope=Scope.settings)
class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
@@ -143,37 +339,9 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
def __init__(self, *args, **kwargs):
"""
Definition file should have one or many task blocks, a rubric block, and a prompt block:
Definition file should have one or many task blocks, a rubric block, and a prompt block.
Sample file:
<combinedopenended attempts="10000">
<rubric>
Blah blah rubric.
</rubric>
<prompt>
Some prompt.
</prompt>
<task>
<selfassessment>
<hintprompt>
What hint about this problem would you give to someone?
</hintprompt>
<submitmessage>
Save Succcesful. Thanks for participating!
</submitmessage>
</selfassessment>
</task>
<task>
<openended min_score_to_attempt="1" max_score_to_attempt="1">
<openendedparam>
<initial_display>Enter essay here.</initial_display>
<answer_display>This is the answer.</answer_display>
<grader_payload>{"grader_settings" : "ml_grading.conf",
"problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
</openendedparam>
</openended>
</task>
</combinedopenended>
See DEFAULT_DATA for a sample.
"""
XModule.__init__(self, *args, **kwargs)
@@ -254,6 +422,11 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
js_module_name = "OpenEndedMarkdownEditingDescriptor"
css = {'scss': [resource_string(__name__, 'css/editor/edit.scss'), resource_string(__name__, 'css/combinedopenended/edit.scss')]}
metadata_translations = {
'is_graded': 'graded',
'attempts': 'max_attempts',
}
def get_context(self):
_context = RawDescriptor.get_context(self)
_context.update({'markdown': self.markdown,

View File

@@ -145,16 +145,55 @@ class TextbookList(List):
class CourseFields(object):
textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", scope=Scope.content)
textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course",
default=[], scope=Scope.content)
wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content)
enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings)
enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings)
start = Date(help="Start time when this module is visible", scope=Scope.settings)
start = Date(help="Start time when this module is visible",
# using now(UTC()) resulted in fractional seconds which screwed up comparisons and anyway w/b the
# time of first invocation of this stmt on the server
default=datetime.fromtimestamp(0, UTC()),
scope=Scope.settings)
end = Date(help="Date that this class ends", scope=Scope.settings)
advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings)
grading_policy = Dict(help="Grading policy definition for this class", scope=Scope.content)
grading_policy = Dict(help="Grading policy definition for this class",
default={"GRADER": [
{
"type": "Homework",
"min_count": 12,
"drop_count": 2,
"short_label": "HW",
"weight": 0.15
},
{
"type": "Lab",
"min_count": 12,
"drop_count": 2,
"weight": 0.15
},
{
"type": "Midterm Exam",
"short_label": "Midterm",
"min_count": 1,
"drop_count": 0,
"weight": 0.3
},
{
"type": "Final Exam",
"short_label": "Final",
"min_count": 1,
"drop_count": 0,
"weight": 0.4
}
],
"GRADE_CUTOFFS": {
"Pass": 0.5
}},
scope=Scope.content)
show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings)
display_name = String(help="Display name for this module", scope=Scope.settings)
display_name = String(help="Display name for this module", default="Empty", display_name="Display Name", scope=Scope.settings)
show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings)
tabs = List(help="List of tabs to enable in this course", scope=Scope.settings)
end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings)
discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings)
@@ -175,7 +214,125 @@ class CourseFields(object):
allow_anonymous_to_peers = Boolean(scope=Scope.settings, default=False)
advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings)
has_children = True
checklists = List(scope=Scope.settings)
checklists = List(scope=Scope.settings,
default=[
{"short_description" : "Getting Started With Studio",
"items" : [{"short_description": "Add Course Team Members",
"long_description": "Grant your collaborators permission to edit your course so you can work together.",
"is_checked": False,
"action_url": "ManageUsers",
"action_text": "Edit Course Team",
"action_external": False},
{"short_description": "Set Important Dates for Your Course",
"long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.",
"is_checked": False,
"action_url": "SettingsDetails",
"action_text": "Edit Course Details &amp; Schedule",
"action_external": False},
{"short_description": "Draft Your Course's Grading Policy",
"long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.",
"is_checked": False,
"action_url": "SettingsGrading",
"action_text": "Edit Grading Settings",
"action_external": False},
{"short_description": "Explore the Other Studio Checklists",
"long_description": "Discover other available course authoring tools, and find help when you need it.",
"is_checked": False,
"action_url": "",
"action_text": "",
"action_external": False}]
},
{"short_description" : "Draft a Rough Course Outline",
"items" : [{"short_description": "Create Your First Section and Subsection",
"long_description": "Use your course outline to build your first Section and Subsection.",
"is_checked": False,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": False},
{"short_description": "Set Section Release Dates",
"long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.",
"is_checked": False,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": False},
{"short_description": "Designate a Subsection as Graded",
"long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.",
"is_checked": False,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": False},
{"short_description": "Reordering Course Content",
"long_description": "Use drag and drop to reorder the content in your course.",
"is_checked": False,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": False},
{"short_description": "Renaming Sections",
"long_description": "Rename Sections by clicking the Section name from the Course Outline.",
"is_checked": False,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": False},
{"short_description": "Deleting Course Content",
"long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.",
"is_checked": False,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": False},
{"short_description": "Add an Instructor-Only Section to Your Outline",
"long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.",
"is_checked": False,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": False}]
},
{"short_description" : "Explore edX's Support Tools",
"items" : [{"short_description": "Explore the Studio Help Forum",
"long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.",
"is_checked": False,
"action_url": "http://help.edge.edx.org/",
"action_text": "Visit Studio Help",
"action_external": True},
{"short_description": "Enroll in edX 101",
"long_description": "Register for edX 101, edX's primer for course creation.",
"is_checked": False,
"action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about",
"action_text": "Register for edX 101",
"action_external": True},
{"short_description": "Download the Studio Documentation",
"long_description": "Download the searchable Studio reference documentation in PDF form.",
"is_checked": False,
"action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf",
"action_text": "Download Documentation",
"action_external": True}]
},
{"short_description" : "Draft Your Course About Page",
"items" : [{"short_description": "Draft a Course Description",
"long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.",
"is_checked": False,
"action_url": "SettingsDetails",
"action_text": "Edit Course Schedule &amp; Details",
"action_external": False},
{"short_description": "Add Staff Bios",
"long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.",
"is_checked": False,
"action_url": "SettingsDetails",
"action_text": "Edit Course Schedule &amp; Details",
"action_external": False},
{"short_description": "Add Course FAQs",
"long_description": "Include a short list of frequently asked questions about your course.",
"is_checked": False,
"action_url": "SettingsDetails",
"action_text": "Edit Course Schedule &amp; Details",
"action_external": False},
{"short_description": "Add Course Prerequisites",
"long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.",
"is_checked": False,
"action_url": "SettingsDetails",
"action_text": "Edit Course Schedule &amp; Details",
"action_external": False}]
}
])
info_sidebar_name = String(scope=Scope.settings, default='Course Handouts')
show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True)
enrollment_domain = String(help="External login method associated with user accounts allowed to register in course",
@@ -208,8 +365,6 @@ class CourseFields(object):
class CourseDescriptor(CourseFields, SequenceDescriptor):
module_class = SequenceModule
template_dir_name = 'course'
def __init__(self, *args, **kwargs):
"""
Expects the same arguments as XModuleDescriptor.__init__
@@ -220,18 +375,16 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
self.wiki_slug = self.location.course
msg = None
if self.start is None:
msg = "Course loaded without a valid start date. id = %s" % self.id
self.start = datetime.now(UTC())
log.critical(msg)
self.system.error_tracker(msg)
# NOTE: relies on the modulestore to call set_grading_policy() right after
# init. (Modulestore is in charge of figuring out where to load the policy from)
# 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'))
if self.system.resources_fs is None:
self.syllabus_present = False
else:
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
self._grading_policy = {}
self.set_grading_policy(self.grading_policy)
@@ -252,42 +405,33 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
log.error(msg)
continue
def default_grading_policy(self):
"""
Return a dict which is a copy of the default grading policy
"""
return {"GRADER": [
{
"type": "Homework",
"min_count": 12,
"drop_count": 2,
"short_label": "HW",
"weight": 0.15
},
{
"type": "Lab",
"min_count": 12,
"drop_count": 2,
"weight": 0.15
},
{
"type": "Midterm Exam",
"short_label": "Midterm",
"min_count": 1,
"drop_count": 0,
"weight": 0.3
},
{
"type": "Final Exam",
"short_label": "Final",
"min_count": 1,
"drop_count": 0,
"weight": 0.4
}
],
"GRADE_CUTOFFS": {
"Pass": 0.5
}}
# TODO check that this is still needed here and can't be by defaults.
if self.tabs is None:
# When calling the various _tab methods, can omit the 'type':'blah' from the
# first arg, since that's only used for dispatch
tabs = []
tabs.append({'type': 'courseware'})
tabs.append({'type': 'course_info', 'name': 'Course Info'})
if self.syllabus_present:
tabs.append({'type': 'syllabus'})
tabs.append({'type': 'textbooks'})
# # If they have a discussion link specified, use that even if we feature
# # flag discussions off. Disabling that is mostly a server safety feature
# # at this point, and we don't need to worry about external sites.
if self.discussion_link:
tabs.append({'type': 'external_discussion', 'link': self.discussion_link})
else:
tabs.append({'type': 'discussion', 'name': 'Discussion'})
tabs.append({'type': 'wiki', 'name': 'Wiki'})
if not self.hide_progress_tab:
tabs.append({'type': 'progress', 'name': 'Progress'})
self.tabs = tabs
def set_grading_policy(self, course_policy):
"""
@@ -298,7 +442,13 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
course_policy = {}
# Load the global settings as a dictionary
grading_policy = self.default_grading_policy()
grading_policy = self.grading_policy
# BOY DO I HATE THIS grading_policy CODE ACROBATICS YET HERE I ADD MORE (dhm)--this fixes things persisted w/
# defective grading policy values (but not None)
if 'GRADER' not in grading_policy:
grading_policy['GRADER'] = CourseFields.grading_policy.default['GRADER']
if 'GRADE_CUTOFFS' not in grading_policy:
grading_policy['GRADE_CUTOFFS'] = CourseFields.grading_policy.default['GRADE_CUTOFFS']
# Override any global settings with the course settings
grading_policy.update(course_policy)
@@ -354,10 +504,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
system.error_tracker("Unable to decode grading policy as json")
policy = {}
# cdodge: import the grading policy information that is on disk and put into the
# descriptor 'definition' bucket as a dictionary so that it is persisted in the DB
instance.grading_policy = policy
# now set the current instance. set_grading_policy() will apply some inheritance rules
instance.set_grading_policy(policy)
@@ -661,6 +807,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
if isinstance(self.advertised_start, basestring):
return try_parse_iso_8601(self.advertised_start)
elif self.advertised_start is None and self.start is None:
# TODO this is an impossible state since the init function forces start to have a value
return 'TBD'
else:
return (self.advertised_start or self.start).strftime("%b %d, %Y")

View File

@@ -929,4 +929,32 @@ section.problem {
}
}
}
.choicetextgroup{
input[type="text"]{
margin-bottom: 0.5em;
}
@extend .choicegroup;
label.choicetextgroup_correct, section.choicetextgroup_correct{
@extend label.choicegroup_correct;
input[type="text"] {
border-color: green;
}
}
label.choicetextgroup_incorrect, section.choicetextgroup_incorrect{
@extend label.choicegroup_incorrect;
}
label.choicetextgroup_show_correct, section.choicetextgroup_show_correct{
&:after{
content: url('../images/correct-icon.png');
margin-left:15px;
}
}
span.mock_label{
cursor : default;
}
}
}

View File

@@ -4,17 +4,27 @@ from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.editing_module import MetadataOnlyEditingDescriptor
from xblock.core import String, Scope
from uuid import uuid4
class DiscussionFields(object):
discussion_id = String(scope=Scope.settings)
discussion_id = String(scope=Scope.settings, default="$$GUID$$")
display_name = String(
display_name="Display Name",
help="Display name for this module",
default="Discussion Tag",
scope=Scope.settings)
data = String(help="XML data for the problem", scope=Scope.content,
default="<discussion></discussion>")
discussion_category = String(
display_name="Category",
default="Week 1",
help="A category name for the discussion. This name appears in the left pane of the discussion forum for the course.",
scope=Scope.settings
)
discussion_target = String(
display_name="Subcategory",
default="Topic-Level Student-Visible Label",
help="A subcategory name for the discussion. This name appears in the left pane of the discussion forum for the course.",
scope=Scope.settings
)
@@ -36,9 +46,15 @@ class DiscussionModule(DiscussionFields, XModule):
class DiscussionDescriptor(DiscussionFields, MetadataOnlyEditingDescriptor, RawDescriptor):
module_class = DiscussionModule
template_dir_name = "discussion"
def __init__(self, *args, **kwargs):
super(DiscussionDescriptor, self).__init__(*args, **kwargs)
# is this too late? i.e., will it get persisted and stay static w/ the first value
# any code references. I believe so.
if self.discussion_id == '$$GUID$$':
self.discussion_id = uuid4().hex
module_class = DiscussionModule
# The discussion XML format uses `id` and `for` attributes,
# but these would overload other module attributes, so we prefix them
# for actual use in the code

View File

@@ -96,6 +96,7 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
'contents': contents,
'display_name': 'Error: ' + location.name,
'location': location,
'category': 'error'
}
return cls(
system,
@@ -109,12 +110,12 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
}
@classmethod
def from_json(cls, json_data, system, error_msg='Error not available'):
def from_json(cls, json_data, system, location, error_msg='Error not available'):
return cls._construct(
system,
json.dumps(json_data, indent=4),
json.dumps(json_data, skipkeys=False, indent=4),
error_msg,
location=Location(json_data['location']),
location=location
)
@classmethod

View File

@@ -58,8 +58,7 @@ class Date(ModelType):
else:
msg = "Field {0} has bad value '{1}'".format(
self._name, field)
log.warning(msg)
return None
raise TypeError(msg)
def to_json(self, value):
"""
@@ -76,6 +75,8 @@ class Date(ModelType):
return value.strftime('%Y-%m-%dT%H:%M:%SZ')
else:
return value.isoformat()
else:
raise TypeError("Cannot convert {} to json".format(value))
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')

View File

@@ -91,15 +91,18 @@ class FolditModule(FolditFields, XModule):
PuzzleComplete.completed_puzzles(self.system.anonymous_student_id),
key=lambda d: (d['set'], d['subset']))
def puzzle_leaders(self, n=10):
def puzzle_leaders(self, n=10, courses=None):
"""
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
leaders = [(e['username'], e['score']) for e in Score.get_tops_n(10)]
leaders.sort(key=lambda x:-x[1])
if courses is None:
courses = [self.location.course_id]
leaders = [(leader['username'], leader['score']) for leader in Score.get_tops_n(10, course_list=courses)]
leaders.sort(key=lambda x: -x[1])
return leaders
@@ -184,7 +187,6 @@ class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor):
filename_extension = "xml"
has_score = True
template_dir_name = "foldit"
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
js_module_name = "HTMLEditingDescriptor"

View File

@@ -141,7 +141,6 @@ class GraphicalSliderToolModule(GraphicalSliderToolFields, XModule):
class GraphicalSliderToolDescriptor(GraphicalSliderToolFields, MakoModuleDescriptor, XmlDescriptor):
module_class = GraphicalSliderToolModule
template_dir_name = 'graphical_slider_tool'
@classmethod
def definition_from_xml(cls, xml_object, system):

View File

@@ -13,12 +13,21 @@ from xmodule.html_checker import check_html
from xmodule.stringify import stringify_children
from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor, name_to_pathname
import textwrap
log = logging.getLogger("mitx.courseware")
class HtmlFields(object):
data = String(help="Html contents to display for this module", scope=Scope.content)
display_name = String(
display_name="Display Name",
help="This name appears in the horizontal navigation at the top of the page.",
scope=Scope.settings,
# it'd be nice to have a useful default but it screws up other things; so,
# use display_name_with_default for those
default="Blank HTML Page"
)
data = String(help="Html contents to display for this module", default="", scope=Scope.content)
source_code = String(help="Source code for LaTeX documents. This feature is not well-supported.", scope=Scope.settings)
@@ -32,7 +41,7 @@ class HtmlModule(HtmlFields, XModule):
css = {'scss': [resource_string(__name__, 'css/html/display.scss')]}
def get_html(self):
if self.system.anonymous_student_id:
if self.system.anonymous_student_id:
return self.data.replace("%%USER_ID%%", self.system.anonymous_student_id)
return self.data
@@ -158,9 +167,9 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
pathname=pathname)
resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True)
with resource_fs.open(filepath, 'w') as file:
with resource_fs.open(filepath, 'w') as filestream:
html_data = self.data.encode('utf-8')
file.write(html_data)
filestream.write(html_data)
# write out the relative name
relname = path(pathname).basename()
@@ -169,26 +178,88 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
elt.set("filename", relname)
return elt
class AboutFields(object):
display_name = String(
help="Display name for this module",
scope=Scope.settings,
default="overview",
)
data = String(
help="Html contents to display for this module",
default="",
scope=Scope.content
)
class AboutDescriptor(HtmlDescriptor):
class AboutModule(AboutFields, HtmlModule):
"""
Overriding defaults but otherwise treated as HtmlModule.
"""
pass
class AboutDescriptor(AboutFields, HtmlDescriptor):
"""
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
in order to be able to create new ones
"""
template_dir_name = "about"
module_class = AboutModule
class StaticTabFields(object):
"""
The overrides for Static Tabs
"""
display_name = String(
display_name="Display Name",
help="This name appears in the horizontal navigation at the top of the page.",
scope=Scope.settings,
default="Empty",
)
data = String(
default=textwrap.dedent("""\
<p>This is where you can add additional pages to your courseware. Click the 'edit' button to begin editing.</p>
"""),
scope=Scope.content,
help="HTML for the additional pages"
)
class StaticTabDescriptor(HtmlDescriptor):
class StaticTabModule(StaticTabFields, HtmlModule):
"""
Supports the field overrides
"""
pass
class StaticTabDescriptor(StaticTabFields, HtmlDescriptor):
"""
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
in order to be able to create new ones
"""
template_dir_name = "statictab"
template_dir_name = None
module_class = StaticTabModule
class CourseInfoDescriptor(HtmlDescriptor):
class CourseInfoFields(object):
"""
Field overrides
"""
data = String(
help="Html contents to display for this module",
default="<ol></ol>",
scope=Scope.content
)
class CourseInfoModule(CourseInfoFields, HtmlModule):
"""
Just to support xblock field overrides
"""
pass
class CourseInfoDescriptor(CourseInfoFields, HtmlDescriptor):
"""
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
in order to be able to create new ones
"""
template_dir_name = "courseinfo"
template_dir_name = None
module_class = CourseInfoModule

View File

@@ -223,6 +223,58 @@ describe 'Problem', ->
expect($('label[for="input_1_1_3"]')).toHaveAttr 'correct_answer', 'true'
expect($('label[for="input_1_2_1"]')).not.toHaveAttr 'correct_answer', 'true'
describe 'radio text question', ->
radio_text_xml='''
<section class="problem">
<div><p></p><span><section id="choicetextinput_1_2_1" class="choicetextinput">
<form class="choicetextgroup capa_inputtype" id="inputtype_1_2_1">
<div class="indicator_container">
<span class="unanswered" style="display:inline-block;" id="status_1_2_1"></span>
</div>
<fieldset>
<section id="forinput1_2_1_choiceinput_0bc">
<input class="ctinput" type="radio" name="choiceinput_1_2_1" id="1_2_1_choiceinput_0bc" value="choiceinput_0"">
<input class="ctinput" type="text" name="choiceinput_0_textinput_0" id="1_2_1_choiceinput_0_textinput_0" value=" ">
<p id="answer_1_2_1_choiceinput_0bc" class="answer"></p>
</>
<section id="forinput1_2_1_choiceinput_1bc">
<input class="ctinput" type="radio" name="choiceinput_1_2_1" id="1_2_1_choiceinput_1bc" value="choiceinput_1" >
<input class="ctinput" type="text" name="choiceinput_1_textinput_0" id="1_2_1_choiceinput_1_textinput_0" value=" " >
<p id="answer_1_2_1_choiceinput_1bc" class="answer"></p>
</section>
<section id="forinput1_2_1_choiceinput_2bc">
<input class="ctinput" type="radio" name="choiceinput_1_2_1" id="1_2_1_choiceinput_2bc" value="choiceinput_2" >
<input class="ctinput" type="text" name="choiceinput_2_textinput_0" id="1_2_1_choiceinput_2_textinput_0" value=" " >
<p id="answer_1_2_1_choiceinput_2bc" class="answer"></p>
</section></fieldset><input class="choicetextvalue" type="hidden" name="input_1_2_1" id="input_1_2_1"></form>
</section></span></div>
</section>
'''
beforeEach ->
# Append a radiotextresponse problem to the problem, so we can check it's javascript functionality
@problem.el.prepend(radio_text_xml)
it 'sets the correct class on the section for the correct choice', ->
spyOn($, 'postWithPrefix').andCallFake (url, callback) ->
callback answers: "1_2_1": ["1_2_1_choiceinput_0bc"], "1_2_1_choiceinput_0bc": "3"
@problem.show()
expect($('#forinput1_2_1_choiceinput_0bc').attr('class')).toEqual(
'choicetextgroup_show_correct')
expect($('#answer_1_2_1_choiceinput_0bc').text()).toEqual('3')
expect($('#answer_1_2_1_choiceinput_1bc').text()).toEqual('')
expect($('#answer_1_2_1_choiceinput_2bc').text()).toEqual('')
it 'Should not disable input fields', ->
spyOn($, 'postWithPrefix').andCallFake (url, callback) ->
callback answers: "1_2_1": ["1_2_1_choiceinput_0bc"], "1_2_1_choiceinput_0bc": "3"
@problem.show()
expect($('input#1_2_1_choiceinput_0bc').attr('disabled')).not.toEqual('disabled')
expect($('input#1_2_1_choiceinput_1bc').attr('disabled')).not.toEqual('disabled')
expect($('input#1_2_1_choiceinput_2bc').attr('disabled')).not.toEqual('disabled')
expect($('input#1_2_1').attr('disabled')).not.toEqual('disabled')
describe 'when the answers are already shown', ->
beforeEach ->
@problem.el.addClass 'showed'

View File

@@ -11,13 +11,13 @@ describe 'OpenEndedMarkdownEditingDescriptor', ->
@descriptor = new OpenEndedMarkdownEditingDescriptor($('.combinedopenended-editor'))
@descriptor.createXMLEditor('replace with markdown')
saveResult = @descriptor.save()
expect(saveResult.metadata.markdown).toEqual(null)
expect(saveResult.nullout).toEqual(['markdown'])
expect(saveResult.data).toEqual('replace with markdown')
it 'saves xml from the xml editor', ->
loadFixtures 'combinedopenended-without-markdown.html'
@descriptor = new OpenEndedMarkdownEditingDescriptor($('.combinedopenended-editor'))
saveResult = @descriptor.save()
expect(saveResult.metadata.markdown).toEqual(null)
expect(saveResult.nullout).toEqual(['markdown'])
expect(saveResult.data).toEqual('xml only')
describe 'insertPrompt', ->

View File

@@ -11,13 +11,13 @@ describe 'MarkdownEditingDescriptor', ->
@descriptor = new MarkdownEditingDescriptor($('.problem-editor'))
@descriptor.createXMLEditor('replace with markdown')
saveResult = @descriptor.save()
expect(saveResult.metadata.markdown).toEqual(null)
expect(saveResult.nullout).toEqual(['markdown'])
expect(saveResult.data).toEqual('replace with markdown')
it 'saves xml from the xml editor', ->
loadFixtures 'problem-without-markdown.html'
@descriptor = new MarkdownEditingDescriptor($('.problem-editor'))
saveResult = @descriptor.save()
expect(saveResult.metadata.markdown).toEqual(null)
expect(saveResult.nullout).toEqual(['markdown'])
expect(saveResult.data).toEqual('xml only')
describe 'insertMultipleChoice', ->

View File

@@ -22,7 +22,6 @@ class @Problem
@$('section.action input:button').click @refreshAnswers
@$('section.action input.check').click @check_fd
#@$('section.action input.check').click @check
@$('section.action input.reset').click @reset
@$('section.action button.show').click @show
@$('section.action input.save').click @save
@@ -162,9 +161,6 @@ class @Problem
# maybe preferable to consolidate all dispatches to use FormData
###
check_fd: =>
# Calling check from check_fd will result in firing the 'problem_check' event twice, since it is also called in the check function.
#Logger.log 'problem_check', @answers
# If there are no file inputs in the problem, we can fall back on @check
if $('input:file').length == 0
@check()
@@ -239,6 +235,12 @@ class @Problem
check: =>
@check_waitfor()
Logger.log 'problem_check', @answers
# Segment.io
analytics.track "Problem Checked",
problem_id: @id
answers: @answers
$.postWithPrefix "#{@url}/problem_check", @answers, (response) =>
switch response.success
when 'incorrect', 'correct'
@@ -401,6 +403,14 @@ class @Problem
answer = JSON.parse(answers[answer_id])
display.showAnswer(answer)
choicetextgroup: (element, display, answers) =>
element = $(element)
input_id = element.attr('id').replace(/inputtype_/,'')
answer = answers[input_id]
for choice in answer
element.find("section#forinput#{choice}").addClass 'choicetextgroup_show_correct'
inputtypeHideAnswerMethods:
choicegroup: (element, display) =>
element = $(element)
@@ -408,3 +418,7 @@ class @Problem
javascriptinput: (element, display) =>
display.hideAnswer()
choicetextgroup: (element, display) =>
element = $(element)
element.find("section[id^='forinput']").removeClass('choicetextgroup_show_correct')

View File

@@ -50,6 +50,10 @@ Write a persuasive essay to a newspaper reflecting your vies on censorship in li
mode: null
})
@setCurrentEditor(@markdown_editor)
selection = @markdown_editor.getSelection()
#Auto-add in the needed template if it isn't already in there.
if(@markdown_editor.getValue() == "")
@markdown_editor.setValue(OpenEndedMarkdownEditingDescriptor.promptTemplate + "\n" + OpenEndedMarkdownEditingDescriptor.rubricTemplate + "\n" + OpenEndedMarkdownEditingDescriptor.tasksTemplate)
# Add listeners for toolbar buttons (only present for markdown editor)
@element.on('click', '.xml-tab', @onShowXMLButton)
@element.on('click', '.format-buttons a', @onToolbarButton)
@@ -153,8 +157,7 @@ Write a persuasive essay to a newspaper reflecting your vies on censorship in li
else
{
data: @xml_editor.getValue()
metadata:
markdown: null
nullout: ['markdown']
}
@insertRubric: (selectedText) ->

View File

@@ -123,9 +123,8 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
}
else
{
data: @xml_editor.getValue()
metadata:
markdown: null
data: @xml_editor.getValue()
nullout: ['markdown']
}
@insertMultipleChoice: (selectedText) ->

View File

@@ -310,14 +310,7 @@ class ModuleStore(object):
"""
raise NotImplementedError
def clone_item(self, source, location):
"""
Clone a new item that is a copy of the item at the location `source`
and writes it to `location`
"""
raise NotImplementedError
def update_item(self, location, data):
def update_item(self, location, data, allow_not_found=False):
"""
Set the data in the item specified by the location to
data

View File

@@ -33,7 +33,7 @@ from xblock.runtime import DbModel, KeyValueStore, InvalidScopeError
from xblock.core import Scope
from xmodule.modulestore import ModuleStoreBase, Location, namedtuple_to_son
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata
log = logging.getLogger(__name__)
@@ -62,11 +62,12 @@ class MongoKeyValueStore(KeyValueStore):
A KeyValueStore that maps keyed data access to one of the 3 data areas
known to the MongoModuleStore (data, children, and metadata)
"""
def __init__(self, data, children, metadata, location):
def __init__(self, data, children, metadata, location, category):
self._data = data
self._children = children
self._metadata = metadata
self._location = location
self._category = category
def get(self, key):
if key.scope == Scope.children:
@@ -78,6 +79,8 @@ class MongoKeyValueStore(KeyValueStore):
elif key.scope == Scope.content:
if key.field_name == 'location':
return self._location
elif key.field_name == 'category':
return self._category
elif key.field_name == 'data' and not isinstance(self._data, dict):
return self._data
else:
@@ -93,6 +96,8 @@ class MongoKeyValueStore(KeyValueStore):
elif key.scope == Scope.content:
if key.field_name == 'location':
self._location = value
elif key.field_name == 'category':
self._category = value
elif key.field_name == 'data' and not isinstance(self._data, dict):
self._data = value
else:
@@ -100,6 +105,15 @@ class MongoKeyValueStore(KeyValueStore):
else:
raise InvalidScopeError(key.scope)
def set_many(self, update_dict):
"""set_many method. Implementations should accept an `update_dict` of
key-value pairs, and set all the `keys` to the given `value`s."""
# `set` simply updates an in-memory db, rather than calling down to a real db,
# as mongo bulk save is handled elsewhere. A future improvement would be to pull
# the mongo-specific bulk save logic into this method.
for key, value in update_dict.iteritems():
self.set(key, value)
def delete(self, key):
if key.scope == Scope.children:
self._children = []
@@ -109,6 +123,8 @@ class MongoKeyValueStore(KeyValueStore):
elif key.scope == Scope.content:
if key.field_name == 'location':
self._location = Location(None)
elif key.field_name == 'category':
self._category = None
elif key.field_name == 'data' and not isinstance(self._data, dict):
self._data = None
else:
@@ -123,7 +139,10 @@ class MongoKeyValueStore(KeyValueStore):
return key.field_name in self._metadata
elif key.scope == Scope.content:
if key.field_name == 'location':
# WHY TRUE? if it's been deleted should it be False?
return True
elif key.field_name == 'category':
return self._category is not None
elif key.field_name == 'data' and not isinstance(self._data, dict):
return True
else:
@@ -185,8 +204,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
else:
# load the module and apply the inherited metadata
try:
category = json_data['location']['category']
class_ = XModuleDescriptor.load_class(
json_data['location']['category'],
category,
self.default_class
)
definition = json_data.get('definition', {})
@@ -201,9 +221,12 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
definition.get('children', []),
metadata,
location,
category
)
model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location))
model_data['category'] = category
model_data['location'] = location
module = class_(self, model_data)
if self.cached_metadata is not None:
# parent container pointers don't differentiate between draft and non-draft
@@ -217,6 +240,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
return ErrorDescriptor.from_json(
json_data,
self,
json_data['location'],
error_msg=exc_info_to_str(sys.exc_info())
)
@@ -582,51 +606,97 @@ class MongoModuleStore(ModuleStoreBase):
modules = self._load_items(list(items), depth)
return modules
def clone_item(self, source, location):
def create_xmodule(self, location, definition_data=None, metadata=None, system=None):
"""
Clone a new item that is a copy of the item at the location `source`
and writes it to `location`
Create the new xmodule but don't save it. Returns the new module.
:param location: a Location--must have a category
:param definition_data: can be empty. The initial definition_data for the kvs
:param metadata: can be empty, the initial metadata for the kvs
:param system: if you already have an xmodule from the course, the xmodule.system value
"""
item = None
try:
source_item = self.collection.find_one(location_to_query(source))
# allow for some programmatically generated substitutions in metadata, e.g. Discussion_id's should be auto-generated
for key in source_item['metadata'].keys():
if source_item['metadata'][key] == '$$GUID$$':
source_item['metadata'][key] = uuid4().hex
source_item['_id'] = Location(location).dict()
self.collection.insert(
source_item,
# Must include this to avoid the django debug toolbar (which defines the deprecated "safe=False")
# from overriding our default value set in the init method.
safe=self.collection.safe
if not isinstance(location, Location):
location = Location(location)
# differs from split mongo in that I believe most of this logic should be above the persistence
# layer but added it here to enable quick conversion. I'll need to reconcile these.
if metadata is None:
metadata = {}
if system is None:
system = CachingDescriptorSystem(
self,
{},
self.default_class,
None,
self.error_tracker,
self.render_template,
{}
)
item = self._load_items([source_item])[0]
xblock_class = XModuleDescriptor.load_class(location.category, self.default_class)
if definition_data is None:
if hasattr(xblock_class, 'data') and getattr(xblock_class, 'data').default is not None:
definition_data = getattr(xblock_class, 'data').default
else:
definition_data = {}
dbmodel = self._create_new_model_data(location.category, location, definition_data, metadata)
xmodule = xblock_class(system, dbmodel)
return xmodule
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
# if we add one then we need to also add it to the policy information (i.e. metadata)
# we should remove this once we can break this reference from the course to static tabs
if location.category == 'static_tab':
course = self.get_course_for_item(item.location)
existing_tabs = course.tabs or []
existing_tabs.append({
'type': 'static_tab',
'name': item.display_name,
'url_slug': item.location.name
})
course.tabs = existing_tabs
self.update_metadata(course.location, course._model_data._kvs._metadata)
except pymongo.errors.DuplicateKeyError:
raise DuplicateItemError(location)
def save_xmodule(self, xmodule):
"""
Save the given xmodule (will either create or update based on whether id already exists).
Pulls out the data definition v metadata v children locally but saves it all.
:param xmodule:
"""
# Save any changes to the xmodule to the MongoKeyValueStore
xmodule.save()
# split mongo's persist_dag is more general and useful.
self.collection.save({
'_id': xmodule.location.dict(),
'metadata': own_metadata(xmodule),
'definition': {
'data': xmodule.xblock_kvs._data,
'children': xmodule.children if xmodule.has_children else []
}
})
# recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(Location(location))
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
self.refresh_cached_metadata_inheritance_tree(xmodule.location)
self.fire_updated_modulestore_signal(get_course_id_no_run(xmodule.location), xmodule.location)
return item
def create_and_save_xmodule(self, location, definition_data=None, metadata=None, system=None):
"""
Create the new xmodule and save it. Does not return the new module because if the caller
will insert it as a child, it's inherited metadata will completely change. The difference
between this and just doing create_xmodule and save_xmodule is this ensures static_tabs get
pointed to by the course.
:param location: a Location--must have a category
:param definition_data: can be empty. The initial definition_data for the kvs
:param metadata: can be empty, the initial metadata for the kvs
:param system: if you already have an xmodule from the course, the xmodule.system value
"""
# differs from split mongo in that I believe most of this logic should be above the persistence
# layer but added it here to enable quick conversion. I'll need to reconcile these.
new_object = self.create_xmodule(location, definition_data, metadata, system)
location = new_object.location
self.save_xmodule(new_object)
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
# if we add one then we need to also add it to the policy information (i.e. metadata)
# we should remove this once we can break this reference from the course to static tabs
# TODO move this special casing to app tier (similar to attaching new element to parent)
if location.category == 'static_tab':
course = self.get_course_for_item(location)
existing_tabs = course.tabs or []
existing_tabs.append({
'type': 'static_tab',
'name': new_object.display_name,
'url_slug': new_object.location.name
})
course.tabs = existing_tabs
# Save any changes to the course to the MongoKeyValueStore
course.save()
self.update_metadata(course.location, course.xblock_kvs._metadata)
def fire_updated_modulestore_signal(self, course_id, location):
"""
@@ -683,7 +753,7 @@ class MongoModuleStore(ModuleStoreBase):
if result['n'] == 0:
raise ItemNotFoundError(location)
def update_item(self, location, data):
def update_item(self, location, data, allow_not_found=False):
"""
Set the data in the item specified by the location to
data
@@ -691,8 +761,11 @@ class MongoModuleStore(ModuleStoreBase):
location: Something that can be passed to Location
data: A nested dictionary of problem data
"""
self._update_single_item(location, {'definition.data': data})
try:
self._update_single_item(location, {'definition.data': data})
except ItemNotFoundError:
if not allow_not_found:
raise
def update_children(self, location, children):
"""
@@ -729,6 +802,8 @@ class MongoModuleStore(ModuleStoreBase):
tab['name'] = metadata.get('display_name')
break
course.tabs = existing_tabs
# Save the updates to the course to the MongoKeyValueStore
course.save()
self.update_metadata(course.location, own_metadata(course))
self._update_single_item(location, {'metadata': metadata})
@@ -751,6 +826,8 @@ class MongoModuleStore(ModuleStoreBase):
course = self.get_course_for_item(item.location)
existing_tabs = course.tabs or []
course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name]
# Save the updates to the course to the MongoKeyValueStore
course.save()
self.update_metadata(course.location, own_metadata(course))
# Must include this to avoid the django debug toolbar (which defines the deprecated "safe=False")
@@ -775,3 +852,24 @@ class MongoModuleStore(ModuleStoreBase):
are loaded on demand, rather than up front
"""
return {}
def _create_new_model_data(self, category, location, definition_data, metadata):
"""
To instantiate a new xmodule which will be saved latter, set up the dbModel and kvs
"""
kvs = MongoKeyValueStore(
definition_data,
[],
metadata,
location,
category
)
class_ = XModuleDescriptor.load_class(
category,
self.default_class
)
model_data = DbModel(kvs, class_, None, MongoUsage(None, location))
model_data['category'] = category
model_data['location'] = location
return model_data

View File

@@ -8,11 +8,12 @@ and otherwise returns i4x://org/course/cat/name).
from datetime import datetime
from xmodule.modulestore import Location, namedtuple_to_son
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata
from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore.mongo.base import MongoModuleStore
from xmodule.modulestore import Location, namedtuple_to_son
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError
from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.mongo.base import location_to_query, get_course_id_no_run, MongoModuleStore
import pymongo
from pytz import UTC
DRAFT = 'draft'
@@ -92,6 +93,21 @@ class DraftModuleStore(MongoModuleStore):
except ItemNotFoundError:
return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, location, depth=depth))
def create_xmodule(self, location, definition_data=None, metadata=None, system=None):
"""
Create the new xmodule but don't save it. Returns the new module with a draft locator
:param location: a Location--must have a category
:param definition_data: can be empty. The initial definition_data for the kvs
:param metadata: can be empty, the initial metadata for the kvs
:param system: if you already have an xmodule from the course, the xmodule.system value
"""
draft_loc = as_draft(location)
if draft_loc.category in DIRECT_ONLY_CATEGORIES:
raise InvalidVersionError(location)
return super(DraftModuleStore, self).create_xmodule(draft_loc, definition_data, metadata, system)
def get_items(self, location, course_id=None, depth=0):
"""
Returns a list of XModuleDescriptor instances for the items
@@ -119,14 +135,26 @@ class DraftModuleStore(MongoModuleStore):
]
return [wrap_draft(item) for item in draft_items + non_draft_items]
def clone_item(self, source, location):
def convert_to_draft(self, source_location):
"""
Clone a new item that is a copy of the item at the location `source`
and writes it to `location`
Create a copy of the source and mark its revision as draft.
:param source: the location of the source (its revision must be None)
"""
if Location(location).category in DIRECT_ONLY_CATEGORIES:
raise InvalidVersionError(location)
return wrap_draft(super(DraftModuleStore, self).clone_item(source, as_draft(location)))
original = self.collection.find_one(location_to_query(source_location))
draft_location = as_draft(source_location)
if draft_location.category in DIRECT_ONLY_CATEGORIES:
raise InvalidVersionError(source_location)
original['_id'] = draft_location.dict()
try:
self.collection.insert(original)
except pymongo.errors.DuplicateKeyError:
raise DuplicateItemError(original['_id'])
self.refresh_cached_metadata_inheritance_tree(draft_location)
self.fire_updated_modulestore_signal(get_course_id_no_run(draft_location), draft_location)
return self._load_items([original])[0]
def update_item(self, location, data, allow_not_found=False):
"""
@@ -140,7 +168,7 @@ class DraftModuleStore(MongoModuleStore):
try:
draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False):
self.clone_item(location, draft_loc)
self.convert_to_draft(location)
except ItemNotFoundError, e:
if not allow_not_found:
raise e
@@ -158,7 +186,7 @@ class DraftModuleStore(MongoModuleStore):
draft_loc = as_draft(location)
draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False):
self.clone_item(location, draft_loc)
self.convert_to_draft(location)
return super(DraftModuleStore, self).update_children(draft_loc, children)
@@ -174,7 +202,7 @@ class DraftModuleStore(MongoModuleStore):
draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False):
self.clone_item(location, draft_loc)
self.convert_to_draft(location)
if 'is_draft' in metadata:
del metadata['is_draft']
@@ -218,9 +246,7 @@ class DraftModuleStore(MongoModuleStore):
"""
Turn the published version into a draft, removing the published version
"""
if Location(location).category in DIRECT_ONLY_CATEGORIES:
raise InvalidVersionError(location)
super(DraftModuleStore, self).clone_item(location, as_draft(location))
self.convert_to_draft(location)
super(DraftModuleStore, self).delete_item(location)
def _query_children_for_cache_children(self, items):

View File

@@ -5,7 +5,6 @@ from django.test import TestCase
from django.conf import settings
import xmodule.modulestore.django
from xmodule.templates import update_templates
from unittest.util import safe_repr
@@ -48,7 +47,7 @@ def draft_mongo_store_config(data_dir):
return {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'ENGINE': 'xmodule.modulestore.mongo.draft.DraftModuleStore',
'OPTIONS': modulestore_options
},
'direct': {
@@ -110,22 +109,6 @@ class ModuleStoreTestCase(TestCase):
modulestore.collection.remove(query)
modulestore.collection.drop()
@staticmethod
def load_templates_if_necessary():
"""
Load templates into the direct modulestore only if they do not already exist.
We need the templates, because they are copied to create
XModules such as sections and problems.
"""
modulestore = xmodule.modulestore.django.modulestore('direct')
# Count the number of templates
query = {"_id.course": "templates"}
num_templates = modulestore.collection.find(query).count()
if num_templates < 1:
update_templates(modulestore)
@classmethod
def setUpClass(cls):
"""
@@ -169,9 +152,6 @@ class ModuleStoreTestCase(TestCase):
# Flush anything that is not a template
ModuleStoreTestCase.flush_mongo_except_templates()
# Check that we have templates loaded; if not, load them
ModuleStoreTestCase.load_templates_if_necessary()
# Call superclass implementation
super(ModuleStoreTestCase, self)._pre_setup()
@@ -185,34 +165,31 @@ class ModuleStoreTestCase(TestCase):
# Call superclass implementation
super(ModuleStoreTestCase, self)._post_teardown()
def assert2XX(self, status_code, msg=None):
"""
Assert that the given value is a success status (between 200 and 299)
"""
if not 200 <= status_code < 300:
msg = self._formatMessage(msg, "%s is not a success status" % safe_repr(status_code))
raise self.failureExecption(msg)
msg = self._formatMessage(msg, "%s is not a success status" % safe_repr(status_code))
self.assertTrue(status_code >= 200 and status_code < 300, msg=msg)
def assert3XX(self, status_code, msg=None):
"""
Assert that the given value is a redirection status (between 300 and 399)
"""
if not 300 <= status_code < 400:
msg = self._formatMessage(msg, "%s is not a redirection status" % safe_repr(status_code))
raise self.failureExecption(msg)
msg = self._formatMessage(msg, "%s is not a redirection status" % safe_repr(status_code))
self.assertTrue(status_code >= 300 and status_code < 400, msg=msg)
def assert4XX(self, status_code, msg=None):
"""
Assert that the given value is a client error status (between 400 and 499)
"""
if not 400 <= status_code < 500:
msg = self._formatMessage(msg, "%s is not a client error status" % safe_repr(status_code))
raise self.failureExecption(msg)
msg = self._formatMessage(msg, "%s is not a client error status" % safe_repr(status_code))
self.assertTrue(status_code >= 400 and status_code < 500, msg=msg)
def assert5XX(self, status_code, msg=None):
"""
Assert that the given value is a server error status (between 500 and 599)
"""
if not 500 <= status_code < 600:
msg = self._formatMessage(msg, "%s is not a server error status" % safe_repr(status_code))
raise self.failureExecption(msg)
msg = self._formatMessage(msg, "%s is not a server error status" % safe_repr(status_code))
self.assertTrue(status_code >= 500 and status_code < 600, msg=msg)

View File

@@ -1,15 +1,14 @@
from factory import Factory, lazy_attribute_sequence, lazy_attribute
from uuid import uuid4
import datetime
from factory import Factory, LazyAttributeSequence
from uuid import uuid4
from pytz import UTC
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata
from xmodule.x_module import ModuleSystem
from mitxmako.shortcuts import render_to_string
from xblock.runtime import InvalidScopeError
from pytz import UTC
from xmodule.course_module import CourseDescriptor
from xblock.core import Scope
from xmodule.x_module import XModuleDescriptor
class XModuleCourseFactory(Factory):
"""
@@ -21,9 +20,8 @@ class XModuleCourseFactory(Factory):
@classmethod
def _create(cls, target_class, **kwargs):
template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
org = kwargs.pop('org', None)
number = kwargs.pop('number', None)
number = kwargs.pop('number', kwargs.pop('course', None))
display_name = kwargs.pop('display_name', None)
location = Location('i4x', org, number, 'course', Location.clean(display_name))
@@ -33,13 +31,13 @@ class XModuleCourseFactory(Factory):
store = modulestore()
# Write the data to the mongo datastore
new_course = store.clone_item(template, location)
new_course = store.create_xmodule(location)
# This metadata code was copied from cms/djangoapps/contentstore/views.py
if display_name is not None:
new_course.display_name = display_name
new_course.lms.start = datetime.datetime.now(UTC)
new_course.lms.start = datetime.datetime.now(UTC).replace(microsecond=0)
new_course.tabs = kwargs.pop(
'tabs',
[
@@ -56,13 +54,7 @@ class XModuleCourseFactory(Factory):
setattr(new_course, k, v)
# Update the data in the mongo datastore
store.update_metadata(new_course.location, own_metadata(new_course))
store.update_item(new_course.location, new_course._model_data._kvs._data)
# update_item updates the the course as it exists in the modulestore, but doesn't
# update the instance we are working with, so have to refetch the course after updating it.
new_course = store.get_instance(new_course.id, new_course.location)
store.save_xmodule(new_course)
return new_course
@@ -73,7 +65,6 @@ class Course:
class CourseFactory(XModuleCourseFactory):
FACTORY_FOR = Course
template = 'i4x://edx/templates/course/Empty'
org = 'MITx'
number = '999'
display_name = 'Robot Super Course'
@@ -86,76 +77,71 @@ class XModuleItemFactory(Factory):
ABSTRACT_FACTORY = True
display_name = None
parent_location = 'i4x://MITx/999/course/Robot_Super_Course'
category = 'problem'
display_name = LazyAttributeSequence(lambda o, n: "{} {}".format(o.category, n))
@lazy_attribute
def category(attr):
template = Location(attr.template)
return template.category
@lazy_attribute
def location(attr):
parent = Location(attr.parent_location)
dest_name = attr.display_name.replace(" ", "_") if attr.display_name is not None else uuid4().hex
return parent._replace(category=attr.category, name=dest_name)
@staticmethod
def location(parent, category, display_name):
dest_name = display_name.replace(" ", "_") if display_name is not None else uuid4().hex
return Location(parent).replace(category=category, name=dest_name)
@classmethod
def _create(cls, target_class, **kwargs):
"""
Uses *kwargs*:
Uses ``**kwargs``:
*parent_location* (required): the location of the parent module
:parent_location: (required): the location of the parent module
(e.g. the parent course or section)
*template* (required): the template to create the item from
(e.g. i4x://templates/section/Empty)
:category: the category of the resulting item.
*data* (optional): the data for the item
:data: (optional): the data for the item
(e.g. XML problem definition for a problem item)
*display_name* (optional): the display name of the item
:display_name: (optional): the display name of the item
*metadata* (optional): dictionary of metadata attributes
:metadata: (optional): dictionary of metadata attributes
*target_class* is ignored
:boilerplate: (optional) the boilerplate for overriding field values
:target_class: is ignored
"""
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
# catch any old style users before they get into trouble
assert not 'template' in kwargs
parent_location = Location(kwargs.get('parent_location'))
template = Location(kwargs.get('template'))
data = kwargs.get('data')
category = kwargs.get('category')
display_name = kwargs.get('display_name')
metadata = kwargs.get('metadata', {})
location = kwargs.get('location', XModuleItemFactory.location(parent_location, category, display_name))
assert location != parent_location
if kwargs.get('boilerplate') is not None:
template_id = kwargs.get('boilerplate')
clz = XModuleDescriptor.load_class(category)
template = clz.get_template(template_id)
assert template is not None
metadata.update(template.get('metadata', {}))
if not isinstance(data, basestring):
data.update(template.get('data'))
store = modulestore('direct')
# This code was based off that in cms/djangoapps/contentstore/views.py
parent = store.get_item(parent_location)
new_item = store.clone_item(template, kwargs.get('location'))
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
new_item.display_name = display_name
metadata['display_name'] = display_name
store.create_and_save_xmodule(location, metadata=metadata, definition_data=data)
# Add additional metadata or override current metadata
item_metadata = own_metadata(new_item)
item_metadata.update(metadata)
store.update_metadata(new_item.location.url(), item_metadata)
if location.category not in DETACHED_CATEGORIES:
parent.children.append(location.url())
store.update_children(parent_location, parent.children)
# replace the data with the optional *data* parameter
if data is not None:
store.update_item(new_item.location, data)
if new_item.location.category not in DETACHED_CATEGORIES:
store.update_children(parent_location, parent.children + [new_item.location.url()])
# update_children updates the the item as it exists in the modulestore, but doesn't
# update the instance we are working with, so have to refetch the item after updating it.
new_item = store.get_item(new_item.location)
return new_item
return store.get_item(location)
class Item:
@@ -164,40 +150,4 @@ class Item:
class ItemFactory(XModuleItemFactory):
FACTORY_FOR = Item
parent_location = 'i4x://MITx/999/course/Robot_Super_Course'
template = 'i4x://edx/templates/chapter/Empty'
@lazy_attribute_sequence
def display_name(attr, n):
return "{} {}".format(attr.category.title(), n)
def get_test_xmodule_for_descriptor(descriptor):
"""
Attempts to create an xmodule which responds usually correctly from the descriptor. Not guaranteed.
:param descriptor:
"""
module_sys = ModuleSystem(
ajax_url='',
track_function=None,
get_module=None,
render_template=render_to_string,
replace_urls=None,
xblock_model_data=_test_xblock_model_data_accessor(descriptor)
)
return descriptor.xmodule(module_sys)
def _test_xblock_model_data_accessor(descriptor):
simple_map = {}
for field in descriptor.fields:
try:
simple_map[field.name] = getattr(descriptor, field.name)
except InvalidScopeError:
simple_map[field.name] = field.default
for field in descriptor.module_class.fields:
if field.name not in simple_map:
simple_map[field.name] = field.default
return lambda o: simple_map
category = 'chapter'

View File

@@ -9,7 +9,6 @@ from xblock.runtime import KeyValueStore, InvalidScopeError
from xmodule.modulestore import Location
from xmodule.modulestore.mongo import MongoModuleStore, MongoKeyValueStore
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.templates import update_templates
from .test_modulestore import check_path_to_location
from . import DATA_DIR
@@ -51,7 +50,6 @@ class TestMongoModuleStore(object):
# Explicitly list the courses to load (don't want the big one)
courses = ['toy', 'simple']
import_from_xml(store, DATA_DIR, courses)
update_templates(store)
return store
@staticmethod
@@ -126,7 +124,7 @@ class TestMongoKeyValueStore(object):
self.location = Location('i4x://org/course/category/name@version')
self.children = ['i4x://org/course/child/a', 'i4x://org/course/child/b']
self.metadata = {'meta': 'meta_val'}
self.kvs = MongoKeyValueStore(self.data, self.children, self.metadata, self.location)
self.kvs = MongoKeyValueStore(self.data, self.children, self.metadata, self.location, 'category')
def _check_read(self, key, expected_value):
assert_equals(expected_value, self.kvs.get(key))

View File

@@ -194,6 +194,10 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
if hasattr(descriptor, 'children'):
for child in descriptor.get_children():
parent_tracker.add_parent(child.location, descriptor.location)
# After setting up the descriptor, save any changes that we have
# made to attributes on the descriptor to the underlying KeyValueStore.
descriptor.save()
return descriptor
render_template = lambda: ''
@@ -463,7 +467,10 @@ class XMLModuleStore(ModuleStoreBase):
# tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix
slug = os.path.splitext(os.path.basename(filepath))[0]
loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug)
module = HtmlDescriptor(system, {'data': html, 'location': loc})
module = HtmlDescriptor(
system,
{'data': html, 'location': loc, 'category': category}
)
# VS[compat]:
# Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them)
# from the course policy

View File

@@ -78,37 +78,7 @@ class CombinedOpenEndedV1Module():
instance_state=None, shared_state=None, metadata=None, static_data=None, **kwargs):
"""
Definition file should have one or many task blocks, a rubric block, and a prompt block:
Sample file:
<combinedopenended attempts="10000">
<rubric>
Blah blah rubric.
</rubric>
<prompt>
Some prompt.
</prompt>
<task>
<selfassessment>
<hintprompt>
What hint about this problem would you give to someone?
</hintprompt>
<submitmessage>
Save Succcesful. Thanks for participating!
</submitmessage>
</selfassessment>
</task>
<task>
<openended min_score_to_attempt="1" max_score_to_attempt="1">
<openendedparam>
<initial_display>Enter essay here.</initial_display>
<answer_display>This is the answer.</answer_display>
<grader_payload>{"grader_settings" : "ml_grading.conf",
"problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
</openendedparam>
</openended>
</task>
</combinedopenended>
Definition file should have one or many task blocks, a rubric block, and a prompt block. See DEFAULT_DATA in combined_open_ended_module for a sample.
"""
@@ -131,14 +101,14 @@ class CombinedOpenEndedV1Module():
# Allow reset is true if student has failed the criteria to move to the next child task
self.ready_to_reset = instance_state.get('ready_to_reset', False)
self.attempts = self.instance_state.get('attempts', MAX_ATTEMPTS)
self.is_scored = self.instance_state.get('is_graded', IS_SCORED) in TRUE_DICT
self.accept_file_upload = self.instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT
self.skip_basic_checks = self.instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) in TRUE_DICT
self.max_attempts = instance_state.get('max_attempts', MAX_ATTEMPTS)
self.is_scored = instance_state.get('graded', IS_SCORED) in TRUE_DICT
self.accept_file_upload = instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT
self.skip_basic_checks = instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) in TRUE_DICT
due_date = self.instance_state.get('due', None)
due_date = instance_state.get('due', None)
grace_period_string = self.instance_state.get('graceperiod', None)
grace_period_string = instance_state.get('graceperiod', None)
try:
self.timeinfo = TimeInfo(due_date, grace_period_string)
except Exception:
@@ -153,7 +123,7 @@ class CombinedOpenEndedV1Module():
# Static data is passed to the child modules to render
self.static_data = {
'max_score': self._max_score,
'max_attempts': self.attempts,
'max_attempts': self.max_attempts,
'prompt': definition['prompt'],
'rubric': definition['rubric'],
'display_name': self.display_name,
@@ -643,15 +613,18 @@ class CombinedOpenEndedV1Module():
if not self.ready_to_reset:
return self.out_of_sync_error(data)
if self.student_attempts > self.attempts:
if self.student_attempts >= self.max_attempts-1:
if self.student_attempts==self.max_attempts-1:
self.student_attempts +=1
return {
'success': False,
# This is a student_facing_error
'error': (
'You have attempted this question {0} times. '
'You are only allowed to attempt it {1} times.'
).format(self.student_attempts, self.attempts)
).format(self.student_attempts, self.max_attempts)
}
self.student_attempts +=1
self.state = self.INITIAL
self.ready_to_reset = False
for i in xrange(0, len(self.task_xml)):
@@ -726,7 +699,12 @@ class CombinedOpenEndedV1Module():
"""
max_score = None
score = None
if self.is_scored and self.weight is not None:
#The old default was None, so set to 1 if it is the old default weight
weight = self.weight
if weight is None:
weight = 1
if self.is_scored:
# Finds the maximum score of all student attempts and keeps it.
score_mat = []
for i in xrange(0, len(self.task_states)):
@@ -739,7 +717,7 @@ class CombinedOpenEndedV1Module():
for z in xrange(0, len(score)):
if score[z] is None:
score[z] = 0
score[z] *= float(self.weight)
score[z] *= float(weight)
score_mat.append(score)
if len(score_mat) > 0:
@@ -753,7 +731,7 @@ class CombinedOpenEndedV1Module():
if max_score is not None:
# Weight the max score if it is not None
max_score *= float(self.weight)
max_score *= float(weight)
else:
# Without a max_score, we cannot have a score!
score = None
@@ -810,7 +788,6 @@ class CombinedOpenEndedV1Descriptor():
filename_extension = "xml"
has_score = True
template_dir_name = "combinedopenended"
def __init__(self, system):
self.system = system

View File

@@ -730,7 +730,6 @@ class OpenEndedDescriptor():
filename_extension = "xml"
has_score = True
template_dir_name = "openended"
def __init__(self, system):
self.system = system

View File

@@ -287,7 +287,6 @@ class SelfAssessmentDescriptor():
filename_extension = "xml"
has_score = True
template_dir_name = "selfassessment"
def __init__(self, system):
self.system = system

View File

@@ -9,6 +9,7 @@ from .capa_module import ComplexEncoder
from .x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from .timeinfo import TimeInfo
from xblock.core import Dict, String, Scope, Boolean, Integer, Float
from xmodule.fields import Date
@@ -19,36 +20,37 @@ from django.utils.timezone import UTC
log = logging.getLogger(__name__)
USE_FOR_SINGLE_LOCATION = False
LINK_TO_LOCATION = ""
MAX_SCORE = 1
IS_GRADED = False
EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please notify course staff."
class PeerGradingFields(object):
use_for_single_location = Boolean(
display_name="Show Single Problem",
help='When True, only the single problem specified by "Link to Problem Location" is shown. '
'When False, a panel is displayed with all problems available for peer grading.',
default=USE_FOR_SINGLE_LOCATION, scope=Scope.settings
default=False,
scope=Scope.settings
)
link_to_location = String(
display_name="Link to Problem Location",
help='The location of the problem being graded. Only used when "Show Single Problem" is True.',
default=LINK_TO_LOCATION, scope=Scope.settings
default="",
scope=Scope.settings
)
is_graded = Boolean(
graded = Boolean(
display_name="Graded",
help='Defines whether the student gets credit for grading this problem. Only used when "Show Single Problem" is True.',
default=IS_GRADED, scope=Scope.settings
default=False,
scope=Scope.settings
)
due_date = Date(help="Due date that should be displayed.", default=None, scope=Scope.settings)
grace_period_string = String(help="Amount of grace to give on the due date.", default=None, scope=Scope.settings)
max_grade = Integer(
help="The maximum grade that a student can receive for this problem.", default=MAX_SCORE,
scope=Scope.settings, values={"min": 0}
due = Date(
help="Due date that should be displayed.",
default=None,
scope=Scope.settings)
grace_period_string = String(
help="Amount of grace to give on the due date.",
default=None,
scope=Scope.settings
)
student_data_for_location = Dict(
help="Student data for a given peer grading problem.",
@@ -57,8 +59,18 @@ class PeerGradingFields(object):
weight = Float(
display_name="Problem Weight",
help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.",
scope=Scope.settings, values={"min": 0, "step": ".1"}
scope=Scope.settings, values={"min": 0, "step": ".1"},
default=1
)
display_name = String(
display_name="Display Name",
help="Display name for this module",
scope=Scope.settings,
default="Peer Grading Interface"
)
data = String(help="Html contents to display for this module",
default='<peergrading></peergrading>',
scope=Scope.content)
class PeerGradingModule(PeerGradingFields, XModule):
@@ -89,35 +101,31 @@ class PeerGradingModule(PeerGradingFields, XModule):
if self.use_for_single_location:
try:
self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location)
except:
except ItemNotFoundError:
log.error("Linked location {0} for peer grading module {1} does not exist".format(
self.link_to_location, self.location))
raise
due_date = self.linked_problem._model_data.get('peer_grading_due', None)
due_date = self.linked_problem._model_data.get('due', None)
if due_date:
self._model_data['due'] = due_date
try:
self.timeinfo = TimeInfo(self.due_date, self.grace_period_string)
except:
log.error("Error parsing due date information in location {0}".format(location))
self.timeinfo = TimeInfo(self.due, self.grace_period_string)
except Exception:
log.error("Error parsing due date information in location {0}".format(self.location))
raise
self.display_due_date = self.timeinfo.display_due_date
try:
self.student_data_for_location = json.loads(self.student_data_for_location)
except:
except Exception:
pass
self.ajax_url = self.system.ajax_url
if not self.ajax_url.endswith("/"):
self.ajax_url = self.ajax_url + "/"
# Integer could return None, so keep this check.
if not isinstance(self.max_grade, int):
raise TypeError("max_grade needs to be an integer.")
def closed(self):
return self._closed(self.timeinfo)
@@ -201,11 +209,16 @@ class PeerGradingModule(PeerGradingFields, XModule):
def get_score(self):
max_score = None
score = None
weight = self.weight
#The old default was None, so set to 1 if it is the old default weight
if weight is None:
weight = 1
score_dict = {
'score': score,
'total': max_score,
}
if not self.use_for_single_location or not self.is_graded:
if not self.use_for_single_location or not self.graded:
return score_dict
try:
@@ -225,11 +238,10 @@ class PeerGradingModule(PeerGradingFields, XModule):
# Ensures that once a student receives a final score for peer grading, that it does not change.
self.student_data_for_location = response
if self.weight is not None:
score = int(count_graded >= count_required and count_graded > 0) * float(self.weight)
total = self.max_grade * float(self.weight)
score_dict['score'] = score
score_dict['total'] = total
score = int(count_graded >= count_required and count_graded > 0) * float(weight)
total = float(weight)
score_dict['score'] = score
score_dict['total'] = total
return score_dict
@@ -240,8 +252,8 @@ class PeerGradingModule(PeerGradingFields, XModule):
randomization, and 5/7 on another
'''
max_grade = None
if self.use_for_single_location and self.is_graded:
max_grade = self.max_grade
if self.use_for_single_location and self.graded:
max_grade = self.weight
return max_grade
def get_next_submission(self, data):
@@ -521,7 +533,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
problem_location = problem['location']
descriptor = _find_corresponding_module_for_location(problem_location)
if descriptor:
problem['due'] = descriptor._model_data.get('peer_grading_due', None)
problem['due'] = descriptor._model_data.get('due', None)
grace_period_string = descriptor._model_data.get('graceperiod', None)
try:
problem_timeinfo = TimeInfo(problem['due'], grace_period_string)
@@ -604,14 +616,18 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor):
has_score = True
always_recalculate_grades = True
template_dir_name = "peer_grading"
#Specify whether or not to pass in open ended interface
needs_open_ended_interface = True
metadata_translations = {
'is_graded': 'graded',
'attempts': 'max_attempts',
'due_data' : 'due'
}
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(PeerGradingDescriptor, self).non_editable_metadata_fields
non_editable_fields.extend([PeerGradingFields.due_date, PeerGradingFields.grace_period_string,
PeerGradingFields.max_grade])
non_editable_fields.extend([PeerGradingFields.due, PeerGradingFields.grace_period_string])
return non_editable_fields

View File

@@ -140,7 +140,6 @@ class PollDescriptor(PollFields, MakoModuleDescriptor, XmlDescriptor):
_child_tag_name = 'answer'
module_class = PollModule
template_dir_name = 'poll'
@classmethod
def definition_from_xml(cls, xml_object, system):

View File

@@ -13,7 +13,7 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor):
Module that provides a raw editing view of its data and children. It
requires that the definition xml is valid.
"""
data = String(help="XML data for the module", scope=Scope.content)
data = String(help="XML data for the module", default="", scope=Scope.content)
@classmethod
def definition_from_xml(cls, xml_object, system):

View File

@@ -1,34 +1,18 @@
"""
This module handles loading xmodule templates from disk into the modulestore.
These templates are used by the CMS to provide baseline content that
can be cloned when adding new modules to a course.
This module handles loading xmodule templates
These templates are used by the CMS to provide content that overrides xmodule defaults for
samples.
`Template`s are defined in x_module. They contain 3 attributes:
metadata: A dictionary with the template metadata. This should contain
any values for fields
* with scope Scope.settings
* that have values different than the field defaults
* and that are to be editable in Studio
data: A JSON value that defines the template content. This should be a dictionary
containing values for fields
* with scope Scope.content
* that have values different than the field defaults
* and that are to be editable in Studio
or, if the module uses a single Scope.content String field named `data`, this
should be a string containing the contents of that field
children: A list of Location urls that define the template children
Templates are defined on XModuleDescriptor types, in the template attribute.
``Template``s are defined in x_module. They contain 2 attributes:
:metadata: A dictionary with the template metadata
:data: A JSON value that defines the template content
"""
# should this move to cms since it's really only for module crud?
import logging
from fs.memoryfs import MemoryFS
from collections import defaultdict
from .x_module import XModuleDescriptor
from .mako_module import MakoDescriptorSystem
from .modulestore import Location
log = logging.getLogger(__name__)
@@ -37,73 +21,9 @@ def all_templates():
"""
Returns all templates for enabled modules, grouped by descriptor type
"""
# TODO use memcache to memoize w/ expiration
templates = defaultdict(list)
for category, descriptor in XModuleDescriptor.load_classes():
templates[category] = descriptor.templates()
return templates
class TemplateTestSystem(MakoDescriptorSystem):
"""
This system exists to help verify that XModuleDescriptors can be instantiated
from their defined templates before we load the templates into the modulestore.
"""
def __init__(self):
super(TemplateTestSystem, self).__init__(
lambda *a, **k: None,
MemoryFS(),
lambda msg: None,
render_template=lambda *a, **k: None,
)
def update_templates(modulestore):
"""
Updates the set of templates in the modulestore with all templates currently
available from the installed plugins
"""
# cdodge: build up a list of all existing templates. This will be used to determine which
# templates have been removed from disk - and thus we need to remove from the DB
templates_to_delete = modulestore.get_items(['i4x', 'edx', 'templates', None, None, None])
for category, templates in all_templates().items():
for template in templates:
if 'display_name' not in template.metadata:
log.warning('No display_name specified in template {0}, skipping'.format(template))
continue
template_location = Location('i4x', 'edx', 'templates', category, Location.clean_for_url_name(template.metadata['display_name']))
try:
json_data = {
'definition': {
'data': template.data,
'children': template.children
},
'metadata': template.metadata
}
json_data['location'] = template_location.dict()
XModuleDescriptor.load_from_json(json_data, TemplateTestSystem())
except:
log.warning('Unable to instantiate {cat} from template {template}, skipping'.format(
cat=category,
template=template
), exc_info=True)
continue
modulestore.update_item(template_location, template.data)
modulestore.update_children(template_location, template.children)
modulestore.update_metadata(template_location, template.metadata)
# remove template from list of templates to delete
templates_to_delete = [t for t in templates_to_delete if t.location != template_location]
# now remove all templates which appear to have removed from disk
if len(templates_to_delete) > 0:
logging.debug('deleting dangling templates = {0}'.format(templates_to_delete))
for template in templates_to_delete:
modulestore.delete_item(template.location)

View File

@@ -1,5 +0,0 @@
---
metadata:
display_name: Empty
data: "<p>This is where you can add additional information about your course.</p>"
children: []

View File

@@ -3,51 +3,50 @@ metadata:
display_name: overview
data: |
<section class="about">
<h2>About This Course</h2>
<p>Include your long course description here. The long course description should contain 150-400 words.</p>
<section class="about">
<h2>About This Course</h2>
<p>Include your long course description here. The long course description should contain 150-400 words.</p>
<p>This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags.</p>
</section>
<p>This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags.</p>
</section>
<section class="prerequisites">
<h2>Prerequisites</h2>
<p>Add information about course prerequisites here.</p>
</section>
<section class="prerequisites">
<h2>Prerequisites</h2>
<p>Add information about course prerequisites here.</p>
</section>
<section class="course-staff">
<h2>Course Staff</h2>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/pl-faculty.png" align="left" style="margin:0 20 px 0">
</div>
<section class="course-staff">
<h2>Course Staff</h2>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/pl-faculty.png" align="left" style="margin:0 20 px 0">
</div>
<h3>Staff Member #1</h3>
<p>Biography of instructor/staff member #1</p>
</article>
<h3>Staff Member #1</h3>
<p>Biography of instructor/staff member #1</p>
</article>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/pl-faculty.png" align="left" style="margin:0 20 px 0">
</div>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/pl-faculty.png" align="left" style="margin:0 20 px 0">
</div>
<h3>Staff Member #2</h3>
<p>Biography of instructor/staff member #2</p>
</article>
</section>
<h3>Staff Member #2</h3>
<p>Biography of instructor/staff member #2</p>
</article>
</section>
<section class="faq">
<section class="responses">
<h2>Frequently Asked Questions</h2>
<article class="response">
<h3>Do I need to buy a textbook?</h3>
<p>No, a free online version of Chemistry: Principles, Patterns, and Applications, First Edition by Bruce Averill and Patricia Eldredge will be available, though you can purchase a printed version (published by FlatWorld Knowledge) if youd like.</p>
</article>
<section class="faq">
<section class="responses">
<h2>Frequently Asked Questions</h2>
<article class="response">
<h3>Do I need to buy a textbook?</h3>
<p>No, a free online version of Chemistry: Principles, Patterns, and Applications, First Edition by Bruce Averill and Patricia Eldredge will be available, though you can purchase a printed version (published by FlatWorld Knowledge) if youd like.</p>
</article>
<article class="response">
<h3>Question #2</h3>
<p>Your answer would be displayed here.</p>
</article>
</section>
</section>
children: []
<article class="response">
<h3>Question #2</h3>
<p>Your answer would be displayed here.</p>
</article>
</section>
</section>

View File

@@ -1,20 +0,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: []

View File

@@ -1,37 +0,0 @@
---
metadata:
display_name: Open Ended Response
markdown: ""
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: []

View File

@@ -1,124 +0,0 @@
---
metadata:
display_name: Empty
start: 2020-10-10T10:00
checklists: [
{"short_description" : "Getting Started With Studio",
"items" : [{"short_description": "Add Course Team Members",
"long_description": "Grant your collaborators permission to edit your course so you can work together.",
"is_checked": false,
"action_url": "ManageUsers",
"action_text": "Edit Course Team",
"action_external": false},
{"short_description": "Set Important Dates for Your Course",
"long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.",
"is_checked": false,
"action_url": "SettingsDetails",
"action_text": "Edit Course Details &amp; Schedule",
"action_external": false},
{"short_description": "Draft Your Course's Grading Policy",
"long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.",
"is_checked": false,
"action_url": "SettingsGrading",
"action_text": "Edit Grading Settings",
"action_external": false},
{"short_description": "Explore the Other Studio Checklists",
"long_description": "Discover other available course authoring tools, and find help when you need it.",
"is_checked": false,
"action_url": "",
"action_text": "",
"action_external": false}]
},
{"short_description" : "Draft a Rough Course Outline",
"items" : [{"short_description": "Create Your First Section and Subsection",
"long_description": "Use your course outline to build your first Section and Subsection.",
"is_checked": false,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": false},
{"short_description": "Set Section Release Dates",
"long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.",
"is_checked": false,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": false},
{"short_description": "Designate a Subsection as Graded",
"long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.",
"is_checked": false,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": false},
{"short_description": "Reordering Course Content",
"long_description": "Use drag and drop to reorder the content in your course.",
"is_checked": false,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": false},
{"short_description": "Renaming Sections",
"long_description": "Rename Sections by clicking the Section name from the Course Outline.",
"is_checked": false,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": false},
{"short_description": "Deleting Course Content",
"long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.",
"is_checked": false,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": false},
{"short_description": "Add an Instructor-Only Section to Your Outline",
"long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.",
"is_checked": false,
"action_url": "CourseOutline",
"action_text": "Edit Course Outline",
"action_external": false}]
},
{"short_description" : "Explore edX's Support Tools",
"items" : [{"short_description": "Explore the Studio Help Forum",
"long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.",
"is_checked": false,
"action_url": "http://help.edge.edx.org/",
"action_text": "Visit Studio Help",
"action_external": true},
{"short_description": "Enroll in edX 101",
"long_description": "Register for edX 101, edX's primer for course creation.",
"is_checked": false,
"action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about",
"action_text": "Register for edX 101",
"action_external": true},
{"short_description": "Download the Studio Documentation",
"long_description": "Download the searchable Studio reference documentation in PDF form.",
"is_checked": false,
"action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf",
"action_text": "Download Documentation",
"action_external": true}]
},
{"short_description" : "Draft Your Course About Page",
"items" : [{"short_description": "Draft a Course Description",
"long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.",
"is_checked": false,
"action_url": "SettingsDetails",
"action_text": "Edit Course Schedule &amp; Details",
"action_external": false},
{"short_description": "Add Staff Bios",
"long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.",
"is_checked": false,
"action_url": "SettingsDetails",
"action_text": "Edit Course Schedule &amp; Details",
"action_external": false},
{"short_description": "Add Course FAQs",
"long_description": "Include a short list of frequently asked questions about your course.",
"is_checked": false,
"action_url": "SettingsDetails",
"action_text": "Edit Course Schedule &amp; Details",
"action_external": false},
{"short_description": "Add Course Prerequisites",
"long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.",
"is_checked": false,
"action_url": "SettingsDetails",
"action_text": "Edit Course Schedule &amp; Details",
"action_external": false}]
}
]
data: { 'textbooks' : [ ], 'wiki_slug' : null }
children: []

View File

@@ -1,5 +0,0 @@
---
metadata:
display_name: Empty
data: "<ol></ol>"
children: []

View File

@@ -1,5 +0,0 @@
---
metadata:
display_name: Empty
data: ""
children: []

View File

@@ -1,9 +0,0 @@
---
metadata:
display_name: Discussion Tag
for: Topic-Level Student-Visible Label
id: $$GUID$$
discussion_category: Week 1
data: |
<discussion />
children: []

View File

@@ -1,7 +1,6 @@
---
metadata:
display_name: Announcement
display_name: Announcement
data: |
<ol>
<li>
@@ -22,4 +21,3 @@ data: |
</section>
</li>
</ol>
children: []

View File

@@ -1,7 +0,0 @@
---
metadata:
display_name: Blank HTML Page
data: |
children: []

View File

@@ -1,33 +0,0 @@
---
metadata:
display_name: Announcement
data: |
<h1>Heading of document</h1>
<h2>First subheading</h2>
<p>This is a paragraph. It will take care of line breaks for you.</p><p>HTML only parses the location
of tags for inserting line breaks into your doc, not
line
breaks
you
add
yourself.
</p>
<h2>Links</h2>
<p>You can refer to other parts of the internet with a <a href="http://www.wikipedia.org/"> link</a>, to other parts of your course by prepending your link with <a href="/course/Week_0">/course/</a></p>
<p>Now a list:</p>
<ul>
<li>An item</li>
<li>Another item</li>
<li>And yet another</li>
</ul>
<p>This list has an ordering </p>
<ol>
<li>An item</li>
<li>Another item</li>
<li>Yet another item</li>
</ol>
<p> Note, we have a lot of standard edX styles, so please try to avoid any custom styling, and make sure that you make a note of any custom styling that you do yourself so that we can incorporate it into
tools that other people can use. </p>
children: []

Some files were not shown because too many files have changed in this diff Show More