diff --git a/.gitignore b/.gitignore
index b13a128a63..8fb170c30f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,4 +29,5 @@ cover_html/
.idea/
.redcar/
chromedriver.log
+/nbproject
ghostdriver.log
diff --git a/.pylintrc b/.pylintrc
index d1cdbb4780..6690bb7df0 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -36,8 +36,12 @@ load-plugins=
disable=
# W0141: Used builtin function 'map'
# W0142: Used * or ** magic
+# R0201: Method could be a function
+# R0901: Too many ancestors
+# R0902: Too many instance attributes
# R0903: Too few public methods (1/2)
- W0141,W0142,R0903
+# R0904: Too many public methods
+ W0141,W0142,R0201,R0901,R0902,R0903,R0904
[REPORTS]
diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py
index 153d13dd13..8c8aed549d 100644
--- a/cms/djangoapps/contentstore/course_info_model.py
+++ b/cms/djangoapps/contentstore/course_info_model.py
@@ -1,7 +1,7 @@
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
-from lxml import html
+from lxml import html, etree
import re
from django.http import HttpResponseBadRequest
import logging
@@ -26,9 +26,9 @@ def get_course_updates(location):
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
- course_html_parsed = html.fromstring(course_updates.definition['data'])
- except:
- course_html_parsed = html.fromstring("
")
+ course_html_parsed = etree.fromstring(course_updates.data)
+ except etree.XMLSyntaxError:
+ course_html_parsed = etree.fromstring("")
# Confirm that root is , iterate over
, pull out
subs and then rest of val
course_upd_collection = []
@@ -60,13 +60,13 @@ def update_course_updates(location, update, passed_id=None):
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
- return HttpResponseBadRequest
+ return HttpResponseBadRequest()
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
- course_html_parsed = html.fromstring(course_updates.definition['data'])
- except:
- course_html_parsed = html.fromstring("")
+ course_html_parsed = etree.fromstring(course_updates.data)
+ except etree.XMLSyntaxError:
+ course_html_parsed = etree.fromstring("")
# No try/catch b/c failure generates an error back to client
new_html_parsed = html.fromstring('
' + update['date'] + '
' + update['content'] + '
')
@@ -85,13 +85,12 @@ def update_course_updates(location, update, passed_id=None):
passed_id = course_updates.location.url() + "/" + str(idx)
# update db record
- course_updates.definition['data'] = html.tostring(course_html_parsed)
- modulestore('direct').update_item(location, course_updates.definition['data'])
-
- return {"id": passed_id,
- "date": update['date'],
- "content": update['content']}
+ course_updates.data = etree.tostring(course_html_parsed)
+ modulestore('direct').update_item(location, course_updates.data)
+ return {"id" : passed_id,
+ "date" : update['date'],
+ "content" :update['content']}
def delete_course_update(location, update, passed_id):
"""
@@ -99,19 +98,19 @@ def delete_course_update(location, update, passed_id):
Returns the resulting course_updates b/c their ids change.
"""
if not passed_id:
- return HttpResponseBadRequest
+ return HttpResponseBadRequest()
try:
course_updates = modulestore('direct').get_item(location)
except ItemNotFoundError:
- return HttpResponseBadRequest
+ return HttpResponseBadRequest()
# TODO use delete_blank_text parser throughout and cache as a static var in a class
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
- course_html_parsed = html.fromstring(course_updates.definition['data'])
- except:
- course_html_parsed = html.fromstring("")
+ course_html_parsed = etree.fromstring(course_updates.data)
+ except etree.XMLSyntaxError:
+ course_html_parsed = etree.fromstring("")
if course_html_parsed.tag == 'ol':
# ??? Should this use the id in the json or in the url or does it matter?
@@ -122,9 +121,9 @@ def delete_course_update(location, update, passed_id):
course_html_parsed.remove(element_to_delete)
# update db record
- course_updates.definition['data'] = html.tostring(course_html_parsed)
+ course_updates.data = etree.tostring(course_html_parsed)
store = modulestore('direct')
- store.update_item(location, course_updates.definition['data'])
+ store.update_item(location, course_updates.data)
return get_course_updates(location)
diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature
index 779d44e4b2..af97709ad0 100644
--- a/cms/djangoapps/contentstore/features/advanced-settings.feature
+++ b/cms/djangoapps/contentstore/features/advanced-settings.feature
@@ -2,53 +2,41 @@ Feature: Advanced (manual) course policy
In order to specify course policy settings for which no custom user interface exists
I want to be able to manually enter JSON key/value pairs
- Scenario: A course author sees only display_name on a newly created course
+ Scenario: A course author sees default advanced settings
Given I have opened a new course in Studio
When I select the Advanced Settings
- Then I see only the display name
+ Then I see default advanced settings
- @skip-phantom
- Scenario: Test if there are no policy settings without existing UI controls
+ Scenario: Add new entries, and they appear alphabetically after save
Given I am on the Advanced Course Settings page in Studio
- When I delete the display name
- Then there are no advanced policy settings
- And I reload the page
- Then there are no advanced policy settings
-
- @skip-phantom
- Scenario: Test cancel editing key name
- Given I am on the Advanced Course Settings page in Studio
- When I edit the name of a policy key
- And I press the "Cancel" notification button
- Then the policy key name is unchanged
-
- Scenario: Test editing key name
- Given I am on the Advanced Course Settings page in Studio
- When I edit the name of a policy key
- And I press the "Save" notification button
- Then the policy key name is changed
+ Then the settings are alphabetized
Scenario: Test cancel editing key value
Given I am on the Advanced Course Settings page in Studio
When I edit the value of a policy key
And I press the "Cancel" notification button
Then the policy key value is unchanged
+ And I reload the page
+ Then the policy key value is unchanged
- @skip-phantom
Scenario: Test editing key value
Given I am on the Advanced Course Settings page in Studio
When I edit the value of a policy key
And I press the "Save" notification button
Then the policy key value is changed
-
- Scenario: Add new entries, and they appear alphabetically after save
- Given I am on the Advanced Course Settings page in Studio
- When I create New Entries
- Then they are alphabetized
And I reload the page
- Then they are alphabetized
+ Then the policy key value is changed
Scenario: Test how multi-line input appears
Given I am on the Advanced Course Settings page in Studio
- When I create a JSON object
+ When I create a JSON object as a value
Then it is displayed as formatted
+ And I reload the page
+ Then it is displayed as formatted
+
+ Scenario: Test automatic quoting of non-JSON values
+ Given I am on the Advanced Course Settings page in Studio
+ When I create a non-JSON value not in quotes
+ Then it is displayed as a string
+ And I reload the page
+ Then it is displayed as a string
diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py
index 1024579b45..7e86e94a31 100644
--- a/cms/djangoapps/contentstore/features/advanced-settings.py
+++ b/cms/djangoapps/contentstore/features/advanced-settings.py
@@ -1,6 +1,7 @@
from lettuce import world, step
from common import *
import time
+from terrain.steps import reload_the_page
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.support import expected_conditions as EC
@@ -11,6 +12,10 @@ http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdrive
"""
from selenium.webdriver.common.keys import Keys
+KEY_CSS = '.key input.policy-key'
+VALUE_CSS = 'textarea.json'
+DISPLAY_NAME_KEY = "display_name"
+DISPLAY_NAME_VALUE = '"Robot Super Course"'
############### ACTIONS ####################
@step('I select the Advanced Settings$')
@@ -20,7 +25,6 @@ def i_select_advanced_settings(step):
css_click(expand_icon_css)
link_css = 'li.nav-course-settings-advanced a'
css_click(link_css)
- # world.browser.click_link_by_text('Advanced Settings')
@step('I am on the Advanced Course Settings page in Studio$')
@@ -29,35 +33,27 @@ def i_am_on_advanced_course_settings(step):
step.given('I select the Advanced Settings')
-# TODO: this is copied from terrain's step.py. Need to figure out how to share that code.
-@step('I reload the page$')
-def reload_the_page(step):
- world.browser.reload()
-
-
-@step(u'I edit the name of a policy key$')
-def edit_the_name_of_a_policy_key(step):
- policy_key_css = 'input.policy-key'
- e = css_find(policy_key_css).first
- e.type('_new')
-
-
@step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(step, name):
def is_visible(driver):
- return EC.visibility_of_element_located((By.CSS_SELECTOR,css,))
- def is_invisible(driver):
- return EC.invisibility_of_element_located((By.CSS_SELECTOR,css,))
+ return EC.visibility_of_element_located((By.CSS_SELECTOR, css,))
+
+ # def is_invisible(driver):
+ # return EC.invisibility_of_element_located((By.CSS_SELECTOR,css,))
css = 'a.%s-button' % name.lower()
wait_for(is_visible)
+ time.sleep(float(1))
+ css_click_at(css)
+
+# is_invisible is not returning a boolean, not working
+# try:
+# css_click_at(css)
+# wait_for(is_invisible)
+# except WebDriverException, e:
+# css_click_at(css)
+# wait_for(is_invisible)
- try:
- css_click_at(css)
- wait_for(is_invisible)
- except WebDriverException, e:
- css_click_at(css)
- wait_for(is_invisible)
@step(u'I edit the value of a policy key$')
def edit_the_value_of_a_policy_key(step):
@@ -65,133 +61,86 @@ def edit_the_value_of_a_policy_key(step):
It is hard to figure out how to get into the CodeMirror
area, so cheat and do it from the policy key field :)
"""
- policy_key_css = 'input.policy-key'
- e = css_find(policy_key_css).first
+ e = css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X')
-@step('I delete the display name$')
-def delete_the_display_name(step):
- delete_entry(0)
- click_save()
-
-
-@step('create New Entries$')
-def create_new_entries(step):
- create_entry("z", "apple")
- create_entry("a", "zebra")
- click_save()
-
-
-@step('I create a JSON object$')
+@step('I create a JSON object as a value$')
def create_JSON_object(step):
- create_entry("json", '{"key": "value", "key_2": "value_2"}')
- click_save()
+ change_display_name_value(step, '{"key": "value", "key_2": "value_2"}')
+
+
+@step('I create a non-JSON value not in quotes$')
+def create_value_not_in_quotes(step):
+ change_display_name_value(step, 'quote me')
############### RESULTS ####################
-@step('I see only the display name$')
-def i_see_only_display_name(step):
- assert_policy_entries(["display_name"], ['"Robot Super Course"'])
+@step('I see default advanced settings$')
+def i_see_default_advanced_settings(step):
+ # Test only a few of the existing properties (there are around 34 of them)
+ assert_policy_entries(
+ ["advanced_modules", DISPLAY_NAME_KEY, "show_calculator"], ["[]", DISPLAY_NAME_VALUE, "false"])
-@step('there are no advanced policy settings$')
-def no_policy_settings(step):
- keys_css = 'input.policy-key'
- val_css = 'textarea.json'
- k = world.browser.is_element_not_present_by_css(keys_css, 5)
- v = world.browser.is_element_not_present_by_css(val_css, 5)
- assert_true(k)
- assert_true(v)
-
-
-@step('they are alphabetized$')
+@step('the settings are alphabetized$')
def they_are_alphabetized(step):
- assert_policy_entries(["a", "display_name", "z"], ['"zebra"', '"Robot Super Course"', '"apple"'])
+ key_elements = css_find(KEY_CSS)
+ all_keys = []
+ for key in key_elements:
+ all_keys.append(key.value)
+
+ assert_equal(sorted(all_keys), all_keys, "policy keys were not sorted")
@step('it is displayed as formatted$')
def it_is_formatted(step):
- assert_policy_entries(["display_name", "json"], ['"Robot Super Course"', '{\n "key": "value",\n "key_2": "value_2"\n}'])
+ assert_policy_entries([DISPLAY_NAME_KEY], ['{\n "key": "value",\n "key_2": "value_2"\n}'])
-@step(u'the policy key name is unchanged$')
-def the_policy_key_name_is_unchanged(step):
- policy_key_css = 'input.policy-key'
- val = css_find(policy_key_css).first.value
- assert_equal(val, 'display_name')
-
-
-@step(u'the policy key name is changed$')
-def the_policy_key_name_is_changed(step):
- policy_key_css = 'input.policy-key'
- val = css_find(policy_key_css).first.value
- assert_equal(val, 'display_name_new')
+@step('it is displayed as a string')
+def it_is_formatted(step):
+ assert_policy_entries([DISPLAY_NAME_KEY], ['"quote me"'])
@step(u'the policy key value is unchanged$')
def the_policy_key_value_is_unchanged(step):
- policy_value_css = 'li.course-advanced-policy-list-item div.value textarea'
- val = css_find(policy_value_css).first.value
- assert_equal(val, '"Robot Super Course"')
+ assert_equal(get_display_name_value(), DISPLAY_NAME_VALUE)
@step(u'the policy key value is changed$')
-def the_policy_key_value_is_unchanged(step):
- policy_value_css = 'li.course-advanced-policy-list-item div.value textarea'
- val = css_find(policy_value_css).first.value
- assert_equal(val, '"Robot Super Course X"')
+def the_policy_key_value_is_changed(step):
+ assert_equal(get_display_name_value(), '"Robot Super Course X"')
############# HELPERS ###############
-def create_entry(key, value):
- # Scroll down the page so the button is visible
- world.scroll_to_bottom()
- css_click_at('a.new-advanced-policy-item', 10, 10)
- new_key_css = 'div#__new_advanced_key__ input'
- new_key_element = css_find(new_key_css).first
- new_key_element.fill(key)
-# For some reason have to get the instance for each command
-# (get error that it is no longer attached to the DOM)
-# Have to do all this because Selenium fill does not remove existing text
- new_value_css = 'div.CodeMirror textarea'
- css_find(new_value_css).last.fill("")
- css_find(new_value_css).last._element.send_keys(Keys.DELETE, Keys.DELETE)
- css_find(new_value_css).last.fill(value)
- # Add in a TAB key press because intermittently on ubuntu the
- # last character of "value" above was not getting typed in
- css_find(new_value_css).last._element.send_keys(Keys.TAB)
-
-
-def delete_entry(index):
- """
- Delete the nth entry where index is 0-based
- """
- css = 'a.delete-button'
- assert_true(world.browser.is_element_present_by_css(css, 5))
- delete_buttons = css_find(css)
- assert_true(len(delete_buttons) > index, "no delete button exists for entry " + str(index))
- delete_buttons[index].click()
-
-
def assert_policy_entries(expected_keys, expected_values):
- assert_entries('.key input.policy-key', expected_keys)
- assert_entries('textarea.json', expected_values)
+ for counter in range(len(expected_keys)):
+ index = get_index_of(expected_keys[counter])
+ assert_false(index == -1, "Could not find key: " + expected_keys[counter])
+ assert_equal(expected_values[counter], css_find(VALUE_CSS)[index].value, "value is incorrect")
-def assert_entries(css, expected_values):
- webElements = css_find(css)
- assert_equal(len(expected_values), len(webElements))
-# Sometimes get stale reference if I hold on to the array of elements
- for counter in range(len(expected_values)):
- assert_equal(expected_values[counter], css_find(css)[counter].value)
+def get_index_of(expected_key):
+ for counter in range(len(css_find(KEY_CSS))):
+ # Sometimes get stale reference if I hold on to the array of elements
+ key = css_find(KEY_CSS)[counter].value
+ if key == expected_key:
+ return counter
+
+ return -1
-def click_save():
- css = "a.save-button"
- css_click_at(css)
+def get_display_name_value():
+ index = get_index_of(DISPLAY_NAME_KEY)
+ return css_find(VALUE_CSS)[index].value
-def fill_last_field(value):
- newValue = css_find('#__new_advanced_key__ input').first
- newValue.fill(value)
+def change_display_name_value(step, new_value):
+ e = css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)]
+ display_name = get_display_name_value()
+ for count in range(len(display_name)):
+ e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE)
+ # Must delete "" before typing the JSON value
+ e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value)
+ press_the_notification_button(step, "Save")
\ No newline at end of file
diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py
index 789226db1a..fc92205030 100644
--- a/cms/djangoapps/contentstore/management/commands/delete_course.py
+++ b/cms/djangoapps/contentstore/management/commands/delete_course.py
@@ -7,7 +7,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
-from prompt import query_yes_no
+from .prompt import query_yes_no
from auth.authz import _delete_course_group
diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py
index 7ed4505c94..8ea6add88d 100644
--- a/cms/djangoapps/contentstore/module_info_model.py
+++ b/cms/djangoapps/contentstore/module_info_model.py
@@ -15,10 +15,10 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
module = store.clone_item(template_location, location)
- data = module.definition['data']
+ data = module.data
if rewrite_static_links:
data = replace_static_urls(
- module.definition['data'],
+ module.data,
None,
course_namespace=Location([
module.location.tag,
@@ -32,7 +32,8 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
return {
'id': module.location.url(),
'data': data,
- 'metadata': module.metadata
+ # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
+ 'metadata': module._model_data._kvs._metadata
}
@@ -70,23 +71,23 @@ def set_module_info(store, location, post_data):
# 'apply' the submitted metadata, so we don't end up deleting system metadata
if post_data.get('metadata') is not None:
posted_metadata = post_data['metadata']
-
+
# 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 in posted_metadata.keys():
-
+ for metadata_key, value in posted_metadata.items():
+
# let's strip out any metadata fields from the postback which have been identified as system metadata
# and therefore should not be user-editable, so we should accept them back from the client
if metadata_key in module.system_metadata_fields:
del posted_metadata[metadata_key]
elif 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 module.metadata:
- del module.metadata[metadata_key]
+ if metadata_key in module._model_data:
+ del module._model_data[metadata_key]
del posted_metadata[metadata_key]
-
- # overlay the new metadata over the modulestore sourced collection to support partial updates
- module.metadata.update(posted_metadata)
-
+ else:
+ module._model_data[metadata_key] = value
+
# commit to datastore
- store.update_metadata(location, module.metadata)
+ # 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)
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index 9d533dffed..d04e1a6332 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -6,15 +6,16 @@ from django.conf import settings
from django.core.urlresolvers import reverse
from path import path
from tempdir import mkdtemp_clean
+from datetime import timedelta
import json
from fs.osfs import OSFS
import copy
from json import loads
from django.contrib.auth.models import User
-from cms.djangoapps.contentstore.utils import get_modulestore
+from contentstore.utils import get_modulestore
-from utils import ModuleStoreTestCase, parse_json
+from .utils import ModuleStoreTestCase, parse_json
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore import Location
@@ -25,6 +26,7 @@ from xmodule.contentstore.django import 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
+from xmodule.modulestore.inheritance import own_metadata
from xmodule.capa_module import CapaDescriptor
from xmodule.course_module import CourseDescriptor
@@ -109,10 +111,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None]))
# make sure the parent no longer points to the child object which was deleted
- self.assertTrue(sequential.location.url() in chapter.definition['children'])
+ self.assertTrue(sequential.location.url() in chapter.children)
- self.client.post(reverse('delete_item'),
- json.dumps({'id': sequential.location.url(), 'delete_children':'true', 'delete_all_versions':'true'}),
+ self.client.post(reverse('delete_item'),
+ json.dumps({'id': sequential.location.url(), 'delete_children': 'true', 'delete_all_versions': 'true'}),
"application/json")
found = False
@@ -127,9 +129,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None]))
# make sure the parent no longer points to the child object which was deleted
- self.assertFalse(sequential.location.url() in chapter.definition['children'])
+ self.assertFalse(sequential.location.url() in chapter.children)
+
-
def test_about_overrides(self):
'''
@@ -139,11 +141,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
module_store = modulestore('direct')
effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None]))
- self.assertEqual(effort.definition['data'], '6 hours')
+ self.assertEqual(effort.data, '6 hours')
# this one should be in a non-override folder
effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'end_date', None]))
- self.assertEqual(effort.definition['data'], 'TBD')
+ self.assertEqual(effort.data, 'TBD')
def test_remove_hide_progress_tab(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
@@ -153,7 +155,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
course = module_store.get_item(source_location)
- self.assertNotIn('hide_progress_tab', course.metadata)
+ self.assertFalse(course.hide_progress_tab)
def test_clone_course(self):
@@ -246,7 +248,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# compare what's on disk compared to what we have in our course
with fs.open('grading_policy.json', 'r') as grading_policy:
on_disk = loads(grading_policy.read())
- self.assertEqual(on_disk, course.definition['data']['grading_policy'])
+ self.assertEqual(on_disk, course.grading_policy)
#check for policy.json
self.assertTrue(fs.exists('policy.json'))
@@ -255,7 +257,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
with fs.open('policy.json', 'r') as course_policy:
on_disk = loads(course_policy.read())
self.assertIn('course/6.002_Spring_2012', on_disk)
- self.assertEqual(on_disk['course/6.002_Spring_2012'], course.metadata)
+ self.assertEqual(on_disk['course/6.002_Spring_2012'], own_metadata(course))
# remove old course
delete_course(module_store, content_store, location)
@@ -302,10 +304,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
course = module_store.get_item(location)
+ metadata = own_metadata(course)
# add a bool piece of unknown metadata so we can verify we don't throw an exception
- course.metadata['new_metadata'] = True
+ metadata['new_metadata'] = True
- module_store.update_metadata(location, course.metadata)
+ module_store.update_metadata(location, metadata)
print 'Exporting to tempdir = {0}'.format(root_dir)
@@ -473,21 +476,20 @@ class ContentStoreTest(ModuleStoreTestCase):
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
context = problem.get_context()
self.assertIn('markdown', context, "markdown is missing from context")
- self.assertIn('markdown', problem.metadata, "markdown is missing from metadata")
self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields")
def test_import_metadata_with_attempts_empty_string(self):
import_from_xml(modulestore(), 'common/test/data/', ['simple'])
module_store = modulestore('direct')
did_load_item = False
- try:
+ try:
module_store.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None]))
did_load_item = True
except ItemNotFoundError:
pass
# make sure we found the item (e.g. it didn't error while loading)
- self.assertTrue(did_load_item)
+ self.assertTrue(did_load_item)
def test_metadata_inheritance(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
@@ -499,8 +501,7 @@ class ContentStoreTest(ModuleStoreTestCase):
# let's assert on the metadata_inheritance on an existing vertical
for vertical in verticals:
- self.assertIn('xqa_key', vertical.metadata)
- self.assertEqual(course.metadata['xqa_key'], vertical.metadata['xqa_key'])
+ self.assertEqual(course.lms.xqa_key, vertical.lms.xqa_key)
self.assertGreater(len(verticals), 0)
@@ -510,36 +511,33 @@ class ContentStoreTest(ModuleStoreTestCase):
# crate a new module and add it as a child to a vertical
module_store.clone_item(source_template_location, new_component_location)
parent = verticals[0]
- module_store.update_children(parent.location, parent.definition.get('children', []) + [new_component_location.url()])
+ module_store.update_children(parent.location, parent.children + [new_component_location.url()])
# flush the cache
module_store.get_cached_metadata_inheritance_tree(new_component_location, -1)
new_module = module_store.get_item(new_component_location)
# check for grace period definition which should be defined at the course level
- self.assertIn('graceperiod', new_module.metadata)
+ self.assertEqual(parent.lms.graceperiod, new_module.lms.graceperiod)
- self.assertEqual(parent.metadata['graceperiod'], new_module.metadata['graceperiod'])
-
- self.assertEqual(course.metadata['xqa_key'], new_module.metadata['xqa_key'])
+ self.assertEqual(course.lms.xqa_key, new_module.lms.xqa_key)
#
# now let's define an override at the leaf node level
#
- new_module.metadata['graceperiod'] = '1 day'
- module_store.update_metadata(new_module.location, new_module.metadata)
+ new_module.lms.graceperiod = timedelta(1)
+ module_store.update_metadata(new_module.location, own_metadata(new_module))
# flush the cache and refetch
module_store.get_cached_metadata_inheritance_tree(new_component_location, -1)
new_module = module_store.get_item(new_component_location)
- self.assertIn('graceperiod', new_module.metadata)
- self.assertEqual('1 day', new_module.metadata['graceperiod'])
+ self.assertEqual(timedelta(1), new_module.lms.graceperiod)
class TemplateTestCase(ModuleStoreTestCase):
- def test_template_cleanup(self):
+ def test_template_cleanup(self):
module_store = modulestore('direct')
# insert a bogus template in the store
@@ -562,4 +560,3 @@ class TemplateTestCase(ModuleStoreTestCase):
asserted = True
self.assertTrue(asserted)
-
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 5560d2e39b..ecdeca29e7 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -10,16 +10,16 @@ from django.core.urlresolvers import reverse
from django.utils.timezone import UTC
from xmodule.modulestore import Location
-from cms.djangoapps.models.settings.course_details import (CourseDetails,
+from models.settings.course_details import (CourseDetails,
CourseSettingsEncoder)
-from cms.djangoapps.models.settings.course_grading import CourseGradingModel
-from cms.djangoapps.contentstore.utils import get_modulestore
+from models.settings.course_grading import CourseGradingModel
+from contentstore.utils import get_modulestore
from django.test import TestCase
-from utils import ModuleStoreTestCase
+from .utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
-from cms.djangoapps.models.settings.course_metadata import CourseMetadata
+from models.settings.course_metadata import CourseMetadata
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.django import modulestore
@@ -246,8 +246,9 @@ class CourseGradingTest(CourseTestCase):
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D")
- test_grader.grace_period = {'hours' : 4, 'minutes' : 5, 'seconds': 0}
+ test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0}
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
+ print test_grader.grace_period, altered_grader.grace_period
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period")
def test_update_grader_from_json(self):
@@ -286,31 +287,31 @@ class CourseMetadataEditingTest(CourseTestCase):
def test_update_from_json(self):
test_model = CourseMetadata.update_from_json(self.course_location,
- { "a" : 1,
- "b_a_c_h" : { "c" : "test" },
- "test_text" : "a text string"})
+ { "advertised_start" : "start A",
+ "testcenter_info" : { "c" : "test" },
+ "days_early_for_beta" : 2})
self.update_check(test_model)
# try fresh fetch to ensure persistence
test_model = CourseMetadata.fetch(self.course_location)
self.update_check(test_model)
# now change some of the existing metadata
test_model = CourseMetadata.update_from_json(self.course_location,
- { "a" : 2,
+ { "advertised_start" : "start B",
"display_name" : "jolly roger"})
self.assertIn('display_name', test_model, 'Missing editable metadata field')
self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value")
- self.assertIn('a', test_model, 'Missing revised a metadata field')
- self.assertEqual(test_model['a'], 2, "a not expected value")
+ self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field')
+ self.assertEqual(test_model['advertised_start'], 'start B', "advertised_start not expected value")
def update_check(self, test_model):
self.assertIn('display_name', test_model, 'Missing editable metadata field')
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
- self.assertIn('a', test_model, 'Missing new a metadata field')
- self.assertEqual(test_model['a'], 1, "a not expected value")
- self.assertIn('b_a_c_h', test_model, 'Missing b_a_c_h metadata field')
- self.assertDictEqual(test_model['b_a_c_h'], { "c" : "test" }, "b_a_c_h not expected value")
- self.assertIn('test_text', test_model, 'Missing test_text metadata field')
- self.assertEqual(test_model['test_text'], "a text string", "test_text not expected value")
+ self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field')
+ self.assertEqual(test_model['advertised_start'], 'start A', "advertised_start not expected value")
+ self.assertIn('testcenter_info', test_model, 'Missing testcenter_info metadata field')
+ self.assertDictEqual(test_model['testcenter_info'], { "c" : "test" }, "testcenter_info not expected value")
+ self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field')
+ self.assertEqual(test_model['days_early_for_beta'], 2, "days_early_for_beta not expected value")
def test_delete_key(self):
@@ -321,5 +322,5 @@ class CourseMetadataEditingTest(CourseTestCase):
self.assertEqual(test_model['display_name'], 'Testing', "not expected value")
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
# check for deletion effectiveness
- self.assertNotIn('showanswer', test_model, 'showanswer field still in')
- self.assertNotIn('xqa_key', test_model, 'xqa_key field still in')
\ No newline at end of file
+ self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in')
+ self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in')
diff --git a/cms/djangoapps/contentstore/tests/test_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py
index c57f1322f5..6a3a1e21f7 100644
--- a/cms/djangoapps/contentstore/tests/test_course_updates.py
+++ b/cms/djangoapps/contentstore/tests/test_course_updates.py
@@ -1,4 +1,4 @@
-from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase
+from contentstore.tests.test_course_settings import CourseTestCase
from django.core.urlresolvers import reverse
import json
diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py
index 09e3b045f9..4ab40d17a8 100644
--- a/cms/djangoapps/contentstore/tests/test_utils.py
+++ b/cms/djangoapps/contentstore/tests/test_utils.py
@@ -1,4 +1,4 @@
-from cms.djangoapps.contentstore import utils
+from contentstore import utils
import mock
from django.test import TestCase
diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py
index c4a46459e2..e43a95fccd 100644
--- a/cms/djangoapps/contentstore/tests/tests.py
+++ b/cms/djangoapps/contentstore/tests/tests.py
@@ -8,7 +8,7 @@ import json
from fs.osfs import OSFS
import copy
-from cms.djangoapps.contentstore.utils import get_modulestore
+from contentstore.utils import get_modulestore
from xmodule.modulestore import Location
from xmodule.modulestore.store_utilities import clone_course
@@ -24,7 +24,7 @@ from xmodule.course_module import CourseDescriptor
from xmodule.seq_module import SequenceDescriptor
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
-from utils import ModuleStoreTestCase, parse_json, user, registration
+from .utils import ModuleStoreTestCase, parse_json, user, registration
class ContentStoreTestCase(ModuleStoreTestCase):
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index cba30131b5..0a99441fe9 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -39,10 +39,10 @@ def get_course_location_for_item(location):
# make sure we found exactly one match on this above course search
found_cnt = len(courses)
if found_cnt == 0:
- raise BaseException('Could not find course at {0}'.format(course_search_location))
+ raise Exception('Could not find course at {0}'.format(course_search_location))
if found_cnt > 1:
- raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
+ raise Exception('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
location = courses[0].location
@@ -136,7 +136,7 @@ def compute_unit_state(unit):
'private' content is editabled and not visible in the LMS
"""
- if unit.metadata.get('is_draft', False):
+ if unit.cms.is_draft:
try:
modulestore('direct').get_item(unit.location)
return UnitState.draft
diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py
index 6566350f8d..eb634b0cdd 100644
--- a/cms/djangoapps/contentstore/views.py
+++ b/cms/djangoapps/contentstore/views.py
@@ -18,7 +18,8 @@ from django.core.files.temp import NamedTemporaryFile
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
from PIL import Image
-from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden
+from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError
+from django.http import HttpResponseNotFound
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.core.context_processors import csrf
@@ -28,11 +29,15 @@ from django.conf import settings
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
+from xmodule.modulestore.inheritance import own_metadata
+from xblock.core import Scope
+from xblock.runtime import KeyValueStore, DbModel, InvalidScopeError
from xmodule.x_module import ModuleSystem
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
import static_replace
from external_auth.views import ssl_login_shortcut
+from xmodule.modulestore.mongo import MongoUsage
from mitxmako.shortcuts import render_to_response, render_to_string
from xmodule.modulestore.django import modulestore
@@ -54,12 +59,12 @@ from contentstore.course_info_model import get_course_updates,\
from cache_toolbox.core import del_cached_content
from xmodule.timeparse import stringify_time
from contentstore.module_info_model import get_module_info, set_module_info
-from cms.djangoapps.models.settings.course_details import CourseDetails,\
+from models.settings.course_details import CourseDetails,\
CourseSettingsEncoder
-from cms.djangoapps.models.settings.course_grading import CourseGradingModel
-from cms.djangoapps.contentstore.utils import get_modulestore
+from models.settings.course_grading import CourseGradingModel
+from contentstore.utils import get_modulestore
from django.shortcuts import redirect
-from cms.djangoapps.models.settings.course_metadata import CourseMetadata
+from models.settings.course_metadata import CourseMetadata
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
@@ -110,7 +115,7 @@ def login_page(request):
def howitworks(request):
if request.user.is_authenticated():
return index(request)
- else:
+ else:
return render_to_response('howitworks.html', {})
# ==== Views for any logged-in user ==================================
@@ -135,7 +140,7 @@ def index(request):
return render_to_response('index.html', {
'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'),
- 'courses': [(course.metadata.get('display_name'),
+ 'courses': [(course.display_name,
reverse('course_index', args=[
course.location.org,
course.location.course,
@@ -239,8 +244,13 @@ def edit_subsection(request, location):
# remove all metadata from the generic dictionary that is presented in a more normalized UI
- policy_metadata = dict((key, value) for key, value in item.metadata.iteritems()
- if key not in ['display_name', 'start', 'due', 'format'] and key not in item.system_metadata_fields)
+ policy_metadata = dict(
+ (field.name, field.read_from(item))
+ for field
+ in item.fields
+ if field.name not in ['display_name', 'start', 'due', 'format'] and
+ field.scope == Scope.settings
+ )
can_view_live = False
subsection_units = item.get_children()
@@ -293,8 +303,7 @@ def edit_unit(request, location):
# Check if there are any advanced modules specified in the course policy. These modules
# should be specified as a list of strings, where the strings are the names of the modules
# in ADVANCED_COMPONENT_TYPES that should be enabled for the course.
- course_metadata = CourseMetadata.fetch(course.location)
- course_advanced_keys = course_metadata.get(ADVANCED_COMPONENT_POLICY_KEY, [])
+ course_advanced_keys = course.advanced_modules
# Set component types according to course policy file
component_types = list(COMPONENT_TYPES)
@@ -315,10 +324,10 @@ def edit_unit(request, location):
if category in component_types:
#This is a hack to create categories for different xmodules
component_templates[category].append((
- template.display_name,
+ template.display_name_with_default,
template.location.url(),
- 'markdown' in template.metadata,
- 'empty' in template.metadata
+ hasattr(template, 'markdown') and template.markdown is not None,
+ template.cms.empty,
))
components = [
@@ -362,11 +371,6 @@ def edit_unit(request, location):
unit_state = compute_unit_state(item)
- try:
- published_date = time.strftime('%B %d, %Y', item.metadata.get('published_date'))
- except TypeError:
- published_date = None
-
return render_to_response('unit.html', {
'context_course': course,
'active_tab': 'courseware',
@@ -377,11 +381,11 @@ def edit_unit(request, location):
'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link,
'subsection': containing_subsection,
- 'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.start))) if containing_subsection.start is not None else None,
+ 'release_date': get_date_display(datetime.fromtimestamp(time.mktime(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'),
'unit_state': unit_state,
- 'published_date': published_date,
+ 'published_date': item.cms.published_date.strftime('%B %d, %Y') if item.cms.published_date is not None else None,
})
@@ -446,9 +450,8 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
dispatch: The action to execute
"""
- instance_state, shared_state = load_preview_state(request, preview_id, location)
descriptor = modulestore().get_item(location)
- instance = load_preview_module(request, preview_id, descriptor, instance_state, shared_state)
+ instance = load_preview_module(request, preview_id, descriptor)
# Let the module handle the AJAX
try:
ajax_return = instance.handle_ajax(dispatch, request.POST)
@@ -459,46 +462,9 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
log.exception("error processing ajax call")
raise
- save_preview_state(request, preview_id, location, instance.get_instance_state(), instance.get_shared_state())
return HttpResponse(ajax_return)
-def load_preview_state(request, preview_id, location):
- """
- Load the state of a preview module from the request
-
- preview_id (str): An identifier specifying which preview this module is used for
- location: The Location of the module to dispatch to
- """
- if 'preview_states' not in request.session:
- request.session['preview_states'] = defaultdict(dict)
-
- instance_state = request.session['preview_states'][preview_id, location].get('instance')
- shared_state = request.session['preview_states'][preview_id, location].get('shared')
-
- return instance_state, shared_state
-
-
-def save_preview_state(request, preview_id, location, instance_state, shared_state):
- """
- Save the state of a preview module to the request
-
- preview_id (str): An identifier specifying which preview this module is used for
- location: The Location of the module to dispatch to
- instance_state: The instance state to save
- shared_state: The shared state to save
- """
- if 'preview_states' not in request.session:
- request.session['preview_states'] = defaultdict(dict)
-
- # request.session doesn't notice indirect changes; so, must set its dict w/ every change to get
- # it to persist: http://www.djangobook.com/en/2.0/chapter14.html
- preview_states = request.session['preview_states']
- preview_states[preview_id, location]['instance'] = instance_state
- preview_states[preview_id, location]['shared'] = shared_state
- request.session['preview_states'] = preview_states # make session mgmt notice the update
-
-
def render_from_lms(template_name, dictionary, context=None, namespace='main'):
"""
Render a template using the LMS MAKO_TEMPLATES
@@ -506,6 +472,33 @@ def render_from_lms(template_name, dictionary, context=None, namespace='main'):
return render_to_string(template_name, dictionary, context, namespace="lms." + namespace)
+class SessionKeyValueStore(KeyValueStore):
+ def __init__(self, request, model_data):
+ self._model_data = model_data
+ self._session = request.session
+
+ def get(self, key):
+ try:
+ return self._model_data[key.field_name]
+ except (KeyError, InvalidScopeError):
+ return self._session[tuple(key)]
+
+ def set(self, key, value):
+ try:
+ self._model_data[key.field_name] = value
+ except (KeyError, InvalidScopeError):
+ self._session[tuple(key)] = value
+
+ def delete(self, key):
+ try:
+ del self._model_data[key.field_name]
+ except (KeyError, InvalidScopeError):
+ del self._session[tuple(key)]
+
+ def has(self, key):
+ return key in self._model_data or key in self._session
+
+
def preview_module_system(request, preview_id, descriptor):
"""
Returns a ModuleSystem for the specified descriptor that is specialized for
@@ -516,6 +509,14 @@ def preview_module_system(request, preview_id, descriptor):
descriptor: An XModuleDescriptor
"""
+ def preview_model_data(descriptor):
+ return DbModel(
+ SessionKeyValueStore(request, descriptor._model_data),
+ descriptor.module_class,
+ preview_id,
+ MongoUsage(preview_id, descriptor.location.url()),
+ )
+
return ModuleSystem(
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'),
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
@@ -526,6 +527,7 @@ def preview_module_system(request, preview_id, descriptor):
debug=True,
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
user=request.user,
+ xblock_model_data=preview_model_data,
)
@@ -538,11 +540,11 @@ def get_preview_module(request, preview_id, descriptor):
preview_id (str): An identifier specifying which preview this module is used for
location: A Location
"""
- instance_state, shared_state = descriptor.get_sample_state()[0]
- return load_preview_module(request, preview_id, descriptor, instance_state, shared_state)
+
+ return load_preview_module(request, preview_id, descriptor)
-def load_preview_module(request, preview_id, descriptor, instance_state, shared_state):
+def load_preview_module(request, preview_id, descriptor):
"""
Return a preview XModule instantiated from the supplied descriptor, instance_state, and shared_state
@@ -554,12 +556,13 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_
"""
system = preview_module_system(request, preview_id, descriptor)
try:
- module = descriptor.xmodule_constructor(system)(instance_state, shared_state)
+ module = descriptor.xmodule(system)
except:
+ log.debug("Unable to load preview module", exc_info=True)
module = ErrorDescriptor.from_descriptor(
descriptor,
error_msg=exc_info_to_str(sys.exc_info())
- ).xmodule_constructor(system)(None, None)
+ ).xmodule(system)
# cdodge: Special case
if module.location.category == 'static_tab':
@@ -577,11 +580,9 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_
module.get_html = replace_static_urls(
module.get_html,
- module.metadata.get('data_dir', module.location.course),
+ getattr(module, 'data_dir', module.location.course),
course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
)
- save_preview_state(request, preview_id, descriptor.location.url(),
- module.get_instance_state(), module.get_shared_state())
return module
@@ -595,7 +596,7 @@ def get_module_previews(request, descriptor):
"""
preview_html = []
for idx, (instance_state, shared_state) in enumerate(descriptor.get_sample_state()):
- module = load_preview_module(request, str(idx), descriptor, instance_state, shared_state)
+ module = load_preview_module(request, str(idx), descriptor)
preview_html.append(module.get_html())
return preview_html
@@ -649,9 +650,11 @@ def delete_item(request):
for parent_loc in parent_locs:
parent = modulestore('direct').get_item(parent_loc)
item_url = item_loc.url()
- if item_url in parent.definition["children"]:
- parent.definition["children"].remove(item_url)
- modulestore('direct').update_children(parent.location, parent.definition["children"])
+ if item_url in parent.children:
+ children = parent.children
+ children.remove(item_url)
+ parent.children = children
+ modulestore('direct').update_children(parent.location, parent.children)
return HttpResponse()
@@ -690,7 +693,7 @@ def save_item(request):
# 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 in posted_metadata.keys():
+ for metadata_key, value in posted_metadata.items():
# let's strip out any metadata fields from the postback which have been identified as system metadata
# and therefore should not be user-editable, so we should accept them back from the client
@@ -698,15 +701,15 @@ def save_item(request):
del posted_metadata[metadata_key]
elif 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.metadata:
- del existing_item.metadata[metadata_key]
+ if metadata_key in existing_item._model_data:
+ del existing_item._model_data[metadata_key]
del posted_metadata[metadata_key]
-
- # overlay the new metadata over the modulestore sourced collection to support partial updates
- existing_item.metadata.update(posted_metadata)
+ else:
+ existing_item._model_data[metadata_key] = value
# commit to datastore
- store.update_metadata(item_location, existing_item.metadata)
+ # 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()
@@ -773,17 +776,14 @@ def clone_item(request):
new_item = get_modulestore(template).clone_item(template, dest_location)
- # TODO: This needs to be deleted when we have proper storage for static content
- new_item.metadata['data_dir'] = parent.metadata['data_dir']
-
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
- new_item.metadata['display_name'] = display_name
+ new_item.display_name = display_name
- get_modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata)
+ get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item))
if new_item.location.category not in DETACHED_CATEGORIES:
- get_modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
+ get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()])
return HttpResponse(json.dumps({'id': dest_location.url()}))
@@ -1002,7 +1002,7 @@ def reorder_static_tabs(request):
for tab in course.tabs:
if tab['type'] == 'static_tab':
reordered_tabs.append({'type': 'static_tab',
- 'name': tab_items[static_tab_idx].metadata.get('display_name'),
+ 'name': tab_items[static_tab_idx].display_name,
'url_slug': tab_items[static_tab_idx].location.name})
static_tab_idx += 1
else:
@@ -1011,7 +1011,7 @@ def reorder_static_tabs(request):
# OK, re-assemble the static tabs in the new order
course.tabs = reordered_tabs
- modulestore('direct').update_metadata(course.location, course.metadata)
+ modulestore('direct').update_metadata(course.location, own_metadata(course))
return HttpResponse()
@@ -1229,7 +1229,6 @@ def course_config_advanced_page(request, org, course, name):
return render_to_response('settings_advanced.html', {
'context_course': course_module,
'course_location' : location,
- 'advanced_blacklist' : json.dumps(CourseMetadata.FILTERED_LIST),
'advanced_dict' : json.dumps(CourseMetadata.fetch(location)),
})
@@ -1312,7 +1311,7 @@ def course_advanced_updates(request, org, course, name):
org, course: Attributes of the Location for the item to edit
"""
location = ['i4x', org, course, 'course', name]
-
+
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
@@ -1322,7 +1321,7 @@ def course_advanced_updates(request, org, course, name):
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
-
+
if real_method == 'GET':
return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json")
elif real_method == 'DELETE':
@@ -1433,13 +1432,10 @@ def create_new_course(request):
new_course = modulestore('direct').clone_item(template, dest_location)
if display_name is not None:
- new_course.metadata['display_name'] = display_name
-
- # we need a 'data_dir' for legacy reasons
- new_course.metadata['data_dir'] = uuid4().hex
+ new_course.display_name = display_name
# set a default start date to now
- new_course.metadata['start'] = stringify_time(time.gmtime())
+ new_course.start = time.gmtime()
initialize_course_tabs(new_course)
@@ -1458,12 +1454,12 @@ def initialize_course_tabs(course):
# This logic is repeated in xmodule/modulestore/tests/factories.py
# so if you change anything here, you need to also change it there.
course.tabs = [{"type": "courseware"},
- {"type": "course_info", "name": "Course Info"},
+ {"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"},
{"type": "progress", "name": "Progress"}]
- modulestore('direct').update_metadata(course.location.url(), course.own_metadata)
+ modulestore('direct').update_metadata(course.location.url(), own_metadata(course))
@ensure_csrf_cookie
@@ -1602,3 +1598,11 @@ def event(request):
console logs don't get distracted :-)
'''
return HttpResponse(True)
+
+
+def render_404(request):
+ return HttpResponseNotFound(render_to_string('404.html', {}))
+
+
+def render_500(request):
+ return HttpResponseServerError(render_to_string('500.html', {}))
diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py
index b27f4e3804..d3cd5fe164 100644
--- a/cms/djangoapps/models/settings/course_details.py
+++ b/cms/djangoapps/models/settings/course_details.py
@@ -1,13 +1,14 @@
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError
+from xmodule.modulestore.inheritance import own_metadata
import json
from json.encoder import JSONEncoder
import time
from contentstore.utils import get_modulestore
from util.converters import jsdate_to_time, time_to_date
-from cms.djangoapps.models.settings import course_grading
-from cms.djangoapps.contentstore.utils import update_item
+from models.settings import course_grading
+from contentstore.utils import update_item
import re
import logging
@@ -43,25 +44,25 @@ class CourseDetails(object):
temploc = course_location._replace(category='about', name='syllabus')
try:
- course.syllabus = get_modulestore(temploc).get_item(temploc).definition['data']
+ course.syllabus = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='overview')
try:
- course.overview = get_modulestore(temploc).get_item(temploc).definition['data']
+ course.overview = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='effort')
try:
- course.effort = get_modulestore(temploc).get_item(temploc).definition['data']
+ course.effort = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='video')
try:
- raw_video = get_modulestore(temploc).get_item(temploc).definition['data']
+ raw_video = get_modulestore(temploc).get_item(temploc).data
course.intro_video = CourseDetails.parse_video_tag(raw_video)
except ItemNotFoundError:
pass
@@ -116,7 +117,7 @@ class CourseDetails(object):
descriptor.enrollment_end = converted
if dirty:
- get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
+ 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
# to make faster, could compare against db or could have client send over a list of which fields changed.
@@ -133,7 +134,6 @@ class CourseDetails(object):
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
update_item(temploc, recomposed_video_tag)
-
# Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm
# it persisted correctly
return CourseDetails.fetch(course_location)
diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py
index 3d0b8f78af..b20fb71f66 100644
--- a/cms/djangoapps/models/settings/course_grading.py
+++ b/cms/djangoapps/models/settings/course_grading.py
@@ -2,6 +2,7 @@ from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
import re
from util import converters
+from datetime import timedelta
class CourseGradingModel(object):
@@ -91,7 +92,7 @@ class CourseGradingModel(object):
descriptor.raw_grader = graders_parsed
descriptor.grade_cutoffs = jsondict['grade_cutoffs']
- get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
+ get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period'])
return CourseGradingModel.fetch(course_location)
@@ -119,7 +120,7 @@ class CourseGradingModel(object):
else:
descriptor.raw_grader.append(grader)
- get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
+ get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
@@ -134,7 +135,7 @@ class CourseGradingModel(object):
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.grade_cutoffs = cutoffs
- get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
+ get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
return cutoffs
@@ -156,11 +157,11 @@ class CourseGradingModel(object):
graceperiodjson = graceperiodjson['grace_period']
# lms requires these to be in a fixed order
- grace_rep = "{0[hours]:d} hours {0[minutes]:d} minutes {0[seconds]:d} seconds".format(graceperiodjson)
+ grace_timedelta = timedelta(**graceperiodjson)
descriptor = get_modulestore(course_location).get_item(course_location)
- descriptor.metadata['graceperiod'] = grace_rep
- get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
+ descriptor.lms.graceperiod = grace_timedelta
+ get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
@staticmethod
def delete_grader(course_location, index):
@@ -176,7 +177,7 @@ class CourseGradingModel(object):
del descriptor.raw_grader[index]
# force propagation to definition
descriptor.raw_grader = descriptor.raw_grader
- get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
+ get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
# NOTE cannot delete cutoffs. May be useful to reset
@staticmethod
@@ -189,7 +190,7 @@ class CourseGradingModel(object):
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.definition['data'])
+ get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
return descriptor.grade_cutoffs
@@ -202,8 +203,8 @@ class CourseGradingModel(object):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
- if 'graceperiod' in descriptor.metadata: del descriptor.metadata['graceperiod']
- get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
+ del descriptor.lms.graceperiod
+ get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
@staticmethod
def get_section_grader_type(location):
@@ -212,7 +213,7 @@ class CourseGradingModel(object):
descriptor = get_modulestore(location).get_item(location)
return {
- "graderType": descriptor.metadata.get('format', u"Not Graded"),
+ "graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
"location": location,
"id": 99 # just an arbitrary value to
}
@@ -224,23 +225,41 @@ class CourseGradingModel(object):
descriptor = get_modulestore(location).get_item(location)
if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded":
- descriptor.metadata['format'] = jsondict.get('graderType')
- descriptor.metadata['graded'] = True
+ descriptor.lms.format = jsondict.get('graderType')
+ descriptor.lms.graded = True
else:
- if 'format' in descriptor.metadata: del descriptor.metadata['format']
- if 'graded' in descriptor.metadata: del descriptor.metadata['graded']
+ del descriptor.lms.format
+ del descriptor.lms.graded
- get_modulestore(location).update_metadata(location, descriptor.metadata)
+ get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata)
@staticmethod
def convert_set_grace_period(descriptor):
- # 5 hours 59 minutes 59 seconds => { hours: 5, minutes : 59, seconds : 59}
- rawgrace = descriptor.metadata.get('graceperiod', None)
+ # 5 hours 59 minutes 59 seconds => converted to iso format
+ rawgrace = descriptor.lms.graceperiod
if rawgrace:
- parsedgrace = {str(key): int(val) for (val, key) in re.findall('\s*(\d+)\s*(\w+)', rawgrace)}
- return parsedgrace
- else: return None
+ hours_from_days = rawgrace.days*24
+ seconds = rawgrace.seconds
+ hours_from_seconds = int(seconds / 3600)
+ hours = hours_from_days + hours_from_seconds
+ seconds -= hours_from_seconds * 3600
+ minutes = int(seconds / 60)
+ seconds -= minutes * 60
+
+ graceperiod = {'hours': 0, 'minutes': 0, 'seconds': 0}
+ if hours > 0:
+ graceperiod['hours'] = hours
+
+ if minutes > 0:
+ graceperiod['minutes'] = minutes
+
+ if seconds > 0:
+ graceperiod['seconds'] = seconds
+
+ return graceperiod
+ else:
+ return None
@staticmethod
def parse_grader(json_grader):
diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py
index 24245a39d5..ed11a6d7a4 100644
--- a/cms/djangoapps/models/settings/course_metadata.py
+++ b/cms/djangoapps/models/settings/course_metadata.py
@@ -1,6 +1,8 @@
from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
from xmodule.x_module import XModuleDescriptor
+from xmodule.modulestore.inheritance import own_metadata
+from xblock.core import Scope
class CourseMetadata(object):
@@ -8,8 +10,7 @@ class CourseMetadata(object):
For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones.
The objects have no predefined attrs but instead are obj encodings of the editable metadata.
'''
- # __new_advanced_key__ is used by client not server; so, could argue against it being here
- FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__']
+ FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod']
@classmethod
def fetch(cls, course_location):
@@ -23,17 +24,20 @@ class CourseMetadata(object):
descriptor = get_modulestore(course_location).get_item(course_location)
- for k, v in descriptor.metadata.iteritems():
- if k not in cls.FILTERED_LIST:
- course[k] = v
-
+ for field in descriptor.fields + descriptor.lms.fields:
+ if field.scope != Scope.settings:
+ continue
+
+ if field.name not in cls.FILTERED_LIST:
+ course[field.name] = field.read_from(descriptor)
+
return course
@classmethod
def update_from_json(cls, course_location, jsondict):
"""
Decode the json into CourseMetadata and save any changed attrs to the db.
-
+
Ensures none of the fields are in the blacklist.
"""
descriptor = get_modulestore(course_location).get_item(course_location)
@@ -42,12 +46,18 @@ class CourseMetadata(object):
for k, v in jsondict.iteritems():
# should it be an error if one of the filtered list items is in the payload?
- if k not in cls.FILTERED_LIST and (k not in descriptor.metadata or descriptor.metadata[k] != v):
+ if k in cls.FILTERED_LIST:
+ continue
+
+ if hasattr(descriptor, k) and getattr(descriptor, k) != v:
dirty = True
- descriptor.metadata[k] = v
+ setattr(descriptor, k, v)
+ elif hasattr(descriptor.lms, k) and getattr(descriptor.lms, k) != k:
+ dirty = True
+ setattr(descriptor.lms, k, v)
if dirty:
- get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
+ get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor))
# Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm
# it persisted correctly
@@ -61,10 +71,11 @@ class CourseMetadata(object):
descriptor = get_modulestore(course_location).get_item(course_location)
for key in payload['deleteKeys']:
- if key in descriptor.metadata:
- del descriptor.metadata[key]
+ if hasattr(descriptor, key):
+ delattr(descriptor, key)
+ elif hasattr(descriptor.lms, key):
+ delattr(descriptor.lms, key)
- get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
+ get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor))
return cls.fetch(course_location)
-
\ No newline at end of file
diff --git a/cms/envs/dev.py b/cms/envs/dev.py
index 9164c02e3f..f70f22512e 100644
--- a/cms/envs/dev.py
+++ b/cms/envs/dev.py
@@ -96,6 +96,13 @@ CACHES = {
'KEY_PREFIX': 'general',
'VERSION': 4,
'KEY_FUNCTION': 'util.memcache.safe_key',
+ },
+
+ 'mongo_metadata_inheritance': {
+ 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
+ 'LOCATION': '/var/tmp/mongo_metadata_inheritance',
+ 'TIMEOUT': 300,
+ 'KEY_FUNCTION': 'util.memcache.safe_key',
}
}
diff --git a/cms/envs/test.py b/cms/envs/test.py
index abe03edd41..d7992cb471 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -98,6 +98,13 @@ CACHES = {
'KEY_PREFIX': 'general',
'VERSION': 4,
'KEY_FUNCTION': 'util.memcache.safe_key',
+ },
+
+ 'mongo_metadata_inheritance': {
+ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+ 'LOCATION': '/var/tmp/mongo_metadata_inheritance',
+ 'TIMEOUT': 300,
+ 'KEY_FUNCTION': 'util.memcache.safe_key',
}
}
diff --git a/cms/one_time_startup.py b/cms/one_time_startup.py
index 93428a3404..38a2fef847 100644
--- a/cms/one_time_startup.py
+++ b/cms/one_time_startup.py
@@ -1,5 +1,13 @@
from dogapi import dog_http_api, dog_stats_api
from django.conf import settings
+from xmodule.modulestore.django import modulestore
+
+from django.core.cache import get_cache, InvalidCacheBackendError
+
+cache = get_cache('mongo_metadata_inheritance')
+for store_name in settings.MODULESTORE:
+ store = modulestore(store_name)
+ store.metadata_inheritance_cache = cache
if hasattr(settings, 'DATADOG_API'):
dog_http_api.api_key = settings.DATADOG_API
diff --git a/cms/static/client_templates/advanced_entry.html b/cms/static/client_templates/advanced_entry.html
index 0312fdd344..6be22e2116 100644
--- a/cms/static/client_templates/advanced_entry.html
+++ b/cms/static/client_templates/advanced_entry.html
@@ -1,16 +1,11 @@
-
+
-
- Keys are case sensitive and cannot contain spaces or start with a number
+
\ No newline at end of file
diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json
index 2249813b04..e7a66b5bc0 100644
--- a/cms/static/coffee/files.json
+++ b/cms/static/coffee/files.json
@@ -1,12 +1,12 @@
{
- "js_files": [
- "/static/js/vendor/RequireJS.js",
- "/static/js/vendor/jquery.min.js",
- "/static/js/vendor/jquery-ui.min.js",
- "/static/js/vendor/jquery.ui.draggable.js",
- "/static/js/vendor/jquery.cookie.js",
- "/static/js/vendor/json2.js",
- "/static/js/vendor/underscore-min.js",
- "/static/js/vendor/backbone-min.js"
+ "static_files": [
+ "js/vendor/RequireJS.js",
+ "js/vendor/jquery.min.js",
+ "js/vendor/jquery-ui.min.js",
+ "js/vendor/jquery.ui.draggable.js",
+ "js/vendor/jquery.cookie.js",
+ "js/vendor/json2.js",
+ "js/vendor/underscore-min.js",
+ "js/vendor/backbone-min.js"
]
}
diff --git a/cms/static/js/models/settings/advanced.js b/cms/static/js/models/settings/advanced.js
index 5741eb88dd..adc259239d 100644
--- a/cms/static/js/models/settings/advanced.js
+++ b/cms/static/js/models/settings/advanced.js
@@ -1,27 +1,15 @@
if (!CMS.Models['Settings']) CMS.Models.Settings = {};
CMS.Models.Settings.Advanced = Backbone.Model.extend({
- // the key for a newly added policy-- before the user has entered a key value
- new_key : "__new_advanced_key__",
defaults: {
// the properties are whatever the user types in (in addition to whatever comes originally from the server)
},
// which keys to send as the deleted keys on next save
deleteKeys : [],
- blacklistKeys : [], // an array which the controller should populate directly for now [static not instance based]
validate: function (attrs) {
- var errors = {};
- for (var key in attrs) {
- if (key === this.new_key || _.isEmpty(key)) {
- errors[key] = "A key must be entered.";
- }
- else if (_.contains(this.blacklistKeys, key)) {
- errors[key] = key + " is a reserved keyword or can be edited on another screen";
- }
- }
- if (!_.isEmpty(errors)) return errors;
+ // Keys can no longer be edited. We are currently not validating values.
},
save : function (attrs, options) {
diff --git a/cms/static/js/template_loader.js b/cms/static/js/template_loader.js
index 3492ca677a..f64c685364 100644
--- a/cms/static/js/template_loader.js
+++ b/cms/static/js/template_loader.js
@@ -5,7 +5,7 @@
if (typeof window.templateLoader == 'function') return;
var templateLoader = {
- templateVersion: "0.0.15",
+ templateVersion: "0.0.16",
templates: {},
loadRemoteTemplate: function(templateName, filename, callback) {
if (!this.templates[templateName]) {
diff --git a/cms/static/js/views/settings/advanced_view.js b/cms/static/js/views/settings/advanced_view.js
index a933bbdb9b..e3ff098efb 100644
--- a/cms/static/js/views/settings/advanced_view.js
+++ b/cms/static/js/views/settings/advanced_view.js
@@ -6,14 +6,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.Advanced
events : {
- 'click .delete-button' : "deleteEntry",
- 'click .new-button' : "addEntry",
- // update model on changes
- 'change .policy-key' : "updateKey",
- // keypress to catch alpha keys and backspace/delete on some browsers
- 'keypress .policy-key' : "showSaveCancelButtons",
- // keyup to catch backspace/delete reliably
- 'keyup .policy-key' : "showSaveCancelButtons",
'focus :input' : "focusInput",
'blur :input' : "blurInput"
// TODO enable/disable save based on validation (currently enabled whenever there are changes)
@@ -95,16 +87,11 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
mirror.setValue(stringValue);
} catch(quotedE) {
// TODO: validation error
- console.log("Error with JSON, even after converting to String.");
- console.log(quotedE);
+ // console.log("Error with JSON, even after converting to String.");
+ // console.log(quotedE);
JSONValue = undefined;
}
}
- else {
- // TODO: validation error
- console.log("Error with JSON, but will not convert to String.");
- console.log(e);
- }
}
if (JSONValue !== undefined) {
self.clearValidationErrors();
@@ -113,7 +100,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
}
});
},
-
showMessage: function (type) {
this.$el.find(".message-status").removeClass("is-shown");
if (type) {
@@ -128,56 +114,19 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
else {
// This is the case of the page first rendering, or when Cancel is pressed.
this.hideSaveCancelButtons();
- this.toggleNewButton(true);
}
},
-
showSaveCancelButtons: function(event) {
if (!this.buttonsVisible) {
- if (event && (event.type === 'keypress' || event.type === 'keyup')) {
- // check whether it's really an altering event: note, String.fromCharCode(keyCode) will
- // give positive values for control/command/option-letter combos; so, don't use it
- if (!((event.charCode && String.fromCharCode(event.charCode) !== "") ||
- // 8 = backspace, 46 = delete
- event.keyCode === 8 || event.keyCode === 46)) return;
- }
this.$el.find(".message-status").removeClass("is-shown");
$('.wrapper-notification').addClass('is-shown');
this.buttonsVisible = true;
}
},
-
hideSaveCancelButtons: function() {
$('.wrapper-notification').removeClass('is-shown');
this.buttonsVisible = false;
},
-
- toggleNewButton: function (enable) {
- var newButton = this.$el.find(".new-button");
- if (enable) {
- newButton.removeClass('disabled');
- }
- else {
- newButton.addClass('disabled');
- }
- },
-
- deleteEntry : function(event) {
- event.preventDefault();
- // find out which entry
- var li$ = $(event.currentTarget).closest('li');
- // Not data b/c the validation view uses it for a selector
- var key = $('.key', li$).attr('id');
-
- delete this.selectorToField[this.fieldToSelectorMap[key]];
- delete this.fieldToSelectorMap[key];
- if (key !== this.model.new_key) {
- this.model.deleteKeys.push(key);
- this.model.unset(key);
- }
- li$.remove();
- this.showSaveCancelButtons();
- },
saveView : function(event) {
// TODO one last verification scan:
// call validateKey on each to ensure proper format
@@ -201,102 +150,15 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
error : CMS.ServerError
});
},
- addEntry : function() {
- var listEle$ = this.$el.find('.course-advanced-policy-list');
- var newEle = this.renderTemplate("", "");
- listEle$.append(newEle);
- // need to re-find b/c replaceWith seems to copy rather than use the specific ele instance
- var policyValueDivs = this.$el.find('#' + this.model.new_key).closest('li').find('.json');
- // only 1 but hey, let's take advantage of the context mechanism
- _.each(policyValueDivs, this.attachJSONEditor, this);
- this.toggleNewButton(false);
- },
- updateKey : function(event) {
- var parentElement = $(event.currentTarget).closest('.key');
- // old key: either the key as in the model or new_key.
- // That is, it doesn't change as the val changes until val is accepted.
- var oldKey = parentElement.attr('id');
- // TODO: validation of keys with spaces. For now at least trim strings to remove initial and
- // trailing whitespace
- var newKey = $.trim($(event.currentTarget).val());
- if (oldKey !== newKey) {
- // TODO: is it OK to erase other validation messages?
- this.clearValidationErrors();
-
- if (!this.validateKey(oldKey, newKey)) return;
-
- if (this.model.has(newKey)) {
- var error = {};
- error[oldKey] = 'You have already defined "' + newKey + '" in the manual policy definitions.';
- error[newKey] = "You tried to enter a duplicate of this key.";
- this.model.trigger("invalid", this.model, error);
- return false;
- }
-
- // explicitly call validate to determine whether to proceed (relying on triggered error means putting continuation in the success
- // method which is uglier I think?)
- var newEntryModel = {};
- // set the new key's value to the old one's
- newEntryModel[newKey] = (oldKey === this.model.new_key ? '' : this.model.get(oldKey));
-
- var validation = this.model.validate(newEntryModel);
- if (validation) {
- if (_.has(validation, newKey)) {
- // swap to the key which the map knows about
- validation[oldKey] = validation[newKey];
- }
- this.model.trigger("invalid", this.model, validation);
- // abandon update
- return;
- }
-
- // Now safe to actually do the update
- this.model.set(newEntryModel);
-
- // update maps
- var selector = this.fieldToSelectorMap[oldKey];
- this.selectorToField[selector] = newKey;
- this.fieldToSelectorMap[newKey] = selector;
- delete this.fieldToSelectorMap[oldKey];
-
- if (oldKey !== this.model.new_key) {
- // mark the old key for deletion and delete from field maps
- this.model.deleteKeys.push(oldKey);
- this.model.unset(oldKey) ;
- }
- else {
- // id for the new entry will now be the key value. Enable new entry button.
- this.toggleNewButton(true);
- }
-
- // check for newkey being the name of one which was previously deleted in this session
- var wasDeleting = this.model.deleteKeys.indexOf(newKey);
- if (wasDeleting >= 0) {
- this.model.deleteKeys.splice(wasDeleting, 1);
- }
-
- // Update the ID to the new value.
- parentElement.attr('id', newKey);
-
- }
- },
- validateKey : function(oldKey, newKey) {
- // model validation can't handle malformed keys nor notice if 2 fields have same key; so, need to add that chk here
- // TODO ensure there's no spaces or illegal chars (note some checking for spaces currently done in model's
- // validate method.
- return true;
- },
-
renderTemplate: function (key, value) {
var newKeyId = _.uniqueId('policy_key_'),
newEle = this.template({ key : key, value : JSON.stringify(value, null, 4),
keyUniqueId: newKeyId, valueUniqueId: _.uniqueId('policy_value_')});
- this.fieldToSelectorMap[(_.isEmpty(key) ? this.model.new_key : key)] = newKeyId;
- this.selectorToField[newKeyId] = (_.isEmpty(key) ? this.model.new_key : key);
+ this.fieldToSelectorMap[key] = newKeyId;
+ this.selectorToField[newKeyId] = key;
return newEle;
},
-
focusInput : function(event) {
$(event.target).prev().addClass("is-focused");
},
diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss
index 5d4bc7c773..a2d46c0510 100644
--- a/cms/static/sass/_base.scss
+++ b/cms/static/sass/_base.scss
@@ -405,6 +405,21 @@ textarea.text {
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
outline: 0;
}
+
+ &[disabled] {
+ border-color: $gray-l4;
+ color: $gray-l2;
+ }
+
+ &[readonly] {
+ border-color: $gray-l4;
+ color: $gray-l1;
+
+ &:focus {
+ @include linear-gradient($lightGrey, tint($lightGrey, 90%));
+ outline: 0;
+ }
+ }
}
// forms - specific
diff --git a/cms/static/sass/_settings.scss b/cms/static/sass/_settings.scss
index d8011dd651..a42ff80bc2 100644
--- a/cms/static/sass/_settings.scss
+++ b/cms/static/sass/_settings.scss
@@ -239,13 +239,9 @@ body.course.settings {
// not editable fields
.field.is-not-editable {
-
- label, .label {
- color: $gray-l3;
- }
-
- input {
- opacity: 0.25;
+
+ & label.is-focused {
+ color: $gray-d2;
}
}
diff --git a/cms/templates/404.html b/cms/templates/404.html
new file mode 100644
index 0000000000..a45a223bad
--- /dev/null
+++ b/cms/templates/404.html
@@ -0,0 +1,14 @@
+<%inherit file="base.html" />
+<%block name="title">Page Not Found%block>
+
+<%block name="content">
+
+
+
+
+
Page not found
+
The page that you were looking for was not found. Go back to the homepage or let us know about any pages that may have been moved at technical@edx.org.
+
+
+
+%block>
\ No newline at end of file
diff --git a/cms/templates/500.html b/cms/templates/500.html
new file mode 100644
index 0000000000..2645b0067b
--- /dev/null
+++ b/cms/templates/500.html
@@ -0,0 +1,13 @@
+<%inherit file="base.html" />
+<%block name="title">Server Error%block>
+
+<%block name="content">
+
+
+
+
Currently the edX servers are down
+
Our staff is currently working to get the site back up as soon as possible. Please email us at technical@edx.org to report any problems or downtime.
<%
- start_date = datetime.fromtimestamp(mktime(subsection.start)) if subsection.start is not None else None
- parent_start_date = datetime.fromtimestamp(mktime(parent_item.start)) if parent_item.start is not None else None
+ start_date = datetime.fromtimestamp(mktime(subsection.lms.start)) if subsection.lms.start is not None else None
+ parent_start_date = datetime.fromtimestamp(mktime(parent_item.lms.start)) if parent_item.lms.start is not None else None
%>
-
+
- % if subsection.start != parent_item.start and subsection.start:
+ % if subsection.lms.start != parent_item.lms.start and subsection.lms.start:
% if parent_start_date is None:
-
The date above differs from the release date of ${parent_item.display_name}, which is unset.
+
The date above differs from the release date of ${parent_item.display_name_with_default}, which is unset.
% else:
-
The date above differs from the release date of ${parent_item.display_name} – ${parent_start_date.strftime('%m/%d/%Y')} at ${parent_start_date.strftime('%H:%M')}.
+
The date above differs from the release date of ${parent_item.display_name_with_default} – ${parent_start_date.strftime('%m/%d/%Y')} at ${parent_start_date.strftime('%H:%M')}.
% endif
- Sync to ${parent_item.display_name}.
<%
# due date uses it own formatting for stringifying the date. As with capa_module.py, there's a utility module available for us to use
- due_date = dateutil.parser.parse(subsection.metadata.get('due')) if 'due' in subsection.metadata else None
+ due_date = dateutil.parser.parse(subsection.lms.due) if subsection.lms.due else None
%>
-
+
Remove due date
@@ -110,7 +110,7 @@
-
+
%block>
\ No newline at end of file
diff --git a/cms/templates/new_item.html b/cms/templates/new_item.html
index 60da39fd2a..45cb157845 100644
--- a/cms/templates/new_item.html
+++ b/cms/templates/new_item.html
@@ -8,7 +8,7 @@
<%
- start_date = datetime.fromtimestamp(mktime(section.start)) if section.start is not None else None
+ start_date = datetime.fromtimestamp(mktime(section.lms.start)) if section.lms.start is not None else None
start_date_str = start_date.strftime('%m/%d/%Y') if start_date is not None else ''
start_time_str = start_date.strftime('%H:%M') if start_date is not None else ''
%>
@@ -174,9 +174,9 @@
Will Release: ${start_date_str} at ${start_time_str}Edit
%endif
-
diff --git a/cms/templates/settings.html b/cms/templates/settings.html
index 39e9b70f9d..b26a17125b 100644
--- a/cms/templates/settings.html
+++ b/cms/templates/settings.html
@@ -70,17 +70,17 @@ from contentstore import utils
-
+
-
+
-
+
These are used in your course URL, and cannot be changed
diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html
index ceee406398..838af5ada9 100644
--- a/cms/templates/settings_advanced.html
+++ b/cms/templates/settings_advanced.html
@@ -21,7 +21,6 @@ $(document).ready(function () {
// proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern
var advancedModel = new CMS.Models.Settings.Advanced(${advanced_dict | n}, {parse: true});
- advancedModel.blacklistKeys = ${advanced_blacklist | n};
advancedModel.url = "${reverse('course_advanced_settings_updates', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}";
var editor = new CMS.Views.Settings.Advanced({
@@ -61,18 +60,11 @@ editor.render();
Manually Edit Course Policy Values (JSON Key / Value pairs)
-
Warning: Add only manual policy data that you are familiar
- with.
+
Warning: Do not modify these policies unless you are familiar with their purpose.
- %endfor
-
-
-
- + Add New Section
-
-
-
-
-
-
-
diff --git a/cms/templates/widgets/sequence-edit.html b/cms/templates/widgets/sequence-edit.html
index e9d796784d..c70f2568fa 100644
--- a/cms/templates/widgets/sequence-edit.html
+++ b/cms/templates/widgets/sequence-edit.html
@@ -40,7 +40,7 @@
${child.display_name}
+ data-preview-type="${child.module_class.js_module_name}">${child.display_name_with_default}
handle
%endfor
diff --git a/cms/templates/widgets/units.html b/cms/templates/widgets/units.html
index 8e23b05bf8..5ac05e79eb 100644
--- a/cms/templates/widgets/units.html
+++ b/cms/templates/widgets/units.html
@@ -22,7 +22,7 @@ This def will enumerate through a passed in subsection and list all of the units
@@ -39,7 +39,7 @@ This def will enumerate through a passed in subsection and list all of the units
-%def>
+%def>
diff --git a/cms/urls.py b/cms/urls.py
index d43b9bc44c..69ce4a540d 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -104,3 +104,9 @@ if settings.ENABLE_JASMINE:
urlpatterns = urlpatterns + (url(r'^_jasmine/', include('django_jasmine.urls')),)
urlpatterns = patterns(*urlpatterns)
+
+#Custom error pages
+handler404 = 'contentstore.views.render_404'
+handler500 = 'contentstore.views.render_500'
+
+
diff --git a/cms/xmodule_namespace.py b/cms/xmodule_namespace.py
new file mode 100644
index 0000000000..cad3110574
--- /dev/null
+++ b/cms/xmodule_namespace.py
@@ -0,0 +1,46 @@
+"""
+Namespace defining common fields used by Studio for all blocks
+"""
+
+import datetime
+
+from xblock.core import Namespace, Boolean, Scope, ModelType, String
+
+
+class StringyBoolean(Boolean):
+ """
+ Reads strings from JSON as booleans.
+
+ If the string is 'true' (case insensitive), then return True,
+ otherwise False.
+
+ JSON values that aren't strings are returned as is
+ """
+ def from_json(self, value):
+ if isinstance(value, basestring):
+ return value.lower() == 'true'
+ return value
+
+
+class DateTuple(ModelType):
+ """
+ ModelType that stores datetime objects as time tuples
+ """
+ def from_json(self, value):
+ return datetime.datetime(*value[0:6])
+
+ def to_json(self, value):
+ if value is None:
+ return None
+
+ return list(value.timetuple())
+
+
+class CmsNamespace(Namespace):
+ """
+ Namespace with fields common to all blocks in Studio
+ """
+ is_draft = Boolean(help="Whether this module is a draft", default=False, scope=Scope.settings)
+ published_date = DateTuple(help="Date when the module was published", scope=Scope.settings)
+ published_by = String(help="Id of the user who published this module", scope=Scope.settings)
+ empty = StringyBoolean(help="Whether this is an empty template", scope=Scope.settings, default=False)
diff --git a/common/djangoapps/__init__.py b/common/djangoapps/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/common/djangoapps/course_groups/tests/tests.py b/common/djangoapps/course_groups/tests/tests.py
index 88d9c1f508..94d52ff6df 100644
--- a/common/djangoapps/course_groups/tests/tests.py
+++ b/common/djangoapps/course_groups/tests/tests.py
@@ -76,7 +76,7 @@ class TestCohorts(django.test.TestCase):
"id": to_id(name)})
for name in discussions)
- course.metadata["discussion_topics"] = topics
+ course.discussion_topics = topics
d = {"cohorted": cohorted}
if cohorted_discussions is not None:
@@ -88,7 +88,7 @@ class TestCohorts(django.test.TestCase):
if auto_cohort_groups is not None:
d["auto_cohort_groups"] = auto_cohort_groups
- course.metadata["cohort_config"] = d
+ course.cohort_config = d
def setUp(self):
diff --git a/common/djangoapps/status/tests.py b/common/djangoapps/status/tests.py
index 1695663ac5..bf60017036 100644
--- a/common/djangoapps/status/tests.py
+++ b/common/djangoapps/status/tests.py
@@ -4,7 +4,7 @@ import os
from django.test.utils import override_settings
from tempfile import NamedTemporaryFile
-from status import get_site_status_msg
+from .status import get_site_status_msg
# Get a name where we can put test files
TMP_FILE = NamedTemporaryFile(delete=False)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 6ac57c1182..902ec82677 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -44,9 +44,8 @@ from collections import namedtuple
from courseware.courses import get_courses, sort_by_announcement
from courseware.access import has_access
-from courseware.models import StudentModuleCache
from courseware.views import get_module_for_descriptor, jump_to
-from courseware.module_render import get_instance_module
+from courseware.model_data import ModelDataCache
from statsd import statsd
@@ -318,7 +317,7 @@ def change_enrollment(request):
if not has_access(user, course, 'enroll'):
return {'success': False,
'error': 'enrollment in {} not allowed at this time'
- .format(course.display_name)}
+ .format(course.display_name_with_default)}
org, course_num, run = course_id.split("/")
statsd.increment("common.student.enrollment",
@@ -1071,14 +1070,14 @@ def accept_name_change(request):
@csrf_exempt
def test_center_login(request):
- # errors are returned by navigating to the error_url, adding a query parameter named "code"
+ # errors are returned by navigating to the error_url, adding a query parameter named "code"
# which contains the error code describing the exceptional condition.
def makeErrorURL(error_url, error_code):
log.error("generating error URL with error code {}".format(error_code))
return "{}?code={}".format(error_url, error_code);
-
+
# get provided error URL, which will be used as a known prefix for returning error messages to the
- # Pearson shell.
+ # Pearson shell.
error_url = request.POST.get("errorURL")
# TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson
@@ -1089,12 +1088,12 @@ def test_center_login(request):
# calculate SHA for query string
# TODO: figure out how to get the original query string, so we can hash it and compare.
-
-
+
+
if 'clientCandidateID' not in request.POST:
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID"));
client_candidate_id = request.POST.get("clientCandidateID")
-
+
# TODO: check remaining parameters, and maybe at least log if they're not matching
# expected values....
# registration_id = request.POST.get("registrationID")
@@ -1108,12 +1107,12 @@ def test_center_login(request):
return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID"));
# find testcenter_registration that matches the provided exam code:
- # Note that we could rely in future on either the registrationId or the exam code,
- # or possibly both. But for now we know what to do with an ExamSeriesCode,
+ # Note that we could rely in future on either the registrationId or the exam code,
+ # or possibly both. But for now we know what to do with an ExamSeriesCode,
# while we currently have no record of RegistrationID values at all.
if 'vueExamSeriesCode' not in request.POST:
- # we are not allowed to make up a new error code, according to Pearson,
- # so instead of "missingExamSeriesCode", we use a valid one that is
+ # we are not allowed to make up a new error code, according to Pearson,
+ # so instead of "missingExamSeriesCode", we use a valid one that is
# inaccurate but at least distinct. (Sigh.)
log.error("missing exam series code for cand ID {}".format(client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID"));
@@ -1127,11 +1126,11 @@ def test_center_login(request):
if not registrations:
log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned"));
-
+
# TODO: figure out what to do if there are more than one registrations....
# for now, just take the first...
registration = registrations[0]
-
+
course_id = registration.course_id
course = course_from_id(course_id) # assume it will be found....
if not course:
@@ -1149,19 +1148,19 @@ def test_center_login(request):
if not timelimit_descriptor:
log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location))
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"));
-
- timelimit_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, testcenteruser.user,
- timelimit_descriptor, depth=None)
- timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor,
+
+ timelimit_module_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user,
+ timelimit_descriptor, depth=None)
+ timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor,
timelimit_module_cache, course_id, position=None)
if not timelimit_module.category == 'timelimit':
log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location))
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"));
-
+
if timelimit_module and timelimit_module.has_ended:
log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at))
return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken"));
-
+
# check if we need to provide an accommodation:
time_accommodation_mapping = {'ET12ET' : 'ADDHALFTIME',
'ET30MN' : 'ADD30MIN',
@@ -1174,27 +1173,24 @@ def test_center_login(request):
# special, hard-coded client ID used by Pearson shell for testing:
if client_candidate_id == "edX003671291147":
time_accommodation_code = 'TESTING'
-
+
if time_accommodation_code:
timelimit_module.accommodation_code = time_accommodation_code
- instance_module = get_instance_module(course_id, testcenteruser.user, timelimit_module, timelimit_module_cache)
- instance_module.state = timelimit_module.get_instance_state()
- instance_module.save()
log.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code))
-
+
# UGLY HACK!!!
- # Login assumes that authentication has occurred, and that there is a
+ # Login assumes that authentication has occurred, and that there is a
# backend annotation on the user object, indicating which backend
# against which the user was authenticated. We're authenticating here
# against the registration entry, and assuming that the request given
# this information is correct, we allow the user to be logged in
# without a password. This could all be formalized in a backend object
- # that does the above checking.
+ # that does the above checking.
# TODO: (brian) create a backend class to do this.
- # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__)
- testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass")
+ # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__)
+ testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass")
login(request, testcenteruser.user)
-
+
# And start the test:
return jump_to(request, course_id, location)
diff --git a/common/djangoapps/terrain/browser.py b/common/djangoapps/terrain/browser.py
index 0881d86124..6394959532 100644
--- a/common/djangoapps/terrain/browser.py
+++ b/common/djangoapps/terrain/browser.py
@@ -3,6 +3,11 @@ from splinter.browser import Browser
from logging import getLogger
import time
+# Let the LMS and CMS do their one-time setup
+# For example, setting up mongo caches
+from lms import one_time_startup
+from cms import one_time_startup
+
logger = getLogger(__name__)
logger.info("Loading the lettuce acceptance testing terrain file...")
diff --git a/common/djangoapps/terrain/factories.py b/common/djangoapps/terrain/factories.py
index bb7ae012c8..c36bf935f1 100644
--- a/common/djangoapps/terrain/factories.py
+++ b/common/djangoapps/terrain/factories.py
@@ -7,13 +7,14 @@ from xmodule.modulestore.django import modulestore
from time import gmtime
from uuid import uuid4
from xmodule.timeparse import stringify_time
+from xmodule.modulestore.inheritance import own_metadata
class GroupFactory(Factory):
FACTORY_FOR = Group
name = 'staff_MITx/999/Robot_Super_Course'
-
+
class UserProfileFactory(Factory):
FACTORY_FOR = UserProfile
@@ -81,18 +82,17 @@ class XModuleCourseFactory(Factory):
# This metadata code was copied from cms/djangoapps/contentstore/views.py
if display_name is not None:
- new_course.metadata['display_name'] = display_name
+ new_course.display_name = display_name
- new_course.metadata['data_dir'] = uuid4().hex
- new_course.metadata['start'] = stringify_time(gmtime())
+ new_course.lms.start = gmtime()
new_course.tabs = [{"type": "courseware"},
- {"type": "course_info", "name": "Course Info"},
+ {"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"},
{"type": "progress", "name": "Progress"}]
# Update the data in the mongo datastore
- store.update_metadata(new_course.location.url(), new_course.own_metadata)
+ store.update_metadata(new_course.location.url(), own_metadata(new_course))
return new_course
@@ -121,35 +121,59 @@ class XModuleItemFactory(Factory):
@classmethod
def _create(cls, target_class, *args, **kwargs):
"""
- kwargs must include parent_location, template. Can contain display_name
- target_class is ignored
+ Uses *kwargs*:
+
+ *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)
+
+ *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
+
+ *metadata* (optional): dictionary of metadata attributes
+
+ *target_class* is ignored
"""
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
parent_location = Location(kwargs.get('parent_location'))
template = Location(kwargs.get('template'))
+ data = kwargs.get('data')
display_name = kwargs.get('display_name')
+ metadata = kwargs.get('metadata', {})
store = modulestore('direct')
# This code was based off that in cms/djangoapps/contentstore/views.py
parent = store.get_item(parent_location)
- dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
+
+ # If a display name is set, use that
+ dest_name = display_name.replace(" ", "_") if display_name is not None else uuid4().hex
+ dest_location = parent_location._replace(category=template.category,
+ name=dest_name)
new_item = store.clone_item(template, dest_location)
- # TODO: This needs to be deleted when we have proper storage for static content
- new_item.metadata['data_dir'] = parent.metadata['data_dir']
-
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
- new_item.metadata['display_name'] = display_name
+ new_item.display_name = display_name
- store.update_metadata(new_item.location.url(), new_item.own_metadata)
+ # 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)
+
+ # 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.definition.get('children', []) + [new_item.location.url()])
+ store.update_children(parent_location, parent.children + [new_item.location.url()])
return new_item
diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py
index 88fba697b2..52eeb23c4a 100644
--- a/common/djangoapps/terrain/steps.py
+++ b/common/djangoapps/terrain/steps.py
@@ -1,5 +1,5 @@
from lettuce import world, step
-from factories import *
+from .factories import *
from lettuce.django import django_url
from django.contrib.auth.models import User
from student.models import CourseEnrollment
@@ -69,6 +69,11 @@ def the_page_title_should_be(step, title):
assert_equals(world.browser.title, title)
+@step(u'the page title should contain "([^"]*)"$')
+def the_page_title_should_contain(step, title):
+ assert(title in world.browser.title)
+
+
@step('I am a logged in user$')
def i_am_logged_in_user(step):
create_user('robot')
@@ -80,18 +85,6 @@ def i_am_not_logged_in(step):
world.browser.cookies.delete()
-@step('I am registered for a course$')
-def i_am_registered_for_a_course(step):
- create_user('robot')
- u = User.objects.get(username='robot')
- CourseEnrollment.objects.get_or_create(user=u, course_id='MITx/6.002x/2012_Fall')
-
-
-@step('I am registered for course "([^"]*)"$')
-def i_am_registered_for_course_by_id(step, course_id):
- register_by_course_id(course_id)
-
-
@step('I am staff for course "([^"]*)"$')
def i_am_staff_for_course_by_id(step, course_id):
register_by_course_id(course_id, True)
@@ -108,6 +101,7 @@ def i_am_an_edx_user(step):
#### helper functions
+
@world.absorb
def scroll_to_bottom():
# Maximize the browser
@@ -116,6 +110,11 @@ def scroll_to_bottom():
@world.absorb
def create_user(uname):
+
+ # If the user already exists, don't try to create it again
+ if len(User.objects.filter(username=uname)) > 0:
+ return
+
portal_user = UserFactory.build(username=uname, email=uname + '@edx.org')
portal_user.set_password('test')
portal_user.save()
@@ -133,13 +132,25 @@ def log_in(email, password):
world.browser.visit(django_url('/'))
world.browser.is_element_present_by_css('header.global', 10)
world.browser.click_link_by_href('#login-modal')
- login_form = world.browser.find_by_css('form#login_form')
+
+ # Wait for the login dialog to load
+ # This is complicated by the fact that sometimes a second #login_form
+ # dialog loads, while the first one remains hidden.
+ # We give them both time to load, starting with the second one.
+ world.browser.is_element_present_by_css('section.content-wrapper form#login_form', wait_time=4)
+ world.browser.is_element_present_by_css('form#login_form', wait_time=2)
+
+ # For some reason, the page sometimes includes two #login_form
+ # elements, the first of which is not visible.
+ # To avoid this, we always select the last of the two #login_form dialogs
+ login_form = world.browser.find_by_css('form#login_form').last
+
login_form.find_by_name('email').fill(email)
login_form.find_by_name('password').fill(password)
login_form.find_by_name('submit').click()
# wait for the page to redraw
- assert world.browser.is_element_present_by_css('.content-wrapper', 10)
+ assert world.browser.is_element_present_by_css('.content-wrapper', wait_time=10)
@world.absorb
diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py
index 7b19c27553..d398dfef0d 100644
--- a/common/djangoapps/xmodule_modifiers.py
+++ b/common/djangoapps/xmodule_modifiers.py
@@ -33,7 +33,7 @@ def wrap_xmodule(get_html, module, template, context=None):
def _get_html():
context.update({
'content': get_html(),
- 'display_name': module.metadata.get('display_name') if module.metadata is not None else None,
+ 'display_name': module.display_name,
'class_': module.__class__.__name__,
'module_name': module.js_module_name
})
@@ -108,42 +108,25 @@ def add_histogram(get_html, module, user):
histogram = grade_histogram(module_id)
render_histogram = len(histogram) > 0
- # TODO (ichuang): Remove after fall 2012 LMS migration done
- if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'):
- [filepath, filename] = module.definition.get('filename', ['', None])
- osfs = module.system.filestore
- if filename is not None and osfs.exists(filename):
- # if original, unmangled filename exists then use it (github
- # doesn't like symlinks)
- filepath = filename
- data_dir = osfs.root_path.rsplit('/')[-1]
- giturl = module.metadata.get('giturl', 'https://github.com/MITx')
- edit_link = "%s/%s/tree/master/%s" % (giturl, data_dir, filepath)
- else:
- edit_link = False
- # Need to define all the variables that are about to be used
- giturl = ""
- data_dir = ""
- source_file = module.metadata.get('source_file', '') # source used to generate the problem XML, eg latex or word
+ source_file = module.lms.source_file # source used to generate the problem XML, eg latex or word
# useful to indicate to staff if problem has been released or not
# TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here
now = time.gmtime()
is_released = "unknown"
- mstart = getattr(module.descriptor, 'start')
+ mstart = module.descriptor.lms.start
+
if mstart is not None:
is_released = "Yes!" if (now > mstart) else "Not yet"
- staff_context = {'definition': module.definition.get('data'),
- 'metadata': json.dumps(module.metadata, indent=4),
+ staff_context = {'fields': [(field.name, getattr(module, field.name)) for field in module.fields],
+ 'lms_fields': [(field.name, getattr(module.lms, field.name)) for field in module.lms.fields],
'location': module.location,
- 'xqa_key': module.metadata.get('xqa_key', ''),
+ 'xqa_key': module.lms.xqa_key,
'source_file': source_file,
- 'source_url': '%s/%s/tree/master/%s' % (giturl, data_dir, source_file),
'category': str(module.__class__.__name__),
# Template uses element_id in js function names, so can't allow dashes
'element_id': module.location.html_id().replace('-', '_'),
- 'edit_link': edit_link,
'user': user,
'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa'),
'histogram': json.dumps(histogram),
diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py
index 8b32686985..42753fc90b 100644
--- a/common/lib/capa/capa/capa_problem.py
+++ b/common/lib/capa/capa/capa_problem.py
@@ -39,11 +39,11 @@ import verifiers
import verifiers.draganddrop
import calc
-from correctmap import CorrectMap
+from .correctmap import CorrectMap
import eia
import inputtypes
import customrender
-from util import contextualize_text, convert_files_to_filenames
+from .util import contextualize_text, convert_files_to_filenames
import xqueue_interface
# to be replaced with auto-registering
@@ -78,7 +78,7 @@ global_context = {'random': random,
# These should be removed from HTML output, including all subelements
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam", "openendedrubric"]
-log = logging.getLogger('mitx.' + __name__)
+log = logging.getLogger(__name__)
#-----------------------------------------------------------------------------
# main class for this module
@@ -108,6 +108,8 @@ class LoncapaProblem(object):
self.do_reset()
self.problem_id = id
self.system = system
+ if self.system is None:
+ raise Exception()
self.seed = seed
if state:
diff --git a/common/lib/capa/capa/checker.py b/common/lib/capa/capa/checker.py
index f583a5ea7d..15358aac9e 100755
--- a/common/lib/capa/capa/checker.py
+++ b/common/lib/capa/capa/checker.py
@@ -12,8 +12,8 @@ from path import path
from cStringIO import StringIO
from collections import defaultdict
-from calc import UndefinedVariable
-from capa_problem import LoncapaProblem
+from .calc import UndefinedVariable
+from .capa_problem import LoncapaProblem
from mako.lookup import TemplateLookup
logging.basicConfig(format="%(levelname)s %(message)s")
diff --git a/common/lib/capa/capa/chem/tests.py b/common/lib/capa/capa/chem/tests.py
index 571526f915..f422fcf0d1 100644
--- a/common/lib/capa/capa/chem/tests.py
+++ b/common/lib/capa/capa/chem/tests.py
@@ -2,7 +2,7 @@ import codecs
from fractions import Fraction
import unittest
-from chemcalc import (compare_chemical_expression, divide_chemical_expression,
+from .chemcalc import (compare_chemical_expression, divide_chemical_expression,
render_to_html, chemical_equations_equal)
import miller
@@ -277,7 +277,6 @@ class Test_Render_Equations(unittest.TestCase):
def test_render9(self):
s = "5[Ni(NH3)4]^2+ + 5/2SO4^2-"
- #import ipdb; ipdb.set_trace()
out = render_to_html(s)
correct = u'5[Ni(NH3)4]2++5⁄2SO42-'
log(out + ' ------- ' + correct, 'html')
diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py
index ea56863a2f..b726f765d8 100644
--- a/common/lib/capa/capa/correctmap.py
+++ b/common/lib/capa/capa/correctmap.py
@@ -47,7 +47,7 @@ class CorrectMap(object):
queuestate=None, **kwargs):
if answer_id is not None:
- self.cmap[answer_id] = {'correctness': correctness,
+ self.cmap[str(answer_id)] = {'correctness': correctness,
'npoints': npoints,
'msg': msg,
'hint': hint,
diff --git a/common/lib/capa/capa/customrender.py b/common/lib/capa/capa/customrender.py
index a925a5970d..60d3ce578b 100644
--- a/common/lib/capa/capa/customrender.py
+++ b/common/lib/capa/capa/customrender.py
@@ -6,7 +6,7 @@ These tags do not have state, so they just get passed the system (for access to
and the xml element.
"""
-from registry import TagRegistry
+from .registry import TagRegistry
import logging
import re
@@ -15,9 +15,9 @@ import json
from lxml import etree
import xml.sax.saxutils as saxutils
-from registry import TagRegistry
+from .registry import TagRegistry
-log = logging.getLogger('mitx.' + __name__)
+log = logging.getLogger(__name__)
registry = TagRegistry()
diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index 9781f10ae6..c2babfa479 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -47,10 +47,10 @@ import sys
import os
import pyparsing
-from registry import TagRegistry
+from .registry import TagRegistry
from capa.chem import chemcalc
-log = logging.getLogger('mitx.' + __name__)
+log = logging.getLogger(__name__)
#########################################################################
@@ -857,6 +857,10 @@ class DragAndDropInput(InputTypeBase):
if tag_type == 'draggable' and not self.no_labels:
dic['label'] = dic['label'] or dic['id']
+ if tag_type == 'draggable':
+ dic['target_fields'] = [parse(target, 'target') for target in
+ tag.iterchildren('target')]
+
return dic
# add labels to images?:
diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py
index d49d030df5..6bf98999d8 100644
--- a/common/lib/capa/capa/responsetypes.py
+++ b/common/lib/capa/capa/responsetypes.py
@@ -28,15 +28,15 @@ from collections import namedtuple
from shapely.geometry import Point, MultiPoint
# specific library imports
-from calc import evaluator, UndefinedVariable
-from correctmap import CorrectMap
+from .calc import evaluator, UndefinedVariable
+from .correctmap import CorrectMap
from datetime import datetime
-from util import *
+from .util import *
from lxml import etree
from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME?
import xqueue_interface
-log = logging.getLogger('mitx.' + __name__)
+log = logging.getLogger(__name__)
#-----------------------------------------------------------------------------
@@ -231,16 +231,14 @@ class LoncapaResponse(object):
# hint specified by function?
hintfn = hintgroup.get('hintfn')
if hintfn:
- '''
- Hint is determined by a function defined in the
'''
- snippets = [{'snippet': """
+ snippets = [{'snippet': r"""
Suppose that \(I(t)\) rises from \(0\) to \(I_S\) at a time \(t_0 \neq 0\)
@@ -1104,7 +1102,7 @@ def sympy_check2():
# the form:
# {'overall_message': STRING,
# 'input_list': [{ 'ok': BOOLEAN, 'msg': STRING }, ...] }
- #
+ #
# This allows the function to return an 'overall message'
# that applies to the entire problem, as well as correct/incorrect
# status and messages for individual inputs
@@ -1197,7 +1195,7 @@ class SymbolicResponse(CustomResponse):
"""
Symbolic math response checking, using symmath library.
"""
- snippets = [{'snippet': '''
+ snippets = [{'snippet': r'''Compute \[ \exp\left(-i \frac{\theta}{2} \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) \]
and give the resulting \(2\times 2\) matrix:
@@ -1988,7 +1986,7 @@ class AnnotationResponse(LoncapaResponse):
for inputfield in self.inputfields:
option_scoring = dict([(option['id'], {
- 'correctness': choices.get(option['choice']),
+ 'correctness': choices.get(option['choice']),
'points': scoring.get(option['choice'])
}) for option in self._find_options(inputfield) ])
diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py
index 7aa299d20d..aa401b70cd 100644
--- a/common/lib/capa/capa/tests/response_xml_factory.py
+++ b/common/lib/capa/capa/tests/response_xml_factory.py
@@ -1,6 +1,7 @@
from lxml import etree
from abc import ABCMeta, abstractmethod
+
class ResponseXMLFactory(object):
""" Abstract base class for capa response XML factories.
Subclasses override create_response_element and
@@ -13,7 +14,7 @@ class ResponseXMLFactory(object):
""" Subclasses override to return an etree element
representing the capa response XML
(e.g. ).
-
+
The tree should NOT contain any input elements
(such as ) as these will be added later."""
return None
@@ -25,7 +26,7 @@ class ResponseXMLFactory(object):
return None
def build_xml(self, **kwargs):
- """ Construct an XML string for a capa response
+ """ Construct an XML string for a capa response
based on **kwargs.
**kwargs is a dictionary that will be passed
@@ -37,7 +38,7 @@ class ResponseXMLFactory(object):
*question_text*: The text of the question to display,
wrapped in
tags.
-
+
*explanation_text*: The detailed explanation that will
be shown if the user answers incorrectly.
@@ -75,7 +76,7 @@ class ResponseXMLFactory(object):
for i in range(0, int(num_responses)):
response_element = self.create_response_element(**kwargs)
root.append(response_element)
-
+
# Add input elements
for j in range(0, int(num_inputs)):
input_element = self.create_input_element(**kwargs)
@@ -135,7 +136,7 @@ class ResponseXMLFactory(object):
# Names of group elements
group_element_names = {'checkbox': 'checkboxgroup',
'radio': 'radiogroup',
- 'multiple': 'choicegroup' }
+ 'multiple': 'choicegroup'}
# Retrieve **kwargs
choices = kwargs.get('choices', [True])
@@ -151,13 +152,11 @@ class ResponseXMLFactory(object):
choice_element = etree.SubElement(group_element, "choice")
choice_element.set("correct", "true" if correct_val else "false")
- # Add some text describing the choice
- etree.SubElement(choice_element, "startouttext")
- etree.text = "Choice description"
- etree.SubElement(choice_element, "endouttext")
-
# Add a name identifying the choice, if one exists
+ # For simplicity, we use the same string as both the
+ # name attribute and the text of the element
if name:
+ choice_element.text = str(name)
choice_element.set("name", str(name))
return group_element
@@ -217,7 +216,7 @@ class CustomResponseXMLFactory(ResponseXMLFactory):
*answer*: Inline script that calculates the answer
"""
-
+
# Retrieve **kwargs
cfn = kwargs.get('cfn', None)
expect = kwargs.get('expect', None)
@@ -247,7 +246,7 @@ class SchematicResponseXMLFactory(ResponseXMLFactory):
def create_response_element(self, **kwargs):
""" Create the XML element.
-
+
Uses *kwargs*:
*answer*: The Python script used to evaluate the answer.
@@ -274,6 +273,7 @@ class SchematicResponseXMLFactory(ResponseXMLFactory):
For testing, we create a bare-bones version of ."""
return etree.Element("schematic")
+
class CodeResponseXMLFactory(ResponseXMLFactory):
""" Factory for creating XML trees """
@@ -286,9 +286,9 @@ class CodeResponseXMLFactory(ResponseXMLFactory):
def create_response_element(self, **kwargs):
""" Create a XML element:
-
+
Uses **kwargs:
-
+
*initial_display*: The code that initially appears in the textbox
[DEFAULT: "Enter code here"]
*answer_display*: The answer to display to the student
@@ -328,6 +328,7 @@ class CodeResponseXMLFactory(ResponseXMLFactory):
# return None here
return None
+
class ChoiceResponseXMLFactory(ResponseXMLFactory):
""" Factory for creating XML trees """
@@ -356,13 +357,13 @@ class FormulaResponseXMLFactory(ResponseXMLFactory):
*num_samples*: The number of times to sample the student's answer
to numerically compare it to the correct answer.
-
+
*tolerance*: The tolerance within which answers will be accepted
- [DEFAULT: 0.01]
+ [DEFAULT: 0.01]
*answer*: The answer to the problem. Can be a formula string
- or a Python variable defined in a script
- (e.g. "$calculated_answer" for a Python variable
+ or a Python variable defined in a script
+ (e.g. "$calculated_answer" for a Python variable
called calculated_answer)
[REQUIRED]
@@ -387,7 +388,7 @@ class FormulaResponseXMLFactory(ResponseXMLFactory):
# Set the sample information
sample_str = self._sample_str(sample_dict, num_samples, tolerance)
response_element.set("samples", sample_str)
-
+
# Set the tolerance
responseparam_element = etree.SubElement(response_element, "responseparam")
@@ -408,7 +409,7 @@ class FormulaResponseXMLFactory(ResponseXMLFactory):
# We could sample a different range, but for simplicity,
# we use the same sample string for the hints
- # that we used previously.
+ # that we used previously.
formulahint_element.set("samples", sample_str)
formulahint_element.set("answer", str(hint_prompt))
@@ -436,10 +437,11 @@ class FormulaResponseXMLFactory(ResponseXMLFactory):
high_range_vals = [str(f[1]) for f in sample_dict.values()]
sample_str = (",".join(sample_dict.keys()) + "@" +
",".join(low_range_vals) + ":" +
- ",".join(high_range_vals) +
+ ",".join(high_range_vals) +
"#" + str(num_samples))
return sample_str
+
class ImageResponseXMLFactory(ResponseXMLFactory):
""" Factory for producing XML """
@@ -450,9 +452,9 @@ class ImageResponseXMLFactory(ResponseXMLFactory):
def create_input_element(self, **kwargs):
""" Create the element.
-
+
Uses **kwargs:
-
+
*src*: URL for the image file [DEFAULT: "/static/image.jpg"]
*width*: Width of the image [DEFAULT: 100]
@@ -490,7 +492,7 @@ class ImageResponseXMLFactory(ResponseXMLFactory):
input_element.set("src", str(src))
input_element.set("width", str(width))
input_element.set("height", str(height))
-
+
if rectangle:
input_element.set("rectangle", rectangle)
@@ -499,6 +501,7 @@ class ImageResponseXMLFactory(ResponseXMLFactory):
return input_element
+
class JavascriptResponseXMLFactory(ResponseXMLFactory):
""" Factory for producing XML """
@@ -522,7 +525,7 @@ class JavascriptResponseXMLFactory(ResponseXMLFactory):
# Both display_src and display_class given,
# or neither given
- assert((display_src and display_class) or
+ assert((display_src and display_class) or
(not display_src and not display_class))
# Create the element
@@ -552,6 +555,7 @@ class JavascriptResponseXMLFactory(ResponseXMLFactory):
""" Create the element """
return etree.Element("javascriptinput")
+
class MultipleChoiceResponseXMLFactory(ResponseXMLFactory):
""" Factory for producing XML """
@@ -564,6 +568,7 @@ class MultipleChoiceResponseXMLFactory(ResponseXMLFactory):
kwargs['choice_type'] = 'multiple'
return ResponseXMLFactory.choicegroup_input_xml(**kwargs)
+
class TrueFalseResponseXMLFactory(ResponseXMLFactory):
""" Factory for producing XML """
@@ -576,6 +581,7 @@ class TrueFalseResponseXMLFactory(ResponseXMLFactory):
kwargs['choice_type'] = 'multiple'
return ResponseXMLFactory.choicegroup_input_xml(**kwargs)
+
class OptionResponseXMLFactory(ResponseXMLFactory):
""" Factory for producing XML"""
@@ -620,7 +626,7 @@ class StringResponseXMLFactory(ResponseXMLFactory):
def create_response_element(self, **kwargs):
""" Create a XML element.
-
+
Uses **kwargs:
*answer*: The correct answer (a string) [REQUIRED]
@@ -642,7 +648,7 @@ class StringResponseXMLFactory(ResponseXMLFactory):
# Create the element
response_element = etree.Element("stringresponse")
- # Set the answer attribute
+ # Set the answer attribute
response_element.set("answer", str(answer))
# Set the case sensitivity
@@ -667,6 +673,7 @@ class StringResponseXMLFactory(ResponseXMLFactory):
def create_input_element(self, **kwargs):
return ResponseXMLFactory.textline_input_xml(**kwargs)
+
class AnnotationResponseXMLFactory(ResponseXMLFactory):
""" Factory for creating XML trees """
def create_response_element(self, **kwargs):
@@ -679,17 +686,17 @@ class AnnotationResponseXMLFactory(ResponseXMLFactory):
input_element = etree.Element("annotationinput")
text_children = [
- {'tag': 'title', 'text': kwargs.get('title', 'super cool annotation') },
- {'tag': 'text', 'text': kwargs.get('text', 'texty text') },
- {'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah') },
- {'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below') },
- {'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag') }
+ {'tag': 'title', 'text': kwargs.get('title', 'super cool annotation')},
+ {'tag': 'text', 'text': kwargs.get('text', 'texty text')},
+ {'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah')},
+ {'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below')},
+ {'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag')}
]
for child in text_children:
etree.SubElement(input_element, child['tag']).text = child['text']
- default_options = [('green', 'correct'),('eggs', 'incorrect'),('ham', 'partially-correct')]
+ default_options = [('green', 'correct'),('eggs', 'incorrect'), ('ham', 'partially-correct')]
options = kwargs.get('options', default_options)
options_element = etree.SubElement(input_element, 'options')
@@ -698,4 +705,3 @@ class AnnotationResponseXMLFactory(ResponseXMLFactory):
option_element.text = description
return input_element
-
diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py
index 6c74d06ef4..e99308587e 100644
--- a/common/lib/capa/capa/tests/test_html_render.py
+++ b/common/lib/capa/capa/tests/test_html_render.py
@@ -7,7 +7,7 @@ import json
import mock
from capa.capa_problem import LoncapaProblem
-from response_xml_factory import StringResponseXMLFactory, CustomResponseXMLFactory
+from .response_xml_factory import StringResponseXMLFactory, CustomResponseXMLFactory
from . import test_system
class CapaHtmlRenderTest(unittest.TestCase):
diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py
index 287caad28f..360fd9f2f6 100644
--- a/common/lib/capa/capa/tests/test_inputtypes.py
+++ b/common/lib/capa/capa/tests/test_inputtypes.py
@@ -557,14 +557,14 @@ class DragAndDropTest(unittest.TestCase):
"target_outline": "false",
"base_image": "/static/images/about_1.png",
"draggables": [
-{"can_reuse": "", "label": "Label 1", "id": "1", "icon": ""},
-{"can_reuse": "", "label": "cc", "id": "name_with_icon", "icon": "/static/images/cc.jpg", },
-{"can_reuse": "", "label": "arrow-left", "id": "with_icon", "icon": "/static/images/arrow-left.png", "can_reuse": ""},
-{"can_reuse": "", "label": "Label2", "id": "5", "icon": "", "can_reuse": ""},
-{"can_reuse": "", "label": "Mute", "id": "2", "icon": "/static/images/mute.png", "can_reuse": ""},
-{"can_reuse": "", "label": "spinner", "id": "name_label_icon3", "icon": "/static/images/spinner.gif", "can_reuse": ""},
-{"can_reuse": "", "label": "Star", "id": "name4", "icon": "/static/images/volume.png", "can_reuse": ""},
-{"can_reuse": "", "label": "Label3", "id": "7", "icon": "", "can_reuse": ""}],
+{"can_reuse": "", "label": "Label 1", "id": "1", "icon": "", "target_fields": []},
+{"can_reuse": "", "label": "cc", "id": "name_with_icon", "icon": "/static/images/cc.jpg", "target_fields": []},
+{"can_reuse": "", "label": "arrow-left", "id": "with_icon", "icon": "/static/images/arrow-left.png", "can_reuse": "", "target_fields": []},
+{"can_reuse": "", "label": "Label2", "id": "5", "icon": "", "can_reuse": "", "target_fields": []},
+{"can_reuse": "", "label": "Mute", "id": "2", "icon": "/static/images/mute.png", "can_reuse": "", "target_fields": []},
+{"can_reuse": "", "label": "spinner", "id": "name_label_icon3", "icon": "/static/images/spinner.gif", "can_reuse": "", "target_fields": []},
+{"can_reuse": "", "label": "Star", "id": "name4", "icon": "/static/images/volume.png", "can_reuse": "", "target_fields": []},
+{"can_reuse": "", "label": "Label3", "id": "7", "icon": "", "can_reuse": "", "target_fields": []}],
"one_per_target": "True",
"targets": [
{"y": "90", "x": "210", "id": "t1", "w": "90", "h": "90"},
diff --git a/common/lib/capa/capa/util.py b/common/lib/capa/capa/util.py
index a0f25c4947..9f3e8bd3a0 100644
--- a/common/lib/capa/capa/util.py
+++ b/common/lib/capa/capa/util.py
@@ -1,4 +1,4 @@
-from calc import evaluator, UndefinedVariable
+from .calc import evaluator, UndefinedVariable
#-----------------------------------------------------------------------------
#
diff --git a/common/lib/capa/capa/verifiers/draganddrop.py b/common/lib/capa/capa/verifiers/draganddrop.py
index 239ff2b9a4..cdfa163f33 100644
--- a/common/lib/capa/capa/verifiers/draganddrop.py
+++ b/common/lib/capa/capa/verifiers/draganddrop.py
@@ -27,6 +27,49 @@ values are (x,y) coordinates of centers of dragged images.
import json
+def flat_user_answer(user_answer):
+ """
+ Convert nested `user_answer` to flat format.
+
+ {'up': {'first': {'p': 'p_l'}}}
+
+ to
+
+ {'up': 'p_l[p][first]'}
+ """
+
+ def parse_user_answer(answer):
+ key = answer.keys()[0]
+ value = answer.values()[0]
+ if isinstance(value, dict):
+
+ # Make complex value:
+ # Example:
+ # Create like 'p_l[p][first]' from {'first': {'p': 'p_l'}
+ complex_value_list = []
+ v_value = value
+ while isinstance(v_value, dict):
+ v_key = v_value.keys()[0]
+ v_value = v_value.values()[0]
+ complex_value_list.append(v_key)
+
+ complex_value = '{0}'.format(v_value)
+ for i in reversed(complex_value_list):
+ complex_value = '{0}[{1}]'.format(complex_value, i)
+
+ res = {key: complex_value}
+ return res
+ else:
+ return answer
+
+ result = []
+ for answer in user_answer:
+ parse_answer = parse_user_answer(answer)
+ result.append(parse_answer)
+
+ return result
+
+
class PositionsCompare(list):
""" Class for comparing positions.
@@ -116,37 +159,36 @@ class DragAndDrop(object):
# Number of draggables in user_groups may be differ that in
# correct_groups, that is incorrect, except special case with 'number'
- for groupname, draggable_ids in self.correct_groups.items():
-
+ for index, draggable_ids in enumerate(self.correct_groups):
# 'number' rule special case
# for reusable draggables we may get in self.user_groups
# {'1': [u'2', u'2', u'2'], '0': [u'1', u'1'], '2': [u'3']}
# if '+number' is in rule - do not remove duplicates and strip
# '+number' from rule
- current_rule = self.correct_positions[groupname].keys()[0]
+ current_rule = self.correct_positions[index].keys()[0]
if 'number' in current_rule:
- rule_values = self.correct_positions[groupname][current_rule]
+ rule_values = self.correct_positions[index][current_rule]
# clean rule, do not do clean duplicate items
- self.correct_positions[groupname].pop(current_rule, None)
+ self.correct_positions[index].pop(current_rule, None)
parsed_rule = current_rule.replace('+', '').replace('number', '')
- self.correct_positions[groupname][parsed_rule] = rule_values
+ self.correct_positions[index][parsed_rule] = rule_values
else: # remove dublicates
- self.user_groups[groupname] = list(set(self.user_groups[groupname]))
+ self.user_groups[index] = list(set(self.user_groups[index]))
- if sorted(draggable_ids) != sorted(self.user_groups[groupname]):
+ if sorted(draggable_ids) != sorted(self.user_groups[index]):
return False
# Check that in every group, for rule of that group, user positions of
# every element are equal with correct positions
- for groupname in self.correct_groups:
+ for index, _ in enumerate(self.correct_groups):
rules_executed = 0
for rule in ('exact', 'anyof', 'unordered_equal'):
# every group has only one rule
- if self.correct_positions[groupname].get(rule, None):
+ if self.correct_positions[index].get(rule, None):
rules_executed += 1
if not self.compare_positions(
- self.correct_positions[groupname][rule],
- self.user_positions[groupname]['user'], flag=rule):
+ self.correct_positions[index][rule],
+ self.user_positions[index]['user'], flag=rule):
return False
if not rules_executed: # no correct rules for current group
# probably xml content mistake - wrong rules names
@@ -248,7 +290,7 @@ class DragAndDrop(object):
correct_answer = {'name4': 't1',
'name_with_icon': 't1',
'5': 't2',
- '7':'t2'}
+ '7': 't2'}
It is draggable_name: dragable_position mapping.
@@ -284,24 +326,25 @@ class DragAndDrop(object):
Args:
user_answer: json
- correct_answer: dict or list
+ correct_answer: dict or list
"""
- self.correct_groups = dict() # correct groups from xml
- self.correct_positions = dict() # correct positions for comparing
- self.user_groups = dict() # will be populated from user answer
- self.user_positions = dict() # will be populated from user answer
+ self.correct_groups = [] # Correct groups from xml.
+ self.correct_positions = [] # Correct positions for comparing.
+ self.user_groups = [] # Will be populated from user answer.
+ self.user_positions = [] # Will be populated from user answer.
- # convert from dict answer format to list format
+ # Convert from dict answer format to list format.
if isinstance(correct_answer, dict):
tmp = []
for key, value in correct_answer.items():
- tmp_dict = {'draggables': [], 'targets': [], 'rule': 'exact'}
- tmp_dict['draggables'].append(key)
- tmp_dict['targets'].append(value)
- tmp.append(tmp_dict)
+ tmp.append({
+ 'draggables': [key],
+ 'targets': [value],
+ 'rule': 'exact'})
correct_answer = tmp
+ # Convert string `user_answer` to object.
user_answer = json.loads(user_answer)
# This dictionary will hold a key for each draggable the user placed on
@@ -309,27 +352,32 @@ class DragAndDrop(object):
# correct_answer entries. If the draggable is mentioned in at least one
# correct_answer entry, the value is False.
# default to consider every user answer excess until proven otherwise.
- self.excess_draggables = dict((users_draggable.keys()[0],True)
- for users_draggable in user_answer['draggables'])
+ self.excess_draggables = dict((users_draggable.keys()[0],True)
+ for users_draggable in user_answer)
- # create identical data structures from user answer and correct answer
- for i in xrange(0, len(correct_answer)):
- groupname = str(i)
- self.correct_groups[groupname] = correct_answer[i]['draggables']
- self.correct_positions[groupname] = {correct_answer[i]['rule']:
- correct_answer[i]['targets']}
- self.user_groups[groupname] = []
- self.user_positions[groupname] = {'user': []}
- for draggable_dict in user_answer['draggables']:
- # draggable_dict is 1-to-1 {draggable_name: position}
+ # Convert nested `user_answer` to flat format.
+ user_answer = flat_user_answer(user_answer)
+
+ # Create identical data structures from user answer and correct answer.
+ for answer in correct_answer:
+ user_groups_data = []
+ user_positions_data = []
+ for draggable_dict in user_answer:
+ # Draggable_dict is 1-to-1 {draggable_name: position}.
draggable_name = draggable_dict.keys()[0]
- if draggable_name in self.correct_groups[groupname]:
- self.user_groups[groupname].append(draggable_name)
- self.user_positions[groupname]['user'].append(
+ if draggable_name in answer['draggables']:
+ user_groups_data.append(draggable_name)
+ user_positions_data.append(
draggable_dict[draggable_name])
# proved that this is not excess
self.excess_draggables[draggable_name] = False
+ self.correct_groups.append(answer['draggables'])
+ self.correct_positions.append({answer['rule']: answer['targets']})
+ self.user_groups.append(user_groups_data)
+ self.user_positions.append({'user': user_positions_data})
+
+
def grade(user_input, correct_answer):
""" Creates DragAndDrop instance from user_input and correct_answer and
calls DragAndDrop.grade for grading.
diff --git a/common/lib/capa/capa/verifiers/tests_draganddrop.py b/common/lib/capa/capa/verifiers/tests_draganddrop.py
index bcd024fa89..75a194cc6d 100644
--- a/common/lib/capa/capa/verifiers/tests_draganddrop.py
+++ b/common/lib/capa/capa/verifiers/tests_draganddrop.py
@@ -1,7 +1,8 @@
import unittest
import draganddrop
-from draganddrop import PositionsCompare
+from .draganddrop import PositionsCompare
+import json
class Test_PositionsCompare(unittest.TestCase):
@@ -40,90 +41,314 @@ class Test_PositionsCompare(unittest.TestCase):
class Test_DragAndDrop_Grade(unittest.TestCase):
+ def test_targets_are_draggable_1(self):
+ user_input = json.dumps([
+ {'p': 'p_l'},
+ {'up': {'first': {'p': 'p_l'}}}
+ ])
+
+ correct_answer = [
+ {
+ 'draggables': ['p'],
+ 'targets': [
+ 'p_l', 'p_r'
+ ],
+ 'rule': 'anyof'
+ },
+ {
+ 'draggables': ['up'],
+ 'targets': [
+ 'p_l[p][first]'
+ ],
+ 'rule': 'anyof'
+ }
+ ]
+ self.assertTrue(draganddrop.grade(user_input, correct_answer))
+
+ def test_targets_are_draggable_2(self):
+ user_input = json.dumps([
+ {'p': 'p_l'},
+ {'p': 'p_r'},
+ {'s': 's_l'},
+ {'s': 's_r'},
+ {'up': {'1': {'p': 'p_l'}}},
+ {'up': {'3': {'p': 'p_l'}}},
+ {'up': {'1': {'p': 'p_r'}}},
+ {'up': {'3': {'p': 'p_r'}}},
+ {'up_and_down': {'1': {'s': 's_l'}}},
+ {'up_and_down': {'1': {'s': 's_r'}}}
+ ])
+
+ correct_answer = [
+ {
+ 'draggables': ['p'],
+ 'targets': ['p_l', 'p_r'],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['s'],
+ 'targets': ['s_l', 's_r'],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['up_and_down'],
+ 'targets': [
+ 's_l[s][1]', 's_r[s][1]'
+ ],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['up'],
+ 'targets': [
+ 'p_l[p][1]', 'p_l[p][3]', 'p_r[p][1]', 'p_r[p][3]'
+ ],
+ 'rule': 'unordered_equal'
+ }
+ ]
+ self.assertTrue(draganddrop.grade(user_input, correct_answer))
+
+ def test_targets_are_draggable_2_manual_parsing(self):
+ user_input = json.dumps([
+ {'up': 'p_l[p][1]'},
+ {'p': 'p_l'},
+ {'up': 'p_l[p][3]'},
+ {'up': 'p_r[p][1]'},
+ {'p': 'p_r'},
+ {'up': 'p_r[p][3]'},
+ {'up_and_down': 's_l[s][1]'},
+ {'s': 's_l'},
+ {'up_and_down': 's_r[s][1]'},
+ {'s': 's_r'}
+ ])
+
+ correct_answer = [
+ {
+ 'draggables': ['p'],
+ 'targets': ['p_l', 'p_r'],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['s'],
+ 'targets': ['s_l', 's_r'],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['up_and_down'],
+ 'targets': [
+ 's_l[s][1]', 's_r[s][1]'
+ ],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['up'],
+ 'targets': [
+ 'p_l[p][1]', 'p_l[p][3]', 'p_r[p][1]', 'p_r[p][3]'
+ ],
+ 'rule': 'unordered_equal'
+ }
+ ]
+ self.assertTrue(draganddrop.grade(user_input, correct_answer))
+
+ def test_targets_are_draggable_3_nested(self):
+ user_input = json.dumps([
+ {'molecule': 'left_side_tagret'},
+ {'molecule': 'right_side_tagret'},
+ {'p': {'p_target': {'molecule': 'left_side_tagret'}}},
+ {'p': {'p_target': {'molecule': 'right_side_tagret'}}},
+ {'s': {'s_target': {'molecule': 'left_side_tagret'}}},
+ {'s': {'s_target': {'molecule': 'right_side_tagret'}}},
+ {'up': {'1': {'p': {'p_target': {'molecule': 'left_side_tagret'}}}}},
+ {'up': {'3': {'p': {'p_target': {'molecule': 'left_side_tagret'}}}}},
+ {'up': {'1': {'p': {'p_target': {'molecule': 'right_side_tagret'}}}}},
+ {'up': {'3': {'p': {'p_target': {'molecule': 'right_side_tagret'}}}}},
+ {'up_and_down': {'1': {'s': {'s_target': {'molecule': 'left_side_tagret'}}}}},
+ {'up_and_down': {'1': {'s': {'s_target': {'molecule': 'right_side_tagret'}}}}}
+ ])
+
+ correct_answer = [
+ {
+ 'draggables': ['molecule'],
+ 'targets': ['left_side_tagret', 'right_side_tagret'],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['p'],
+ 'targets': [
+ 'left_side_tagret[molecule][p_target]',
+ 'right_side_tagret[molecule][p_target]'
+ ],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['s'],
+ 'targets': [
+ 'left_side_tagret[molecule][s_target]',
+ 'right_side_tagret[molecule][s_target]'
+ ],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['up_and_down'],
+ 'targets': [
+ 'left_side_tagret[molecule][s_target][s][1]',
+ 'right_side_tagret[molecule][s_target][s][1]'
+ ],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['up'],
+ 'targets': [
+ 'left_side_tagret[molecule][p_target][p][1]',
+ 'left_side_tagret[molecule][p_target][p][3]',
+ 'right_side_tagret[molecule][p_target][p][1]',
+ 'right_side_tagret[molecule][p_target][p][3]'
+ ],
+ 'rule': 'unordered_equal'
+ }
+ ]
+ self.assertTrue(draganddrop.grade(user_input, correct_answer))
+
+ def test_targets_are_draggable_4_real_example(self):
+ user_input = json.dumps([
+ {'single_draggable': 's_l'},
+ {'single_draggable': 's_r'},
+ {'single_draggable': 'p_sigma'},
+ {'single_draggable': 'p_sigma*'},
+ {'single_draggable': 's_sigma'},
+ {'single_draggable': 's_sigma*'},
+ {'double_draggable': 'p_pi*'},
+ {'double_draggable': 'p_pi'},
+ {'triple_draggable': 'p_l'},
+ {'triple_draggable': 'p_r'},
+ {'up': {'1': {'triple_draggable': 'p_l'}}},
+ {'up': {'2': {'triple_draggable': 'p_l'}}},
+ {'up': {'2': {'triple_draggable': 'p_r'}}},
+ {'up': {'3': {'triple_draggable': 'p_r'}}},
+ {'up_and_down': {'1': {'single_draggable': 's_l'}}},
+ {'up_and_down': {'1': {'single_draggable': 's_r'}}},
+ {'up_and_down': {'1': {'single_draggable': 's_sigma'}}},
+ {'up_and_down': {'1': {'single_draggable': 's_sigma*'}}},
+ {'up_and_down': {'1': {'double_draggable': 'p_pi'}}},
+ {'up_and_down': {'2': {'double_draggable': 'p_pi'}}}
+ ])
+
+ # 10 targets:
+ # s_l, s_r, p_l, p_r, s_sigma, s_sigma*, p_pi, p_sigma, p_pi*, p_sigma*
+ #
+ # 3 draggable objects, which have targets (internal target ids - 1, 2, 3):
+ # single_draggable, double_draggable, triple_draggable
+ #
+ # 2 draggable objects:
+ # up, up_and_down
+ correct_answer = [
+ {
+ 'draggables': ['triple_draggable'],
+ 'targets': ['p_l', 'p_r'],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['double_draggable'],
+ 'targets': ['p_pi', 'p_pi*'],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['single_draggable'],
+ 'targets': ['s_l', 's_r', 's_sigma', 's_sigma*', 'p_sigma', 'p_sigma*'],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['up'],
+ 'targets': ['p_l[triple_draggable][1]', 'p_l[triple_draggable][2]',
+ 'p_r[triple_draggable][2]', 'p_r[triple_draggable][3]'],
+ 'rule': 'unordered_equal'
+ },
+ {
+ 'draggables': ['up_and_down'],
+ 'targets': ['s_l[single_draggable][1]', 's_r[single_draggable][1]',
+ 's_sigma[single_draggable][1]', 's_sigma*[single_draggable][1]',
+ 'p_pi[double_draggable][1]', 'p_pi[double_draggable][2]'],
+ 'rule': 'unordered_equal'
+ },
+
+ ]
+ self.assertTrue(draganddrop.grade(user_input, correct_answer))
+
def test_targets_true(self):
- user_input = '{"draggables": [{"1": "t1"}, \
- {"name_with_icon": "t2"}]}'
- correct_answer = {'1': 't1', 'name_with_icon': 't2'}
+ user_input = '[{"1": "t1"}, \
+ {"name_with_icon": "t2"}]'
+ correct_answer = {'1': 't1', 'name_with_icon': 't2'}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_expect_no_actions_wrong(self):
- user_input = '{"draggables": [{"1": "t1"}, \
- {"name_with_icon": "t2"}]}'
+ user_input = '[{"1": "t1"}, \
+ {"name_with_icon": "t2"}]'
correct_answer = []
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_expect_no_actions_right(self):
- user_input = '{"draggables": []}'
+ user_input = '[]'
correct_answer = []
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_targets_false(self):
- user_input = '{"draggables": [{"1": "t1"}, \
- {"name_with_icon": "t2"}]}'
- correct_answer = {'1': 't3', 'name_with_icon': 't2'}
+ user_input = '[{"1": "t1"}, \
+ {"name_with_icon": "t2"}]'
+ correct_answer = {'1': 't3', 'name_with_icon': 't2'}
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_multiple_images_per_target_true(self):
- user_input = '{\
- "draggables": [{"1": "t1"}, {"name_with_icon": "t2"}, \
- {"2": "t1"}]}'
- correct_answer = {'1': 't1', 'name_with_icon': 't2',
+ user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}, \
+ {"2": "t1"}]'
+ correct_answer = {'1': 't1', 'name_with_icon': 't2',
'2': 't1'}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_multiple_images_per_target_false(self):
- user_input = '{\
- "draggables": [{"1": "t1"}, {"name_with_icon": "t2"}, \
- {"2": "t1"}]}'
- correct_answer = {'1': 't2', 'name_with_icon': 't2',
+ user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}, \
+ {"2": "t1"}]'
+ correct_answer = {'1': 't2', 'name_with_icon': 't2',
'2': 't1'}
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_targets_and_positions(self):
- user_input = '{"draggables": [{"1": [10,10]}, \
- {"name_with_icon": [[10,10],4]}]}'
+ user_input = '[{"1": [10,10]}, \
+ {"name_with_icon": [[10,10],4]}]'
correct_answer = {'1': [10, 10], 'name_with_icon': [[10, 10], 4]}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_position_and_targets(self):
- user_input = '{"draggables": [{"1": "t1"}, {"name_with_icon": "t2"}]}'
+ user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}]'
correct_answer = {'1': 't1', 'name_with_icon': 't2'}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_positions_exact(self):
- user_input = '{"draggables": \
- [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
+ user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
correct_answer = {'1': [10, 10], 'name_with_icon': [20, 20]}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_positions_false(self):
- user_input = '{"draggables": \
- [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
+ user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
correct_answer = {'1': [25, 25], 'name_with_icon': [20, 20]}
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_positions_true_in_radius(self):
- user_input = '{"draggables": \
- [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
+ user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
correct_answer = {'1': [14, 14], 'name_with_icon': [20, 20]}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_positions_true_in_manual_radius(self):
- user_input = '{"draggables": \
- [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
+ user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
correct_answer = {'1': [[40, 10], 30], 'name_with_icon': [20, 20]}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_positions_false_in_manual_radius(self):
- user_input = '{"draggables": \
- [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
+ user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
correct_answer = {'1': [[40, 10], 29], 'name_with_icon': [20, 20]}
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_correct_answer_not_has_key_from_user_answer(self):
- user_input = '{"draggables": [{"1": "t1"}, \
- {"name_with_icon": "t2"}]}'
+ user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}]'
correct_answer = {'3': 't3', 'name_with_icon': 't2'}
self.assertFalse(draganddrop.grade(user_input, correct_answer))
@@ -131,20 +356,20 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
"""Draggables can be places anywhere on base image.
Place grass in the middle of the image and ant in the
right upper corner."""
- user_input = '{"draggables": \
- [{"ant":[610.5,57.449951171875]},{"grass":[322.5,199.449951171875]}]}'
+ user_input = '[{"ant":[610.5,57.449951171875]},\
+ {"grass":[322.5,199.449951171875]}]'
correct_answer = {'grass': [[300, 200], 200], 'ant': [[500, 0], 200]}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_lcao_correct(self):
"""Describe carbon molecule in LCAO-MO"""
- user_input = '{"draggables":[{"1":"s_left"}, \
+ user_input = '[{"1":"s_left"}, \
{"5":"s_right"},{"4":"s_sigma"},{"6":"s_sigma_star"},{"7":"p_left_1"}, \
{"8":"p_left_2"},{"10":"p_right_1"},{"9":"p_right_2"}, \
{"2":"p_pi_1"},{"3":"p_pi_2"},{"11":"s_sigma_name"}, \
{"13":"s_sigma_star_name"},{"15":"p_pi_name"},{"16":"p_pi_star_name"}, \
- {"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]}'
+ {"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]'
correct_answer = [{
'draggables': ['1', '2', '3', '4', '5', '6'],
@@ -178,12 +403,12 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_lcao_extra_element_incorrect(self):
"""Describe carbon molecule in LCAO-MO"""
- user_input = '{"draggables":[{"1":"s_left"}, \
+ user_input = '[{"1":"s_left"}, \
{"5":"s_right"},{"4":"s_sigma"},{"6":"s_sigma_star"},{"7":"p_left_1"}, \
{"8":"p_left_2"},{"17":"p_left_3"},{"10":"p_right_1"},{"9":"p_right_2"}, \
{"2":"p_pi_1"},{"3":"p_pi_2"},{"11":"s_sigma_name"}, \
{"13":"s_sigma_star_name"},{"15":"p_pi_name"},{"16":"p_pi_star_name"}, \
- {"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]}'
+ {"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]'
correct_answer = [{
'draggables': ['1', '2', '3', '4', '5', '6'],
@@ -217,9 +442,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_reuse_draggable_no_mupliples(self):
"""Test reusable draggables (no mupltiple draggables per target)"""
- user_input = '{"draggables":[{"1":"target1"}, \
+ user_input = '[{"1":"target1"}, \
{"2":"target2"},{"1":"target3"},{"2":"target4"},{"2":"target5"}, \
- {"3":"target6"}]}'
+ {"3":"target6"}]'
correct_answer = [
{
'draggables': ['1'],
@@ -240,9 +465,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_reuse_draggable_with_mupliples(self):
"""Test reusable draggables with mupltiple draggables per target"""
- user_input = '{"draggables":[{"1":"target1"}, \
+ user_input = '[{"1":"target1"}, \
{"2":"target2"},{"1":"target1"},{"2":"target4"},{"2":"target4"}, \
- {"3":"target6"}]}'
+ {"3":"target6"}]'
correct_answer = [
{
'draggables': ['1'],
@@ -263,10 +488,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_reuse_many_draggable_with_mupliples(self):
"""Test reusable draggables with mupltiple draggables per target"""
- user_input = '{"draggables":[{"1":"target1"}, \
+ user_input = '[{"1":"target1"}, \
{"2":"target2"},{"1":"target1"},{"2":"target4"},{"2":"target4"}, \
{"3":"target6"}, {"4": "target3"}, {"5": "target4"}, \
- {"5": "target5"}, {"6": "target2"}]}'
+ {"5": "target5"}, {"6": "target2"}]'
correct_answer = [
{
'draggables': ['1', '4'],
@@ -292,12 +517,12 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_reuse_many_draggable_with_mupliples_wrong(self):
"""Test reusable draggables with mupltiple draggables per target"""
- user_input = '{"draggables":[{"1":"target1"}, \
+ user_input = '[{"1":"target1"}, \
{"2":"target2"},{"1":"target1"}, \
{"2":"target3"}, \
{"2":"target4"}, \
{"3":"target6"}, {"4": "target3"}, {"5": "target4"}, \
- {"5": "target5"}, {"6": "target2"}]}'
+ {"5": "target5"}, {"6": "target2"}]'
correct_answer = [
{
'draggables': ['1', '4'],
@@ -323,10 +548,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_false(self):
"""Test reusable draggables (no mupltiple draggables per target)"""
- user_input = '{"draggables":[{"a":"target1"}, \
+ user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \
{"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \
- {"a":"target1"}]}'
+ {"a":"target1"}]'
correct_answer = [
{
'draggables': ['a'],
@@ -347,10 +572,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_(self):
"""Test reusable draggables (no mupltiple draggables per target)"""
- user_input = '{"draggables":[{"a":"target1"}, \
+ user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \
{"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \
- {"a":"target10"}]}'
+ {"a":"target10"}]'
correct_answer = [
{
'draggables': ['a'],
@@ -371,10 +596,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_multiple(self):
"""Test reusable draggables (mupltiple draggables per target)"""
- user_input = '{"draggables":[{"a":"target1"}, \
+ user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"b":"target5"}, \
{"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \
- {"a":"target1"}]}'
+ {"a":"target1"}]'
correct_answer = [
{
'draggables': ['a', 'a', 'a'],
@@ -395,10 +620,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_multiple_false(self):
"""Test reusable draggables (mupltiple draggables per target)"""
- user_input = '{"draggables":[{"a":"target1"}, \
+ user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \
{"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \
- {"a":"target1"}]}'
+ {"a":"target1"}]'
correct_answer = [
{
'draggables': ['a', 'a', 'a'],
@@ -419,10 +644,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_reused(self):
"""Test a b c in 10 labels reused"""
- user_input = '{"draggables":[{"a":"target1"}, \
+ user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"b":"target5"}, \
{"c":"target6"}, {"b":"target8"},{"c":"target9"}, \
- {"a":"target10"}]}'
+ {"a":"target10"}]'
correct_answer = [
{
'draggables': ['a', 'a'],
@@ -443,10 +668,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_reused_false(self):
"""Test a b c in 10 labels reused false"""
- user_input = '{"draggables":[{"a":"target1"}, \
+ user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"b":"target5"}, {"a":"target8"},\
{"c":"target6"}, {"b":"target8"},{"c":"target9"}, \
- {"a":"target10"}]}'
+ {"a":"target10"}]'
correct_answer = [
{
'draggables': ['a', 'a'],
@@ -467,9 +692,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_mixed_reuse_and_not_reuse(self):
"""Test reusable draggables """
- user_input = '{"draggables":[{"a":"target1"}, \
+ user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"}, {"a":"target4"},\
- {"a":"target5"}]}'
+ {"a":"target5"}]'
correct_answer = [
{
'draggables': ['a', 'b'],
@@ -485,8 +710,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_mixed_reuse_and_not_reuse_number(self):
"""Test reusable draggables with number """
- user_input = '{"draggables":[{"a":"target1"}, \
- {"b":"target2"},{"c":"target3"}, {"a":"target4"}]}'
+ user_input = '[{"a":"target1"}, \
+ {"b":"target2"},{"c":"target3"}, {"a":"target4"}]'
correct_answer = [
{
'draggables': ['a', 'a', 'b'],
@@ -502,8 +727,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_mixed_reuse_and_not_reuse_number_false(self):
"""Test reusable draggables with numbers, but wrong"""
- user_input = '{"draggables":[{"a":"target1"}, \
- {"b":"target2"},{"c":"target3"}, {"a":"target4"}, {"a":"target10"}]}'
+ user_input = '[{"a":"target1"}, \
+ {"b":"target2"},{"c":"target3"}, {"a":"target4"}, {"a":"target10"}]'
correct_answer = [
{
'draggables': ['a', 'a', 'b'],
@@ -518,9 +743,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_alternative_correct_answer(self):
- user_input = '{"draggables":[{"name_with_icon":"t1"},\
+ user_input = '[{"name_with_icon":"t1"},\
{"name_with_icon":"t1"},{"name_with_icon":"t1"},{"name4":"t1"}, \
- {"name4":"t1"}]}'
+ {"name4":"t1"}]'
correct_answer = [
{'draggables': ['name4'], 'targets': ['t1', 't1'], 'rule': 'exact'},
{'draggables': ['name_with_icon'], 'targets': ['t1', 't1', 't1'],
@@ -533,14 +758,13 @@ class Test_DragAndDrop_Populate(unittest.TestCase):
def test_1(self):
correct_answer = {'1': [[40, 10], 29], 'name_with_icon': [20, 20]}
- user_input = '{"draggables": \
- [{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
+ user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
dnd = draganddrop.DragAndDrop(correct_answer, user_input)
- correct_groups = {'1': ['name_with_icon'], '0': ['1']}
- correct_positions = {'1': {'exact': [[20, 20]]}, '0': {'exact': [[[40, 10], 29]]}}
- user_groups = {'1': [u'name_with_icon'], '0': [u'1']}
- user_positions = {'1': {'user': [[20, 20]]}, '0': {'user': [[10, 10]]}}
+ correct_groups = [['1'], ['name_with_icon']]
+ correct_positions = [{'exact': [[[40, 10], 29]]}, {'exact': [[20, 20]]}]
+ user_groups = [['1'], ['name_with_icon']]
+ user_positions = [{'user': [[10, 10]]}, {'user': [[20, 20]]}]
self.assertEqual(correct_groups, dnd.correct_groups)
self.assertEqual(correct_positions, dnd.correct_positions)
@@ -551,49 +775,49 @@ class Test_DragAndDrop_Populate(unittest.TestCase):
class Test_DraAndDrop_Compare_Positions(unittest.TestCase):
def test_1(self):
- dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
+ dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertTrue(dnd.compare_positions(correct=[[1, 1], [2, 3]],
user=[[2, 3], [1, 1]],
flag='anyof'))
def test_2a(self):
- dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
+ dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertTrue(dnd.compare_positions(correct=[[1, 1], [2, 3]],
user=[[2, 3], [1, 1]],
flag='exact'))
def test_2b(self):
- dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
+ dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertFalse(dnd.compare_positions(correct=[[1, 1], [2, 3]],
user=[[2, 13], [1, 1]],
flag='exact'))
def test_3(self):
- dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
+ dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertFalse(dnd.compare_positions(correct=["a", "b"],
user=["a", "b", "c"],
flag='anyof'))
def test_4(self):
- dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
+ dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertTrue(dnd.compare_positions(correct=["a", "b", "c"],
user=["a", "b"],
flag='anyof'))
def test_5(self):
- dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
+ dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertFalse(dnd.compare_positions(correct=["a", "b", "c"],
user=["a", "c", "b"],
flag='exact'))
def test_6(self):
- dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
+ dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertTrue(dnd.compare_positions(correct=["a", "b", "c"],
user=["a", "c", "b"],
flag='anyof'))
def test_7(self):
- dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
+ dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertFalse(dnd.compare_positions(correct=["a", "b", "b"],
user=["a", "c", "b"],
flag='anyof'))
diff --git a/common/lib/capa/capa/xqueue_interface.py b/common/lib/capa/capa/xqueue_interface.py
index 8dbe2c84aa..5cf2488af0 100644
--- a/common/lib/capa/capa/xqueue_interface.py
+++ b/common/lib/capa/capa/xqueue_interface.py
@@ -7,7 +7,7 @@ import logging
import requests
-log = logging.getLogger('mitx.' + __name__)
+log = logging.getLogger(__name__)
dateformat = '%Y%m%d%H%M%S'
diff --git a/common/lib/capa/setup.py b/common/lib/capa/setup.py
index 9c724dec8b..d9c813f55c 100644
--- a/common/lib/capa/setup.py
+++ b/common/lib/capa/setup.py
@@ -4,5 +4,5 @@ setup(
name="capa",
version="0.1",
packages=find_packages(exclude=["tests"]),
- install_requires=['distribute==0.6.34', 'pyparsing==1.5.6'],
+ install_requires=['distribute==0.6.30', 'pyparsing==1.5.6'],
)
diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py
index 835085d8ea..85d42690b9 100644
--- a/common/lib/xmodule/setup.py
+++ b/common/lib/xmodule/setup.py
@@ -28,6 +28,7 @@ setup(
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"error = xmodule.error_module:ErrorDescriptor",
"peergrading = xmodule.peer_grading_module:PeerGradingDescriptor",
+ "poll_question = xmodule.poll_module:PollDescriptor",
"problem = xmodule.capa_module:CapaDescriptor",
"problemset = xmodule.seq_module:SequenceDescriptor",
"randomize = xmodule.randomize_module:RandomizeDescriptor",
@@ -45,6 +46,7 @@ setup(
"static_tab = xmodule.html_module:StaticTabDescriptor",
"custom_tag_template = xmodule.raw_module:RawDescriptor",
"about = xmodule.html_module:AboutDescriptor",
+ "wrapper = xmodule.wrapper_module:WrapperDescriptor",
"graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor",
"annotatable = xmodule.annotatable_module:AnnotatableDescriptor",
"foldit = xmodule.foldit_module:FolditDescriptor",
diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py
index 537d864127..0e1c66df8e 100644
--- a/common/lib/xmodule/xmodule/abtest_module.py
+++ b/common/lib/xmodule/xmodule/abtest_module.py
@@ -1,4 +1,3 @@
-import json
import random
import logging
from lxml import etree
@@ -7,6 +6,7 @@ from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.xml_module import XmlDescriptor
from xmodule.exceptions import InvalidDefinitionError
+from xblock.core import String, Scope, Object, BlockScope
DEFAULT = "_DEFAULT_GROUP"
@@ -31,29 +31,42 @@ def group_from_value(groups, v):
return g
-class ABTestModule(XModule):
+class ABTestFields(object):
+ group_portions = Object(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content)
+ group_assignments = Object(help="What group this user belongs to", scope=Scope.student_preferences, default={})
+ group_content = Object(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []})
+ experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content)
+ has_children = True
+
+
+class ABTestModule(ABTestFields, XModule):
"""
Implements an A/B test with an aribtrary number of competing groups
"""
- def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs)
-
- if shared_state is None:
+ def __init__(self, *args, **kwargs):
+ XModule.__init__(self, *args, **kwargs)
+ if self.group is None:
self.group = group_from_value(
- self.definition['data']['group_portions'].items(),
+ self.group_portions.items(),
random.uniform(0, 1)
)
- else:
- shared_state = json.loads(shared_state)
- self.group = shared_state['group']
- def get_shared_state(self):
- return json.dumps({'group': self.group})
+ @property
+ def group(self):
+ return self.group_assignments.get(self.experiment)
+
+ @group.setter
+ def group(self, value):
+ self.group_assignments[self.experiment] = value
+
+ @group.deleter
+ def group(self):
+ del self.group_assignments[self.experiment]
def get_child_descriptors(self):
- active_locations = set(self.definition['data']['group_content'][self.group])
+ active_locations = set(self.group_content[self.group])
return [desc for desc in self.descriptor.get_children() if desc.location.url() in active_locations]
def displayable_items(self):
@@ -64,43 +77,11 @@ class ABTestModule(XModule):
# TODO (cpennington): Use Groups should be a first class object, rather than being
# managed by ABTests
-class ABTestDescriptor(RawDescriptor, XmlDescriptor):
+class ABTestDescriptor(ABTestFields, RawDescriptor, XmlDescriptor):
module_class = ABTestModule
template_dir_name = "abtest"
- def __init__(self, system, definition=None, **kwargs):
- """
- definition is a dictionary with the following layout:
- {'data': {
- 'experiment': 'the name of the experiment',
- 'group_portions': {
- 'group_a': 0.1,
- 'group_b': 0.2
- },
- 'group_contents': {
- 'group_a': [
- 'url://for/content/module/1',
- 'url://for/content/module/2',
- ],
- 'group_b': [
- 'url://for/content/module/3',
- ],
- DEFAULT: [
- 'url://for/default/content/1'
- ]
- }
- },
- 'children': [
- 'url://for/content/module/1',
- 'url://for/content/module/2',
- 'url://for/content/module/3',
- 'url://for/default/content/1',
- ]}
- """
- kwargs['shared_state_key'] = definition['data']['experiment']
- RawDescriptor.__init__(self, system, definition, **kwargs)
-
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
@@ -118,19 +99,16 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
"ABTests must specify an experiment. Not found in:\n{xml}"
.format(xml=etree.tostring(xml_object, pretty_print=True)))
- definition = {
- 'data': {
- 'experiment': experiment,
- 'group_portions': {},
- 'group_content': {DEFAULT: []},
- },
- 'children': []}
+ group_portions = {}
+ group_content = {}
+ children = []
+
for group in xml_object:
if group.tag == 'default':
name = DEFAULT
else:
name = group.get('name')
- definition['data']['group_portions'][name] = float(group.get('portion', 0))
+ group_portions[name] = float(group.get('portion', 0))
child_content_urls = []
for child in group:
@@ -140,29 +118,33 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
log.exception("Unable to load child when parsing ABTest. Continuing...")
continue
- definition['data']['group_content'][name] = child_content_urls
- definition['children'].extend(child_content_urls)
+ group_content[name] = child_content_urls
+ children.extend(child_content_urls)
default_portion = 1 - sum(
- portion for (name, portion) in definition['data']['group_portions'].items())
+ portion for (name, portion) in group_portions.items()
+ )
if default_portion < 0:
raise InvalidDefinitionError("ABTest portions must add up to less than or equal to 1")
- definition['data']['group_portions'][DEFAULT] = default_portion
- definition['children'].sort()
+ group_portions[DEFAULT] = default_portion
+ children.sort()
- return definition
+ return {
+ 'group_portions': group_portions,
+ 'group_content': group_content,
+ }, children
def definition_to_xml(self, resource_fs):
xml_object = etree.Element('abtest')
- xml_object.set('experiment', self.definition['data']['experiment'])
- for name, group in self.definition['data']['group_content'].items():
+ xml_object.set('experiment', self.experiment)
+ for name, group in self.group_content.items():
if name == DEFAULT:
group_elem = etree.SubElement(xml_object, 'default')
else:
group_elem = etree.SubElement(xml_object, 'group', attrib={
- 'portion': str(self.definition['data']['group_portions'][name]),
+ 'portion': str(self.group_portions[name]),
'name': name,
})
@@ -172,6 +154,5 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
return xml_object
-
def has_dynamic_children(self):
return True
diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py
index f093b76f52..db2aa13cb7 100644
--- a/common/lib/xmodule/xmodule/annotatable_module.py
+++ b/common/lib/xmodule/xmodule/annotatable_module.py
@@ -5,13 +5,17 @@ from pkg_resources import resource_string, resource_listdir
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
-from xmodule.modulestore.mongo import MongoModuleStore
-from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
+from xblock.core import Scope, String
log = logging.getLogger(__name__)
-class AnnotatableModule(XModule):
+
+class AnnotatableFields(object):
+ data = String(help="XML data for the annotation", scope=Scope.content)
+
+
+class AnnotatableModule(AnnotatableFields, XModule):
js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/html/display.coffee'),
@@ -22,6 +26,17 @@ class AnnotatableModule(XModule):
css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]}
icon_class = 'annotatable'
+
+ def __init__(self, *args, **kwargs):
+ XModule.__init__(self, *args, **kwargs)
+
+ xmltree = etree.fromstring(self.data)
+
+ self.instructions = self._extract_instructions(xmltree)
+ self.content = etree.tostring(xmltree, encoding='unicode')
+ self.element_id = self.location.html_id()
+ self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
+
def _get_annotation_class_attr(self, index, el):
""" Returns a dict with the CSS class attribute to set on the annotation
and an XML key to delete from the element.
@@ -103,7 +118,7 @@ class AnnotatableModule(XModule):
def get_html(self):
""" Renders parameters to template. """
context = {
- 'display_name': self.display_name,
+ 'display_name': self.display_name_with_default,
'element_id': self.element_id,
'instructions_html': self.instructions,
'content_html': self._render_content()
@@ -111,19 +126,8 @@ class AnnotatableModule(XModule):
return self.system.render_template('annotatable.html', context)
- def __init__(self, system, location, definition, descriptor,
- instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
- xmltree = etree.fromstring(self.definition['data'])
-
- self.instructions = self._extract_instructions(xmltree)
- self.content = etree.tostring(xmltree, encoding='unicode')
- self.element_id = self.location.html_id()
- self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
-
-class AnnotatableDescriptor(RawDescriptor):
+class AnnotatableDescriptor(AnnotatableFields, RawDescriptor):
module_class = AnnotatableModule
stores_state = True
template_dir_name = "annotatable"
diff --git a/common/lib/xmodule/xmodule/backcompat_module.py b/common/lib/xmodule/xmodule/backcompat_module.py
index 40ffd46d1c..9e7b132e9e 100644
--- a/common/lib/xmodule/xmodule/backcompat_module.py
+++ b/common/lib/xmodule/xmodule/backcompat_module.py
@@ -1,7 +1,7 @@
"""
These modules exist to translate old format XML into newer, semantic forms
"""
-from x_module import XModuleDescriptor
+from .x_module import XModuleDescriptor
from lxml import etree
from functools import wraps
import logging
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index 2597690572..e66b1d3495 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -6,25 +6,45 @@ import hashlib
import json
import logging
import traceback
-import re
import sys
-from datetime import timedelta
from lxml import etree
from pkg_resources import resource_string
from capa.capa_problem import LoncapaProblem
from capa.responsetypes import StudentInputError
from capa.util import convert_files_to_filenames
-from progress import Progress
+from .progress import Progress
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.exceptions import NotFoundError
+from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, Float
+from .fields import Timedelta
log = logging.getLogger("mitx.courseware")
-#-----------------------------------------------------------------------------
-TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$')
+
+class StringyInteger(Integer):
+ """
+ A model type that converts from strings to integers when reading from json
+ """
+ def from_json(self, value):
+ try:
+ return int(value)
+ except:
+ return None
+
+
+class StringyFloat(Float):
+ """
+ A model type that converts from string to floats when reading from json
+ """
+ def from_json(self, value):
+ try:
+ return float(value)
+ except:
+ return None
+
# Generated this many different variants of problems with rerandomize=per_student
NUM_RANDOMIZATION_BINS = 20
@@ -45,41 +65,15 @@ def randomization_bin(seed, problem_id):
return int(h.hexdigest()[:7], 16) % NUM_RANDOMIZATION_BINS
-def only_one(lst, default="", process=lambda x: x):
- """
- If lst is empty, returns default
+class Randomization(String):
+ def from_json(self, value):
+ if value in ("", "true"):
+ return "always"
+ elif value == "false":
+ return "per_student"
+ return value
- If lst has a single element, applies process to that element and returns it.
-
- Otherwise, raises an exception.
- """
- if len(lst) == 0:
- return default
- elif len(lst) == 1:
- return process(lst[0])
- else:
- raise Exception('Malformed XML: expected at most one element in list.')
-
-
-def parse_timedelta(time_str):
- """
- time_str: A string with the following components:
- day[s] (optional)
- hour[s] (optional)
- minute[s] (optional)
- second[s] (optional)
-
- Returns a datetime.timedelta parsed from the string
- """
- parts = TIMEDELTA_REGEX.match(time_str)
- if not parts:
- return
- parts = parts.groupdict()
- time_params = {}
- for (name, param) in parts.iteritems():
- if param:
- time_params[name] = int(param)
- return timedelta(**time_params)
+ to_json = from_json
class ComplexEncoder(json.JSONEncoder):
@@ -89,13 +83,32 @@ class ComplexEncoder(json.JSONEncoder):
return json.JSONEncoder.default(self, obj)
-class CapaModule(XModule):
+class CapaFields(object):
+ attempts = StringyInteger(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state)
+ max_attempts = StringyInteger(help="Maximum number of attempts that a student is allowed", scope=Scope.settings)
+ due = String(help="Date that this problem is due by", scope=Scope.settings)
+ graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings)
+ showanswer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed")
+ force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings, default=False)
+ rerandomize = Randomization(help="When to rerandomize the problem", default="always", scope=Scope.settings)
+ data = String(help="XML data for the problem", scope=Scope.content)
+ correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state, default={})
+ student_answers = Object(help="Dictionary with the current student responses", scope=Scope.student_state)
+ done = Boolean(help="Whether the student has answered the problem", scope=Scope.student_state)
+ display_name = String(help="Display name for this module", scope=Scope.settings)
+ seed = StringyInteger(help="Random seed for this student", scope=Scope.student_state)
+ weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings)
+ markdown = String(help="Markdown source of this module", scope=Scope.settings)
+
+
+class CapaModule(CapaFields, XModule):
'''
An XModule implementing LonCapa format problems, implemented by way of
capa.capa_problem.LoncapaProblem
'''
icon_class = 'problem'
+
js = {'coffee': [resource_string(__name__, 'js/src/capa/display.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/javascript_loader.coffee'),
@@ -107,61 +120,25 @@ class CapaModule(XModule):
js_module_name = "Problem"
css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]}
- def __init__(self, system, location, definition, descriptor, instance_state=None,
- shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor, instance_state,
- shared_state, **kwargs)
+ def __init__(self, system, location, descriptor, model_data):
+ XModule.__init__(self, system, location, descriptor, model_data)
- self.attempts = 0
- self.max_attempts = None
-
- dom2 = etree.fromstring(definition['data'])
-
- display_due_date_string = self.metadata.get('due', None)
- if display_due_date_string is not None:
- self.display_due_date = dateutil.parser.parse(display_due_date_string)
- #log.debug("Parsed " + display_due_date_string +
- # " to " + str(self.display_due_date))
+ if self.due:
+ due_date = dateutil.parser.parse(self.due)
else:
- self.display_due_date = None
+ due_date = None
- grace_period_string = self.metadata.get('graceperiod', None)
- if grace_period_string is not None and self.display_due_date:
- self.grace_period = parse_timedelta(grace_period_string)
- self.close_date = self.display_due_date + self.grace_period
- #log.debug("Then parsed " + grace_period_string +
- # " to closing date" + str(self.close_date))
+ if self.graceperiod is not None and due_date:
+ self.close_date = due_date + self.graceperiod
else:
- self.grace_period = None
- self.close_date = self.display_due_date
+ self.close_date = due_date
- max_attempts = self.metadata.get('attempts')
- if max_attempts is not None and max_attempts != '':
- self.max_attempts = int(max_attempts)
- else:
- self.max_attempts = None
-
- self.show_answer = self.metadata.get('showanswer', 'closed')
-
- self.force_save_button = self.metadata.get('force_save_button', 'false')
-
- if self.show_answer == "":
- self.show_answer = "closed"
-
- if instance_state is not None:
- instance_state = json.loads(instance_state)
- if instance_state is not None and 'attempts' in instance_state:
- self.attempts = instance_state['attempts']
-
- self.name = only_one(dom2.xpath('/problem/@name'))
-
- if self.rerandomize == 'never':
- self.seed = 1
- elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'):
- # see comment on randomization_bin
- self.seed = randomization_bin(system.seed, self.location.url)
- else:
- self.seed = None
+ if self.seed is None:
+ if self.rerandomize == 'never':
+ self.seed = 1
+ elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'):
+ # see comment on randomization_bin
+ self.seed = randomization_bin(system.seed, self.location.url)
# Need the problem location in openendedresponse to send out. Adding
# it to the system here seems like the least clunky way to get it
@@ -171,8 +148,7 @@ class CapaModule(XModule):
try:
# TODO (vshnayder): move as much as possible of this work and error
# checking to descriptor load time
- self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
- instance_state, seed=self.seed, system=self.system)
+ self.lcp = self.new_lcp(self.get_state_for_lcp())
except Exception as err:
msg = 'cannot create LoncapaProblem {loc}: {err}'.format(
loc=self.location.url(), err=err)
@@ -189,35 +165,38 @@ class CapaModule(XModule):
problem_text = (''
'Problem %s has an error:%s' %
(self.location.url(), msg))
- self.lcp = LoncapaProblem(
- problem_text, self.location.html_id(),
- instance_state, seed=self.seed, system=self.system)
+ self.lcp = self.new_lcp(self.get_state_for_lcp(), text=problem_text)
else:
# add extra info and raise
raise Exception(msg), None, sys.exc_info()[2]
- @property
- def rerandomize(self):
- """
- Property accessor that returns self.metadata['rerandomize'] in a
- canonical form
- """
- rerandomize = self.metadata.get('rerandomize', 'always')
- if rerandomize in ("", "always", "true"):
- return "always"
- elif rerandomize in ("false", "per_student"):
- return "per_student"
- elif rerandomize == "never":
- return "never"
- elif rerandomize == "onreset":
- return "onreset"
- else:
- raise Exception("Invalid rerandomize attribute " + rerandomize)
+ self.set_state_from_lcp()
- def get_instance_state(self):
- state = self.lcp.get_state()
- state['attempts'] = self.attempts
- return json.dumps(state)
+ def new_lcp(self, state, text=None):
+ if text is None:
+ text = self.data
+
+ return LoncapaProblem(
+ problem_text=text,
+ id=self.location.html_id(),
+ state=state,
+ system=self.system,
+ )
+
+ def get_state_for_lcp(self):
+ return {
+ 'done': self.done,
+ 'correct_map': self.correct_map,
+ 'student_answers': self.student_answers,
+ 'seed': self.seed,
+ }
+
+ def set_state_from_lcp(self):
+ lcp_state = self.lcp.get_state()
+ self.done = lcp_state['done']
+ self.correct_map = lcp_state['correct_map']
+ self.student_answers = lcp_state['student_answers']
+ self.seed = lcp_state['seed']
def get_score(self):
return self.lcp.get_score()
@@ -234,7 +213,7 @@ class CapaModule(XModule):
if total > 0:
try:
return Progress(score, total)
- except Exception as err:
+ except Exception:
log.exception("Got bad progress")
return None
return None
@@ -291,7 +270,6 @@ class CapaModule(XModule):
return False
else:
return True
-
# Only randomized problems need a "reset" button
else:
return False
@@ -310,11 +288,26 @@ class CapaModule(XModule):
is_survey_question = (self.max_attempts == 0)
needs_reset = self.is_completed() and self.rerandomize == "always"
+ # If the student has unlimited attempts, and their answers
+ # are not randomized, then we do not need a save button
+ # because they can use the "Check" button without consequences.
+ #
+ # The consequences we want to avoid are:
+ # * Using up an attempt (if max_attempts is set)
+ # * Changing the current problem, and no longer being
+ # able to view it (if rerandomize is "always")
+ #
+ # In those cases. the if statement below is false,
+ # and the save button can still be displayed.
+ #
+ if self.max_attempts is None and self.rerandomize != "always":
+ return False
+
# If the problem is closed (and not a survey question with max_attempts==0),
- # then do NOT show the reset button
+ # then do NOT show the save button
# If we're waiting for the user to reset a randomized problem
- # then do NOT show the reset button
- if (self.closed() and not is_survey_question) or needs_reset:
+ # then do NOT show the save button
+ elif (self.closed() and not is_survey_question) or needs_reset:
return False
else:
return True
@@ -343,6 +336,8 @@ class CapaModule(XModule):
# We're in non-debug mode, and possibly even in production. We want
# to avoid bricking of problem as much as possible
else:
+ # We're in non-debug mode, and possibly even in production. We want
+ # to avoid bricking of problem as much as possible
# Presumably, student submission has corrupted LoncapaProblem HTML.
# First, pull down all student answers
@@ -359,9 +354,8 @@ class CapaModule(XModule):
student_answers.pop(answer_id)
# Next, generate a fresh LoncapaProblem
- self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
- state=None, # Tabula rasa
- seed=self.seed, system=self.system)
+ self.lcp = self.new_lcp(None)
+ self.set_state_from_lcp()
# Prepend a scary warning to the student
warning = '
'\
@@ -379,8 +373,8 @@ class CapaModule(XModule):
html = warning
try:
html += self.lcp.get_html()
- except Exception, err: # Couldn't do it. Give up
- log.exception(err)
+ except Exception: # Couldn't do it. Give up
+ log.exception("Unable to generate html from LoncapaProblem")
raise
return html
@@ -403,16 +397,15 @@ class CapaModule(XModule):
# if we want to show a check button, and False otherwise
# This works because non-empty strings evaluate to True
if self.should_show_check_button():
- check_button = self.check_button_name()
+ check_button = self.check_button_name()
else:
check_button = False
- content = {'name': self.display_name,
+ content = {'name': self.display_name_with_default,
'html': html,
- 'weight': self.descriptor.weight,
+ 'weight': self.weight,
}
-
context = {'problem': content,
'id': self.id,
'check_button': check_button,
@@ -499,28 +492,28 @@ class CapaModule(XModule):
'''
Is the user allowed to see an answer?
'''
- if self.show_answer == '':
+ if self.showanswer == '':
return False
- elif self.show_answer == "never":
+ elif self.showanswer == "never":
return False
elif self.system.user_is_staff:
# This is after the 'never' check because admins can see the answer
# unless the problem explicitly prevents it
return True
- elif self.show_answer == 'attempted':
+ elif self.showanswer == 'attempted':
return self.attempts > 0
- elif self.show_answer == 'answered':
+ elif self.showanswer == 'answered':
# NOTE: this is slightly different from 'attempted' -- resetting the problems
# makes lcp.done False, but leaves attempts unchanged.
return self.lcp.done
- elif self.show_answer == 'closed':
+ elif self.showanswer == 'closed':
return self.closed()
- elif self.show_answer == 'finished':
+ elif self.showanswer == 'finished':
return self.closed() or self.is_correct()
- elif self.show_answer == 'past_due':
+ elif self.showanswer == 'past_due':
return self.is_past_due()
- elif self.show_answer == 'always':
+ elif self.showanswer == 'always':
return True
return False
@@ -539,6 +532,8 @@ class CapaModule(XModule):
queuekey = get['queuekey']
score_msg = get['xqueue_body']
self.lcp.update_score(score_msg, queuekey)
+ self.set_state_from_lcp()
+ self.publish_grade()
return dict() # No AJAX return is needed
@@ -550,13 +545,14 @@ class CapaModule(XModule):
'''
event_info = dict()
event_info['problem_id'] = self.location.url()
- self.system.track_function('show_answer', event_info)
+ self.system.track_function('showanswer', event_info)
if not self.answer_available():
raise NotFoundError('Answer is not available')
else:
answers = self.lcp.get_question_answers()
+ self.set_state_from_lcp()
- # answers (eg ) may have embedded images
+ # answers (eg ) may have embedded images
# but be careful, some problems are using non-string answer dicts
new_answers = dict()
for answer_id in answers:
@@ -606,7 +602,7 @@ class CapaModule(XModule):
to 'input_1' in the returned dict)
'''
answers = dict()
-
+
for key in get:
# e.g. input_resistor_1 ==> resistor_1
_, _, name = key.partition('_')
@@ -639,6 +635,18 @@ class CapaModule(XModule):
return answers
+ def publish_grade(self):
+ """
+ Publishes the student's current grade to the system as an event
+ """
+ score = self.lcp.get_score()
+ self.system.publish({
+ 'event_name': 'grade',
+ 'value': score['score'],
+ 'max_value': score['total'],
+ })
+
+
def check_problem(self, get):
''' Checks whether answers to a problem are correct, and
returns a map of correct/incorrect answers:
@@ -652,7 +660,6 @@ class CapaModule(XModule):
answers = self.make_dict_of_responses(get)
event_info['answers'] = convert_files_to_filenames(answers)
-
# Too late. Cannot submit
if self.closed():
event_info['failure'] = 'closed'
@@ -660,7 +667,7 @@ class CapaModule(XModule):
raise NotFoundError('Problem is closed')
# Problem submitted. Student should reset before checking again
- if self.lcp.done and self.rerandomize == "always":
+ if self.done and self.rerandomize == "always":
event_info['failure'] = 'unreset'
self.system.track_function('save_problem_check_fail', event_info)
raise NotFoundError('Problem must be reset before it can be checked again')
@@ -672,12 +679,11 @@ class CapaModule(XModule):
waittime_between_requests = self.system.xqueue['waittime']
if (current_time - prev_submit_time).total_seconds() < waittime_between_requests:
msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests
- return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
+ return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
try:
- old_state = self.lcp.get_state()
- lcp_id = self.lcp.problem_id
correct_map = self.lcp.grade_answers(answers)
+ self.set_state_from_lcp()
except StudentInputError as inst:
log.exception("StudentInputError in capa_module:problem_check")
return {'success': inst.message}
@@ -686,12 +692,14 @@ class CapaModule(XModule):
msg = "Error checking problem: " + str(err)
msg += '\nTraceback:\n' + traceback.format_exc()
return {'success': msg}
- log.exception("Error in capa_module problem checking")
- raise Exception("error in capa_module")
+ raise
self.attempts = self.attempts + 1
self.lcp.done = True
+ self.set_state_from_lcp()
+ self.publish_grade()
+
# success = correct if ALL questions in this problem are correct
success = 'correct'
for answer_id in correct_map:
@@ -705,7 +713,7 @@ class CapaModule(XModule):
event_info['attempts'] = self.attempts
self.system.track_function('save_problem_check', event_info)
- if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback
+ if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback
self.system.psychometrics_handler(self.get_instance_state())
# render problem into HTML
@@ -729,7 +737,7 @@ class CapaModule(XModule):
event_info['answers'] = answers
# Too late. Cannot submit
- if self.closed() and not self.max_attempts==0:
+ if self.closed() and not self.max_attempts ==0:
event_info['failure'] = 'closed'
self.system.track_function('save_problem_fail', event_info)
return {'success': False,
@@ -737,7 +745,7 @@ class CapaModule(XModule):
# Problem submitted. Student should reset before saving
# again.
- if self.lcp.done and self.rerandomize == "always":
+ if self.done and self.rerandomize == "always":
event_info['failure'] = 'done'
self.system.track_function('save_problem_fail', event_info)
return {'success': False,
@@ -745,9 +753,11 @@ class CapaModule(XModule):
self.lcp.student_answers = answers
+ self.set_state_from_lcp()
+
self.system.track_function('save_problem_success', event_info)
msg = "Your answers have been saved"
- if not self.max_attempts==0:
+ if not self.max_attempts ==0:
msg += " but not graded. Hit 'Check' to grade them."
return {'success': True,
'msg': msg}
@@ -773,31 +783,33 @@ class CapaModule(XModule):
return {'success': False,
'error': "Problem is closed"}
- if not self.lcp.done:
+ if not self.done:
event_info['failure'] = 'not_done'
self.system.track_function('reset_problem_fail', event_info)
return {'success': False,
'error': "Refresh the page and make an attempt before resetting."}
- self.lcp.do_reset()
if self.rerandomize in ["always", "onreset"]:
# reset random number generator seed (note the self.lcp.get_state()
# in next line)
- self.lcp.seed = None
-
+ seed = None
+ else:
+ seed = self.lcp.seed
- self.lcp = LoncapaProblem(self.definition['data'],
- self.location.html_id(), self.lcp.get_state(),
- system=self.system)
+ # Generate a new problem with either the previous seed or a new seed
+ self.lcp = self.new_lcp({'seed': seed})
+
+ # Pull in the new problem seed
+ self.set_state_from_lcp()
event_info['new_state'] = self.lcp.get_state()
self.system.track_function('reset_problem', event_info)
- return { 'success': True,
+ return {'success': True,
'html': self.get_problem_html(encapsulate=False)}
-class CapaDescriptor(RawDescriptor):
+class CapaDescriptor(CapaFields, RawDescriptor):
"""
Module implementing problems in the LON-CAPA format,
as implemented by capa.capa_problem
@@ -818,20 +830,27 @@ class CapaDescriptor(RawDescriptor):
# actually use type and points?
metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points')
+ # The capa format specifies that what we call max_attempts in the code
+ # is the attribute `attempts`. This will do that conversion
+ metadata_translations = dict(RawDescriptor.metadata_translations)
+ metadata_translations['attempts'] = 'max_attempts'
+
def get_context(self):
_context = RawDescriptor.get_context(self)
- _context.update({'markdown': self.metadata.get('markdown', ''),
- 'enable_markdown' : 'markdown' in self.metadata})
+ _context.update({'markdown': self.markdown,
+ 'enable_markdown': self.markdown is not None})
return _context
@property
def editable_metadata_fields(self):
- """Remove any metadata from the editable fields which have their own editor or shouldn't be edited by user."""
- subset = [field for field in super(CapaDescriptor,self).editable_metadata_fields
- if field not in ['markdown', 'empty']]
+ """Remove metadata from the editable fields since it has its own editor"""
+ subset = super(CapaDescriptor, self).editable_metadata_fields
+ if 'markdown' in subset:
+ del subset['markdown']
+ if 'empty' in subset:
+ del subset['empty']
return subset
-
# VS[compat]
# TODO (cpennington): Delete this method once all fall 2012 course are being
# edited in the cms
@@ -841,12 +860,3 @@ class CapaDescriptor(RawDescriptor):
'problems/' + path[8:],
path[8:],
]
-
- def __init__(self, *args, **kwargs):
- super(CapaDescriptor, self).__init__(*args, **kwargs)
-
- weight_string = self.metadata.get('weight', None)
- if weight_string:
- self.weight = float(weight_string)
- else:
- self.weight = None
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index 0cc69a4a24..48fbfcced1 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -6,19 +6,72 @@ from pkg_resources import resource_string
from xmodule.raw_module import RawDescriptor
from .x_module import XModule
+from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, Float, List
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
+from collections import namedtuple
log = logging.getLogger("mitx.courseware")
-VERSION_TUPLES = (
- ('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module),
-)
+V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload",
+ "skip_spelling_checks", "due", "graceperiod", "max_score"]
+
+V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state",
+ "student_attempts", "ready_to_reset"]
+
+V1_ATTRIBUTES = V1_SETTINGS_ATTRIBUTES + V1_STUDENT_ATTRIBUTES
+
+VersionTuple = namedtuple('VersionTuple', ['descriptor', 'module', 'settings_attributes', 'student_attributes'])
+VERSION_TUPLES = {
+ 1: VersionTuple(CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module, V1_SETTINGS_ATTRIBUTES,
+ V1_STUDENT_ATTRIBUTES),
+}
DEFAULT_VERSION = 1
-DEFAULT_VERSION = str(DEFAULT_VERSION)
-class CombinedOpenEndedModule(XModule):
+class VersionInteger(Integer):
+ """
+ A model type that converts from strings to integers when reading from json.
+ Also does error checking to see if version is correct or not.
+ """
+
+ def from_json(self, value):
+ try:
+ value = int(value)
+ if value not in VERSION_TUPLES:
+ version_error_string = "Could not find version {0}, using version {1} instead"
+ log.error(version_error_string.format(value, DEFAULT_VERSION))
+ value = DEFAULT_VERSION
+ except:
+ value = DEFAULT_VERSION
+ return value
+
+
+class CombinedOpenEndedFields(object):
+ display_name = String(help="Display name for this module", default="Open Ended Grading", scope=Scope.settings)
+ current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.student_state)
+ task_states = List(help="List of state dictionaries of each task within this module.", scope=Scope.student_state)
+ state = String(help="Which step within the current task that the student is on.", default="initial",
+ scope=Scope.student_state)
+ student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0,
+ scope=Scope.student_state)
+ ready_to_reset = Boolean(help="If the problem is ready to be reset or not.", default=False,
+ scope=Scope.student_state)
+ attempts = Integer(help="Maximum number of attempts that a student is allowed.", default=1, scope=Scope.settings)
+ is_graded = Boolean(help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
+ accept_file_upload = Boolean(help="Whether or not the problem accepts file uploads.", default=False,
+ scope=Scope.settings)
+ skip_spelling_checks = Boolean(help="Whether or not to skip initial spelling checks.", default=True,
+ scope=Scope.settings)
+ due = String(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)
+ max_score = Integer(help="Maximum score for the problem.", default=1, 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)
+
+
+class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
"""
This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc).
It transitions between problems, and support arbitrary ordering.
@@ -49,6 +102,8 @@ class CombinedOpenEndedModule(XModule):
INTERMEDIATE_DONE = 'intermediate_done'
DONE = 'done'
+ icon_class = 'problem'
+
js = {'coffee': [resource_string(__name__, 'js/src/combinedopenended/display.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/javascript_loader.coffee'),
@@ -57,11 +112,8 @@ class CombinedOpenEndedModule(XModule):
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
- def __init__(self, system, location, definition, descriptor,
- instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
-
+ def __init__(self, system, location, descriptor, model_data):
+ XModule.__init__(self, system, location, descriptor, model_data)
"""
Definition file should have one or many task blocks, a rubric block, and a prompt block:
@@ -100,50 +152,37 @@ class CombinedOpenEndedModule(XModule):
self.system = system
self.system.set('location', location)
- # Load instance state
- if instance_state is not None:
- instance_state = json.loads(instance_state)
- else:
- instance_state = {}
+ if self.task_states is None:
+ self.task_states = []
- self.version = self.metadata.get('version', DEFAULT_VERSION)
- version_error_string = "Version of combined open ended module {0} is not correct. Going with version {1}"
- if not isinstance(self.version, basestring):
- try:
- self.version = str(self.version)
- except:
- #This is a dev_facing_error
- log.info(version_error_string.format(self.version, DEFAULT_VERSION))
- self.version = DEFAULT_VERSION
+ version_tuple = VERSION_TUPLES[self.version]
- versions = [i[0] for i in VERSION_TUPLES]
- descriptors = [i[1] for i in VERSION_TUPLES]
- modules = [i[2] for i in VERSION_TUPLES]
+ self.student_attributes = version_tuple.student_attributes
+ self.settings_attributes = version_tuple.settings_attributes
- try:
- version_index = versions.index(self.version)
- except:
- #This is a dev_facing_error
- log.error(version_error_string.format(self.version, DEFAULT_VERSION))
- self.version = DEFAULT_VERSION
- version_index = versions.index(self.version)
+ attributes = self.student_attributes + self.settings_attributes
static_data = {
'rewrite_content_links': self.rewrite_content_links,
}
-
- self.child_descriptor = descriptors[version_index](self.system)
- self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(definition['data']),
- self.system)
- self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor,
- instance_state=json.dumps(instance_state), metadata=self.metadata,
- static_data=static_data)
+ instance_state = {k: getattr(self, k) for k in attributes}
+ self.child_descriptor = version_tuple.descriptor(self.system)
+ self.child_definition = version_tuple.descriptor.definition_from_xml(etree.fromstring(self.data), self.system)
+ self.child_module = version_tuple.module(self.system, location, self.child_definition, self.child_descriptor,
+ instance_state=instance_state, static_data=static_data,
+ attributes=attributes)
+ self.save_instance_data()
def get_html(self):
- return self.child_module.get_html()
+ self.save_instance_data()
+ return_value = self.child_module.get_html()
+ return return_value
def handle_ajax(self, dispatch, get):
- return self.child_module.handle_ajax(dispatch, get)
+ self.save_instance_data()
+ return_value = self.child_module.handle_ajax(dispatch, get)
+ self.save_instance_data()
+ return return_value
def get_instance_state(self):
return self.child_module.get_instance_state()
@@ -151,8 +190,8 @@ class CombinedOpenEndedModule(XModule):
def get_score(self):
return self.child_module.get_score()
- def max_score(self):
- return self.child_module.max_score()
+ #def max_score(self):
+ # return self.child_module.max_score()
def get_progress(self):
return self.child_module.get_progress()
@@ -161,12 +200,14 @@ class CombinedOpenEndedModule(XModule):
def due_date(self):
return self.child_module.due_date
- @property
- def display_name(self):
- return self.child_module.display_name
+ def save_instance_data(self):
+ for attribute in self.student_attributes:
+ child_attr = getattr(self.child_module, attribute)
+ if child_attr != getattr(self, attribute):
+ setattr(self, attribute, getattr(self.child_module, attribute))
-class CombinedOpenEndedDescriptor(RawDescriptor):
+class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
"""
Module for adding combined open ended questions
"""
diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py
index 787d355c4a..a9375cae78 100644
--- a/common/lib/xmodule/xmodule/conditional_module.py
+++ b/common/lib/xmodule/xmodule/conditional_module.py
@@ -1,126 +1,147 @@
+"""Conditional module is the xmodule, which you can use for disabling
+some xmodules by conditions.
+"""
+
import json
import logging
+from lxml import etree
+from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor
+from xblock.core import String, Scope, List
+from xmodule.modulestore.exceptions import ItemNotFoundError
-from pkg_resources import resource_string
log = logging.getLogger('mitx.' + __name__)
-class ConditionalModule(XModule):
- '''
+class ConditionalFields(object):
+ show_tag_list = List(help="Poll answers", scope=Scope.content)
+
+
+class ConditionalModule(ConditionalFields, XModule):
+ """
Blocks child module from showing unless certain conditions are met.
Example:
-
+
+
-
-
-
+ tag attributes:
+ sources - location id of required modules, separated by ';'
- '''
+ completed - map to `is_completed` module method
+ attempted - map to `is_attempted` module method
+ poll_answer - map to `poll_answer` module attribute
+ voted - map to `voted` module attribute
- js = {'coffee': [resource_string(__name__, 'js/src/conditional/display.coffee'),
+ tag attributes:
+ sources - location id of modules, separated by ';'
+ """
+
+ js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
+ resource_string(__name__, 'js/src/conditional/display.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
- resource_string(__name__, 'js/src/javascript_loader.coffee'),
+
]}
js_module_name = "Conditional"
css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]}
+ # Map
+ # key:
+ # value:
+ conditions_map = {
+ 'poll_answer': 'poll_answer', # poll_question attr
+ 'completed': 'is_completed', # capa_problem attr
+ 'attempted': 'is_attempted', # capa_problem attr
+ 'voted': 'voted' # poll_question attr
+ }
- def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs):
- """
- In addition to the normal XModule init, provide:
-
- self.condition = string describing condition required
-
- """
- XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs)
- self.contents = None
- self.condition = self.metadata.get('condition', '')
- self._get_required_modules()
- children = self.get_display_items()
- if children:
- self.icon_class = children[0].get_icon_class()
- #log.debug('conditional module required=%s' % self.required_modules_list)
-
- def _get_required_modules(self):
- self.required_modules = []
- for descriptor in self.descriptor.get_required_module_descriptors():
- module = self.system.get_module(descriptor)
- self.required_modules.append(module)
- #log.debug('required_modules=%s' % (self.required_modules))
+ def _get_condition(self):
+ # Get first valid condition.
+ for xml_attr, attr_name in self.conditions_map.iteritems():
+ xml_value = self.descriptor.xml_attributes.get(xml_attr)
+ if xml_value:
+ return xml_value, attr_name
+ raise Exception('Error in conditional module: unknown condition "%s"'
+ % xml_attr)
def is_condition_satisfied(self):
- self._get_required_modules()
+ self.required_modules = [self.system.get_module(descriptor) for
+ descriptor in self.descriptor.get_required_module_descriptors()]
- if self.condition == 'require_completed':
- # all required modules must be completed, as determined by
- # the modules .is_completed() method
- for module in self.required_modules:
- #log.debug('in is_condition_satisfied; student_answers=%s' % module.lcp.student_answers)
- #log.debug('in is_condition_satisfied; instance_state=%s' % module.instance_state)
- if not hasattr(module, 'is_completed'):
- raise Exception('Error in conditional module: required module %s has no .is_completed() method' % module)
- if not module.is_completed():
- log.debug('conditional module: %s not completed' % module)
- return False
- else:
- log.debug('conditional module: %s IS completed' % module)
- return True
- elif self.condition == 'require_attempted':
- # all required modules must be attempted, as determined by
- # the modules .is_attempted() method
- for module in self.required_modules:
- if not hasattr(module, 'is_attempted'):
- raise Exception('Error in conditional module: required module %s has no .is_attempted() method' % module)
- if not module.is_attempted():
- log.debug('conditional module: %s not attempted' % module)
- return False
- else:
- log.debug('conditional module: %s IS attempted' % module)
- return True
- else:
- raise Exception('Error in conditional module: unknown condition "%s"' % self.condition)
+ xml_value, attr_name = self._get_condition()
- return True
+ if xml_value and self.required_modules:
+ for module in self.required_modules:
+ if not hasattr(module, attr_name):
+ raise Exception('Error in conditional module: \
+ required module {module} has no {module_attr}'.format(
+ module=module, module_attr=attr_name))
+
+ attr = getattr(module, attr_name)
+ if callable(attr):
+ attr = attr()
+
+ if xml_value != str(attr):
+ break
+ else:
+ return True
+ return False
def get_html(self):
- self.is_condition_satisfied()
+ # Calculate html ids of dependencies
+ self.required_html_ids = [descriptor.location.html_id() for
+ descriptor in self.descriptor.get_required_module_descriptors()]
+
return self.system.render_template('conditional_ajax.html', {
'element_id': self.location.html_id(),
'id': self.id,
'ajax_url': self.system.ajax_url,
+ 'depends': ';'.join(self.required_html_ids)
})
def handle_ajax(self, dispatch, post):
- '''
- This is called by courseware.module_render, to handle an AJAX call.
- '''
- #log.debug('conditional_module handle_ajax: dispatch=%s' % dispatch)
-
+ """This is called by courseware.moduleodule_render, to handle
+ an AJAX call.
+ """
if not self.is_condition_satisfied():
- context = {'module': self}
- html = self.system.render_template('conditional_module.html', context)
- return json.dumps({'html': html})
+ message = self.descriptor.xml_attributes.get('message')
+ context = {'module': self,
+ 'message': message}
+ html = self.system.render_template('conditional_module.html',
+ context)
+ return json.dumps({'html': [html], 'message': bool(message)})
- if self.contents is None:
- self.contents = [child.get_html() for child in self.get_display_items()]
-
- # for now, just deal with one child
- html = self.contents[0]
+ html = [child.get_html() for child in self.get_display_items()]
return json.dumps({'html': html})
+ def get_icon_class(self):
+ new_class = 'other'
+ if self.is_condition_satisfied():
+ # HACK: This shouldn't be hard-coded to two types
+ # OBSOLETE: This obsoletes 'type'
+ class_priority = ['video', 'problem']
+
+ child_classes = [self.system.get_module(child_descriptor).get_icon_class()
+ for child_descriptor in self.descriptor.get_children()]
+ for c in class_priority:
+ if c in child_classes:
+ new_class = c
+ return new_class
+
+
+class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
+ """Descriptor for conditional xmodule."""
+ _tag_name = 'conditional'
-class ConditionalDescriptor(SequenceDescriptor):
module_class = ConditionalModule
filename_extension = "xml"
@@ -128,26 +149,68 @@ class ConditionalDescriptor(SequenceDescriptor):
stores_state = True
has_score = False
- def __init__(self, *args, **kwargs):
- super(ConditionalDescriptor, self).__init__(*args, **kwargs)
- required_module_list = [tuple(x.split('/', 1)) for x in self.metadata.get('required', '').split('&')]
- self.required_module_locations = []
- for rm in required_module_list:
- try:
- (tag, name) = rm
- except Exception as err:
- msg = "Specification of required module in conditional is broken: %s" % self.metadata.get('required')
- log.warning(msg)
- self.system.error_tracker(msg)
- continue
- loc = self.location.dict()
- loc['category'] = tag
- loc['name'] = name
- self.required_module_locations.append(Location(loc))
- log.debug('ConditionalDescriptor required_module_locations=%s' % self.required_module_locations)
+ @staticmethod
+ def parse_sources(xml_element, system, return_descriptor=False):
+ """Parse xml_element 'sources' attr and:
+ if return_descriptor=True - return list of descriptors
+ if return_descriptor=False - return list of locations
+ """
+ result = []
+ sources = xml_element.get('sources')
+ if sources:
+ locations = [location.strip() for location in sources.split(';')]
+ for location in locations:
+ if Location.is_valid(location): # Check valid location url.
+ try:
+ if return_descriptor:
+ descriptor = system.load_item(location)
+ result.append(descriptor)
+ else:
+ result.append(location)
+ except ItemNotFoundError:
+ msg = "Invalid module by location."
+ log.exception(msg)
+ system.error_tracker(msg)
+ return result
def get_required_module_descriptors(self):
- """Returns a list of XModuleDescritpor instances upon which this module depends, but are
- not children of this module"""
- return [self.system.load_item(loc) for loc in self.required_module_locations]
+ """Returns a list of XModuleDescritpor instances upon
+ which this module depends.
+ """
+ return ConditionalDescriptor.parse_sources(
+ self.xml_attributes, self.system, True)
+
+ @classmethod
+ def definition_from_xml(cls, xml_object, system):
+ children = []
+ show_tag_list = []
+ for child in xml_object:
+ if child.tag == 'show':
+ location = ConditionalDescriptor.parse_sources(
+ child, system)
+ children.extend(location)
+ show_tag_list.extend(location)
+ else:
+ try:
+ descriptor = system.process_xml(etree.tostring(child))
+ module_url = descriptor.location.url()
+ children.append(module_url)
+ except:
+ msg = "Unable to load child when parsing Conditional."
+ log.exception(msg)
+ system.error_tracker(msg)
+ return {'show_tag_list': show_tag_list}, children
+
+ def definition_to_xml(self, resource_fs):
+ xml_object = etree.Element(self._tag_name)
+ for child in self.get_children():
+ location = str(child.location)
+ if location in self.show_tag_list:
+ show_str = '<{tag_name} sources="{sources}" />'.format(
+ tag_name='show', sources=location)
+ xml_object.append(etree.fromstring(show_str))
+ else:
+ xml_object.append(
+ etree.fromstring(child.export_to_xml(resource_fs)))
+ return xml_object
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index 89c52bb742..7c47e0887a 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -9,7 +9,7 @@ from datetime import datetime
from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor, SequenceModule
-from xmodule.timeparse import parse_time, stringify_time
+from xmodule.timeparse import parse_time
from xmodule.util.decorators import lazyproperty
from xmodule.graders import grader_from_conf
from datetime import datetime
@@ -19,107 +19,212 @@ import requests
import time
import copy
+from xblock.core import Scope, ModelType, List, String, Object, Boolean
+from .fields import Date
+
log = logging.getLogger(__name__)
+class StringOrDate(Date):
+ def from_json(self, value):
+ """
+ Parse an optional metadata key containing a time: if present, complain
+ if it doesn't parse.
+ Return None if not present or invalid.
+ """
+ if value is None:
+ return None
+
+ try:
+ return time.strptime(value, self.time_format)
+ except ValueError:
+ return value
+
+ def to_json(self, value):
+ """
+ Convert a time struct to a string
+ """
+ if value is None:
+ return None
+
+ try:
+ return time.strftime(self.time_format, value)
+ except (ValueError, TypeError):
+ return value
+
+
+
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True)
_cached_toc = {}
+class Textbook(object):
+ def __init__(self, title, book_url):
+ self.title = title
+ self.book_url = book_url
+ self.start_page = int(self.table_of_contents[0].attrib['page'])
-class CourseDescriptor(SequenceDescriptor):
- module_class = SequenceModule
+ # The last page should be the last element in the table of contents,
+ # but it may be nested. So recurse all the way down the last element
+ last_el = self.table_of_contents[-1]
+ while last_el.getchildren():
+ last_el = last_el[-1]
- template_dir_name = 'course'
+ self.end_page = int(last_el.attrib['page'])
- class Textbook:
- def __init__(self, title, book_url):
- self.title = title
- self.book_url = book_url
- self.table_of_contents = self._get_toc_from_s3()
- self.start_page = int(self.table_of_contents[0].attrib['page'])
+ @lazyproperty
+ def table_of_contents(self):
+ """
+ Accesses the textbook's table of contents (default name "toc.xml") at the URL self.book_url
- # The last page should be the last element in the table of contents,
- # but it may be nested. So recurse all the way down the last element
- last_el = self.table_of_contents[-1]
- while last_el.getchildren():
- last_el = last_el[-1]
+ Returns XML tree representation of the table of contents
+ """
+ toc_url = self.book_url + 'toc.xml'
- self.end_page = int(last_el.attrib['page'])
+ # cdodge: I've added this caching of TOC because in Mongo-backed instances (but not Filesystem stores)
+ # course modules have a very short lifespan and are constantly being created and torn down.
+ # Since this module in the __init__() method does a synchronous call to AWS to get the TOC
+ # this is causing a big performance problem. So let's be a bit smarter about this and cache
+ # each fetch and store in-mem for 10 minutes.
+ # NOTE: I have to get this onto sandbox ASAP as we're having runtime failures. I'd like to swing back and
+ # rewrite to use the traditional Django in-memory cache.
+ try:
+ # see if we already fetched this
+ if toc_url in _cached_toc:
+ (table_of_contents, timestamp) = _cached_toc[toc_url]
+ age = datetime.now() - timestamp
+ # expire every 10 minutes
+ if age.seconds < 600:
+ return table_of_contents
+ except Exception as err:
+ pass
- @property
- def table_of_contents(self):
- return self.table_of_contents
+ # Get the table of contents from S3
+ log.info("Retrieving textbook table of contents from %s" % toc_url)
+ try:
+ r = requests.get(toc_url)
+ except Exception as err:
+ msg = 'Error %s: Unable to retrieve textbook table of contents at %s' % (err, toc_url)
+ log.error(msg)
+ raise Exception(msg)
- def _get_toc_from_s3(self):
- """
- Accesses the textbook's table of contents (default name "toc.xml") at the URL self.book_url
+ # TOC is XML. Parse it
+ try:
+ table_of_contents = etree.fromstring(r.text)
+ except Exception as err:
+ msg = 'Error %s: Unable to parse XML for textbook table of contents at %s' % (err, toc_url)
+ log.error(msg)
+ raise Exception(msg)
- Returns XML tree representation of the table of contents
- """
- toc_url = self.book_url + 'toc.xml'
+ return table_of_contents
- # cdodge: I've added this caching of TOC because in Mongo-backed instances (but not Filesystem stores)
- # course modules have a very short lifespan and are constantly being created and torn down.
- # Since this module in the __init__() method does a synchronous call to AWS to get the TOC
- # this is causing a big performance problem. So let's be a bit smarter about this and cache
- # each fetch and store in-mem for 10 minutes.
- # NOTE: I have to get this onto sandbox ASAP as we're having runtime failures. I'd like to swing back and
- # rewrite to use the traditional Django in-memory cache.
+
+class TextbookList(List):
+ def from_json(self, values):
+ textbooks = []
+ for title, book_url in values:
try:
- # see if we already fetched this
- if toc_url in _cached_toc:
- (table_of_contents, timestamp) = _cached_toc[toc_url]
- age = datetime.now() - timestamp
- # expire every 10 minutes
- if age.seconds < 600:
- return table_of_contents
- except Exception as err:
- pass
-
- # Get the table of contents from S3
- log.info("Retrieving textbook table of contents from %s" % toc_url)
- try:
- r = requests.get(toc_url)
- except Exception as err:
- msg = 'Error %s: Unable to retrieve textbook table of contents at %s' % (err, toc_url)
- log.error(msg)
- raise Exception(msg)
-
- # TOC is XML. Parse it
- try:
- table_of_contents = etree.fromstring(r.text)
- _cached_toc[toc_url] = (table_of_contents, datetime.now())
- except Exception as err:
- msg = 'Error %s: Unable to parse XML for textbook table of contents at %s' % (err, toc_url)
- log.error(msg)
- raise Exception(msg)
-
- return table_of_contents
-
- def __init__(self, system, definition=None, **kwargs):
- super(CourseDescriptor, self).__init__(system, definition, **kwargs)
- self.textbooks = []
- for title, book_url in self.definition['data']['textbooks']:
- try:
- self.textbooks.append(self.Textbook(title, book_url))
+ textbooks.append(Textbook(title, book_url))
except:
# If we can't get to S3 (e.g. on a train with no internet), don't break
# the rest of the courseware.
log.exception("Couldn't load textbook ({0}, {1})".format(title, book_url))
continue
- self.wiki_slug = self.definition['data']['wiki_slug'] or self.location.course
+ return textbooks
+
+ def to_json(self, values):
+ json_data = []
+ for val in values:
+ if isinstance(val, Textbook):
+ json_data.append((val.title, val.book_url))
+ elif isinstance(val, tuple):
+ json_data.append(val)
+ else:
+ continue
+ return json_data
+
+
+class CourseFields(object):
+ textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", 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)
+ end = Date(help="Date that this class ends", scope=Scope.settings)
+ advertised_start = StringOrDate(help="Date that this course is advertised to start", scope=Scope.settings)
+ grading_policy = Object(help="Grading policy definition for this class", 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)
+ 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)
+ discussion_topics = Object(
+ help="Map of topics names to ids",
+ scope=Scope.settings,
+ computed_default=lambda c: {'General': {'id': c.location.html_id()}},
+ )
+ testcenter_info = Object(help="Dictionary of Test Center info", scope=Scope.settings)
+ announcement = Date(help="Date this course is announced", scope=Scope.settings)
+ cohort_config = Object(help="Dictionary defining cohort configuration", scope=Scope.settings)
+ is_new = Boolean(help="Whether this course should be flagged as new", scope=Scope.settings)
+ no_grade = Boolean(help="True if this course isn't graded", default=False, scope=Scope.settings)
+ disable_progress_graph = Boolean(help="True if this course shouldn't display the progress graph", default=False, scope=Scope.settings)
+ pdf_textbooks = List(help="List of dictionaries containing pdf_textbook configuration", scope=Scope.settings)
+ html_textbooks = List(help="List of dictionaries containing html_textbook configuration", scope=Scope.settings)
+ remote_gradebook = Object(scope=Scope.settings)
+ allow_anonymous = Boolean(scope=Scope.settings, default=True)
+ 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
+
+ info_sidebar_name = String(scope=Scope.settings, default='Course Handouts')
+
+ # An extra property is used rather than the wiki_slug/number because
+ # there are courses that change the number for different runs. This allows
+ # courses to share the same css_class across runs even if they have
+ # different numbers.
+ #
+ # TODO get rid of this as soon as possible or potentially build in a robust
+ # way to add in course-specific styling. There needs to be a discussion
+ # about the right way to do this, but arjun will address this ASAP. Also
+ # note that the courseware template needs to change when this is removed.
+ css_class = String(help="DO NOT USE THIS", scope=Scope.settings)
+
+ # TODO: This is a quick kludge to allow CS50 (and other courses) to
+ # specify their own discussion forums as external links by specifying a
+ # "discussion_link" in their policy JSON file. This should later get
+ # folded in with Syllabus, Course Info, and additional Custom tabs in a
+ # more sensible framework later.
+ discussion_link = String(help="DO NOT USE THIS", scope=Scope.settings)
+
+ # TODO: same as above, intended to let internal CS50 hide the progress tab
+ # until we get grade integration set up.
+ # Explicit comparison to True because we always want to return a bool.
+ hide_progress_tab = Boolean(help="DO NOT USE THIS", scope=Scope.settings)
+
+
+class CourseDescriptor(CourseFields, SequenceDescriptor):
+ module_class = SequenceModule
+
+ template_dir_name = 'course'
+
+
+ def __init__(self, *args, **kwargs):
+ super(CourseDescriptor, self).__init__(*args, **kwargs)
+
+ if self.wiki_slug is None:
+ 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
# hack it -- start in 1970
- self.metadata['start'] = stringify_time(time.gmtime(0))
+ self.start = time.gmtime(0)
log.critical(msg)
- system.error_tracker(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)
@@ -128,10 +233,10 @@ class CourseDescriptor(SequenceDescriptor):
# disable the syllabus content for courses that do not provide a syllabus
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
self._grading_policy = {}
- self.set_grading_policy(self.definition['data'].get('grading_policy', None))
+ self.set_grading_policy(self.grading_policy)
self.test_center_exams = []
- test_center_info = self.metadata.get('testcenter_info')
+ test_center_info = self.testcenter_info
if test_center_info is not None:
for exam_name in test_center_info:
try:
@@ -144,11 +249,11 @@ class CourseDescriptor(SequenceDescriptor):
log.error(msg)
continue
- def defaut_grading_policy(self):
+ def default_grading_policy(self):
"""
Return a dict which is a copy of the default grading policy
"""
- default = {"GRADER": [
+ return {"GRADER": [
{
"type": "Homework",
"min_count": 12,
@@ -180,7 +285,6 @@ class CourseDescriptor(SequenceDescriptor):
"GRADE_CUTOFFS": {
"Pass": 0.5
}}
- return copy.deepcopy(default)
def set_grading_policy(self, course_policy):
"""
@@ -191,7 +295,7 @@ class CourseDescriptor(SequenceDescriptor):
course_policy = {}
# Load the global settings as a dictionary
- grading_policy = self.defaut_grading_policy()
+ grading_policy = self.default_grading_policy()
# Override any global settings with the course settings
grading_policy.update(course_policy)
@@ -222,7 +326,6 @@ class CourseDescriptor(SequenceDescriptor):
return policy_str
-
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
instance = super(CourseDescriptor, cls).from_xml(xml_data, system, org, course)
@@ -250,14 +353,13 @@ class CourseDescriptor(SequenceDescriptor):
# 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.definition['data']['grading_policy'] = policy
+ instance.grading_policy = policy
# now set the current instance. set_grading_policy() will apply some inheritance rules
instance.set_grading_policy(policy)
return instance
-
@classmethod
def definition_from_xml(cls, xml_object, system):
textbooks = []
@@ -272,12 +374,12 @@ class CourseDescriptor(SequenceDescriptor):
wiki_slug = wiki_tag.attrib.get("slug", default=None)
xml_object.remove(wiki_tag)
- definition = super(CourseDescriptor, cls).definition_from_xml(xml_object, system)
+ definition, children = super(CourseDescriptor, cls).definition_from_xml(xml_object, system)
- definition.setdefault('data', {})['textbooks'] = textbooks
- definition['data']['wiki_slug'] = wiki_slug
+ definition['textbooks'] = textbooks
+ definition['wiki_slug'] = wiki_slug
- return definition
+ return definition, children
def has_ended(self):
"""
@@ -292,30 +394,6 @@ class CourseDescriptor(SequenceDescriptor):
def has_started(self):
return time.gmtime() > self.start
- @property
- def end(self):
- return self._try_parse_time("end")
- @end.setter
- def end(self, value):
- if isinstance(value, time.struct_time):
- self.metadata['end'] = stringify_time(value)
- @property
- def enrollment_start(self):
- return self._try_parse_time("enrollment_start")
-
- @enrollment_start.setter
- def enrollment_start(self, value):
- if isinstance(value, time.struct_time):
- self.metadata['enrollment_start'] = stringify_time(value)
- @property
- def enrollment_end(self):
- return self._try_parse_time("enrollment_end")
-
- @enrollment_end.setter
- def enrollment_end(self, value):
- if isinstance(value, time.struct_time):
- self.metadata['enrollment_end'] = stringify_time(value)
-
@property
def grader(self):
return grader_from_conf(self.raw_grader)
@@ -328,7 +406,7 @@ class CourseDescriptor(SequenceDescriptor):
def raw_grader(self, value):
# NOTE WELL: this change will not update the processed graders. If we need that, this needs to call grader_from_conf
self._grading_policy['RAW_GRADER'] = value
- self.definition['data'].setdefault('grading_policy', {})['GRADER'] = value
+ self.grading_policy['GRADER'] = value
@property
def grade_cutoffs(self):
@@ -337,48 +415,23 @@ class CourseDescriptor(SequenceDescriptor):
@grade_cutoffs.setter
def grade_cutoffs(self, value):
self._grading_policy['GRADE_CUTOFFS'] = value
- self.definition['data'].setdefault('grading_policy', {})['GRADE_CUTOFFS'] = value
+
+ # XBlock fields don't update after mutation
+ policy = self.grading_policy
+ policy['GRADE_CUTOFFS'] = value
+ self.grading_policy = policy
@property
def lowest_passing_grade(self):
return min(self._grading_policy['GRADE_CUTOFFS'].values())
- @property
- def tabs(self):
- """
- Return the tabs config, as a python object, or None if not specified.
- """
- return self.metadata.get('tabs')
-
- @property
- def pdf_textbooks(self):
- """
- Return the pdf_textbooks config, as a python object, or None if not specified.
- """
- return self.metadata.get('pdf_textbooks', [])
-
- @property
- def html_textbooks(self):
- """
- Return the html_textbooks config, as a python object, or None if not specified.
- """
- return self.metadata.get('html_textbooks', [])
-
- @tabs.setter
- def tabs(self, value):
- self.metadata['tabs'] = value
-
- @property
- def show_calculator(self):
- return self.metadata.get("show_calculator", None) == "Yes"
-
@property
def is_cohorted(self):
"""
Return whether the course is cohorted.
"""
- config = self.metadata.get("cohort_config")
+ config = self.cohort_config
if config is None:
return False
@@ -392,7 +445,7 @@ class CourseDescriptor(SequenceDescriptor):
if not self.is_cohorted:
return False
- return bool(self.metadata.get("cohort_config", {}).get(
+ return bool(self.cohort_config.get(
"auto_cohort", False))
@property
@@ -402,8 +455,10 @@ class CourseDescriptor(SequenceDescriptor):
specified. Returns specified list even if is_cohorted and/or auto_cohort are
false.
"""
- return self.metadata.get("cohort_config", {}).get(
- "auto_cohort_groups", [])
+ if self.cohort_config is None:
+ return []
+ else:
+ return self.cohort_config.get("auto_cohort_groups", [])
@property
@@ -411,7 +466,7 @@ class CourseDescriptor(SequenceDescriptor):
"""
Return list of topic ids defined in course policy.
"""
- topics = self.metadata.get("discussion_topics", {})
+ topics = self.discussion_topics
return [d["id"] for d in topics.values()]
@@ -422,7 +477,7 @@ class CourseDescriptor(SequenceDescriptor):
the empty set. Note that all inline discussions are automatically
cohorted based on the course's is_cohorted setting.
"""
- config = self.metadata.get("cohort_config")
+ config = self.cohort_config
if config is None:
return set()
@@ -431,13 +486,13 @@ class CourseDescriptor(SequenceDescriptor):
@property
- def is_new(self):
+ def is_newish(self):
"""
- Returns if the course has been flagged as new in the metadata. If
+ Returns if the course has been flagged as new. If
there is no flag, return a heuristic value considering the
announcement and the start dates.
"""
- flag = self.metadata.get('is_new', None)
+ flag = self.is_new
if flag is None:
# Use a heuristic if the course has not been flagged
announcement, start, now = self._sorting_dates()
@@ -457,8 +512,8 @@ class CourseDescriptor(SequenceDescriptor):
@property
def sorting_score(self):
"""
- Returns a number that can be used to sort the courses according
- the how "new"" they are. The "newness"" score is computed using a
+ Returns a tuple that can be used to sort the courses according
+ the how "new" they are. The "newness" score is computed using a
heuristic that takes into account the announcement and
(advertized) start dates of the course if available.
@@ -483,12 +538,13 @@ class CourseDescriptor(SequenceDescriptor):
def to_datetime(timestamp):
return datetime(*timestamp[:6])
- def get_date(field):
- timetuple = self._try_parse_time(field)
- return to_datetime(timetuple) if timetuple else None
-
- announcement = get_date('announcement')
- start = get_date('advertised_start') or to_datetime(self.start)
+ announcement = self.announcement
+ if announcement is not None:
+ announcement = to_datetime(announcement)
+ if self.advertised_start is None or isinstance(self.advertised_start, basestring):
+ start = to_datetime(self.start)
+ else:
+ start = to_datetime(self.advertised_start)
now = to_datetime(time.gmtime())
return announcement, start, now
@@ -513,7 +569,7 @@ class CourseDescriptor(SequenceDescriptor):
all_descriptors - This contains a list of all xmodules that can
effect grading a student. This is used to efficiently fetch
- all the xmodule state for a StudentModuleCache without walking
+ all the xmodule state for a ModelDataCache without walking
the descriptor tree again.
@@ -531,14 +587,14 @@ class CourseDescriptor(SequenceDescriptor):
for c in self.get_children():
sections = []
for s in c.get_children():
- if s.metadata.get('graded', False):
+ if s.lms.graded:
xmoduledescriptors = list(yield_descriptor_descendents(s))
xmoduledescriptors.append(s)
# The xmoduledescriptors included here are only the ones that have scores.
section_description = {'section_descriptor': s, 'xmoduledescriptors': filter(lambda child: child.has_score, xmoduledescriptors)}
- section_format = s.metadata.get('format', "")
+ section_format = s.lms.format if s.lms.format is not None else ''
graded_sections[section_format] = graded_sections.get(section_format, []) + [section_description]
all_descriptors.extend(xmoduledescriptors)
@@ -579,58 +635,23 @@ class CourseDescriptor(SequenceDescriptor):
@property
def start_date_text(self):
- parsed_advertised_start = self._try_parse_time('advertised_start')
-
- # If the advertised start isn't a real date string, we assume it's free
- # form text...
- if parsed_advertised_start is None and \
- ('advertised_start' in self.metadata):
- return self.metadata['advertised_start']
-
- displayed_start = parsed_advertised_start or self.start
-
- # If we have neither an advertised start or a real start, just return TBD
- if not displayed_start:
- return "TBD"
-
- return time.strftime("%b %d, %Y", displayed_start)
+ if isinstance(self.advertised_start, basestring):
+ return self.advertised_start
+ elif self.advertised_start is None and self.start is None:
+ return 'TBD'
+ else:
+ return time.strftime("%b %d, %Y", self.advertised_start or self.start)
@property
def end_date_text(self):
return time.strftime("%b %d, %Y", self.end)
- # An extra property is used rather than the wiki_slug/number because
- # there are courses that change the number for different runs. This allows
- # courses to share the same css_class across runs even if they have
- # different numbers.
- #
- # TODO get rid of this as soon as possible or potentially build in a robust
- # way to add in course-specific styling. There needs to be a discussion
- # about the right way to do this, but arjun will address this ASAP. Also
- # note that the courseware template needs to change when this is removed.
- @property
- def css_class(self):
- return self.metadata.get('css_class', '')
-
- @property
- def info_sidebar_name(self):
- return self.metadata.get('info_sidebar_name', 'Course Handouts')
-
- @property
- def discussion_link(self):
- """TODO: This is a quick kludge to allow CS50 (and other courses) to
- specify their own discussion forums as external links by specifying a
- "discussion_link" in their policy JSON file. This should later get
- folded in with Syllabus, Course Info, and additional Custom tabs in a
- more sensible framework later."""
- return self.metadata.get('discussion_link', None)
-
@property
def forum_posts_allowed(self):
try:
blackout_periods = [(parse_time(start), parse_time(end))
for start, end
- in self.metadata.get('discussion_blackouts', [])]
+ in self.discussion_blackouts]
now = time.gmtime()
for start, end in blackout_periods:
if start <= now <= end:
@@ -640,23 +661,6 @@ class CourseDescriptor(SequenceDescriptor):
return True
- @property
- def hide_progress_tab(self):
- """TODO: same as above, intended to let internal CS50 hide the progress tab
- until we get grade integration set up."""
- # Explicit comparison to True because we always want to return a bool.
- return self.metadata.get('hide_progress_tab') == True
-
- @property
- def end_of_course_survey_url(self):
- """
- Pull from policy. Once we have our own survey module set up, can change this to point to an automatically
- created survey for each class.
-
- Returns None if no url specified.
- """
- return self.metadata.get('end_of_course_survey_url')
-
class TestCenterExam(object):
def __init__(self, course_id, exam_name, exam_info):
self.course_id = course_id
@@ -743,10 +747,6 @@ class CourseDescriptor(SequenceDescriptor):
exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code]
return exams[0] if len(exams) == 1 else None
- @property
- def title(self):
- return self.display_name
-
@property
def number(self):
return self.location.course
diff --git a/common/lib/xmodule/xmodule/css/poll/display.scss b/common/lib/xmodule/xmodule/css/poll/display.scss
new file mode 100644
index 0000000000..cfc03bcf91
--- /dev/null
+++ b/common/lib/xmodule/xmodule/css/poll/display.scss
@@ -0,0 +1,221 @@
+section.poll_question {
+ @media print {
+ display: block;
+ width: auto;
+ padding: 0;
+
+ canvas, img {
+ page-break-inside: avoid;
+ }
+ }
+
+ .inline {
+ display: inline;
+ }
+
+ h3 {
+ margin-top: 0;
+ margin-bottom: 15px;
+ color: #fe57a1;
+ font-size: 1.9em;
+
+ &.problem-header {
+ section.staff {
+ margin-top: 30px;
+ font-size: 80%;
+ }
+ }
+
+ @media print {
+ display: block;
+ width: auto;
+ border-right: 0;
+ }
+ }
+
+ p {
+ text-align: justify;
+ font-weight: bold;
+ }
+
+ .poll_answer {
+ margin-bottom: 20px;
+
+ &.short {
+ clear: both;
+ }
+
+ .question {
+ height: auto;
+ clear: both;
+ min-height: 30px;
+
+ &.short {
+ clear: none;
+ width: 30%;
+ display: inline;
+ float: left;
+ }
+
+ .button {
+ -webkit-appearance: none;
+ -webkit-background-clip: padding-box;
+ -webkit-border-image: none;
+ -webkit-box-align: center;
+ -webkit-box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset;
+ -webkit-font-smoothing: antialiased;
+ -webkit-rtl-ordering: logical;
+ -webkit-user-select: text;
+ -webkit-writing-mode: horizontal-tb;
+ background-clip: padding-box;
+ background-color: rgb(238, 238, 238);
+ background-image: -webkit-linear-gradient(top, rgb(238, 238, 238), rgb(210, 210, 210));
+ border-bottom-color: rgb(202, 202, 202);
+ border-bottom-left-radius: 3px;
+ border-bottom-right-radius: 3px;
+ border-bottom-style: solid;
+ border-bottom-width: 1px;
+ border-left-color: rgb(202, 202, 202);
+ border-left-style: solid;
+ border-left-width: 1px;
+ border-right-color: rgb(202, 202, 202);
+ border-right-style: solid;
+ border-right-width: 1px;
+ border-top-color: rgb(202, 202, 202);
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+ border-top-style: solid;
+ border-top-width: 1px;
+ box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset;
+ box-sizing: border-box;
+ color: rgb(51, 51, 51);
+ cursor: pointer;
+
+ /* display: inline-block; */
+ display: inline;
+ float: left;
+
+ font-family: 'Open Sans', Verdana, Geneva, sans-serif;
+ font-size: 13px;
+ font-style: normal;
+ font-variant: normal;
+ font-weight: bold;
+
+ letter-spacing: normal;
+ line-height: 25.59375px;
+ margin-bottom: 15px;
+ margin: 0px;
+ padding: 0px;
+ text-align: center;
+ text-decoration: none;
+ text-indent: 0px;
+ text-shadow: rgb(248, 248, 248) 0px 1px 0px;
+ text-transform: none;
+ vertical-align: top;
+ white-space: pre-line;
+
+ width: 25px;
+ height: 25px;
+
+ word-spacing: 0px;
+ writing-mode: lr-tb;
+ }
+ .button.answered {
+ -webkit-box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
+ background-color: rgb(29, 157, 217);
+ background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176));
+ border-bottom-color: rgb(13, 114, 162);
+ border-left-color: rgb(13, 114, 162);
+ border-right-color: rgb(13, 114, 162);
+ border-top-color: rgb(13, 114, 162);
+ box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
+ color: rgb(255, 255, 255);
+ text-shadow: rgb(7, 103, 148) 0px 1px 0px;
+ }
+
+ .text {
+ display: inline;
+ float: left;
+ width: 80%;
+ text-align: left;
+ min-height: 30px;
+ margin-left: 20px;
+ height: auto;
+ margin-bottom: 20px;
+ cursor: pointer;
+
+ &.short {
+ width: 100px;
+ }
+ }
+ }
+
+ .stats {
+ min-height: 40px;
+ margin-top: 20px;
+ clear: both;
+
+ &.short {
+ margin-top: 0;
+ clear: none;
+ display: inline;
+ float: right;
+ width: 70%;
+ }
+
+ .bar {
+ width: 75%;
+ height: 20px;
+ border: 1px solid black;
+ display: inline;
+ float: left;
+ margin-right: 10px;
+
+ &.short {
+ width: 65%;
+ height: 20px;
+ margin-top: 3px;
+ }
+
+ .percent {
+ background-color: gray;
+ width: 0px;
+ height: 20px;
+
+ &.short { }
+ }
+ }
+
+ .number {
+ width: 80px;
+ display: inline;
+ float: right;
+ height: 28px;
+ text-align: right;
+
+ &.short {
+ width: 120px;
+ height: auto;
+ }
+ }
+ }
+ }
+
+ .poll_answer.answered {
+ -webkit-box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
+ background-color: rgb(29, 157, 217);
+ background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176));
+ border-bottom-color: rgb(13, 114, 162);
+ border-left-color: rgb(13, 114, 162);
+ border-right-color: rgb(13, 114, 162);
+ border-top-color: rgb(13, 114, 162);
+ box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
+ color: rgb(255, 255, 255);
+ text-shadow: rgb(7, 103, 148) 0px 1px 0px;
+ }
+
+ .button.reset-button {
+ clear: both;
+ float: right;
+ }
+}
diff --git a/cms/djangoapps/__init__.py b/common/lib/xmodule/xmodule/css/wrapper/display.scss
similarity index 100%
rename from cms/djangoapps/__init__.py
rename to common/lib/xmodule/xmodule/css/wrapper/display.scss
diff --git a/common/lib/xmodule/xmodule/discussion_module.py b/common/lib/xmodule/xmodule/discussion_module.py
index 6ddfcbe6c0..7725a88e77 100644
--- a/common/lib/xmodule/xmodule/discussion_module.py
+++ b/common/lib/xmodule/xmodule/discussion_module.py
@@ -3,35 +3,38 @@ from pkg_resources import resource_string, resource_listdir
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
-
-import json
+from xblock.core import String, Scope
-class DiscussionModule(XModule):
+class DiscussionFields(object):
+ discussion_id = String(scope=Scope.settings)
+ discussion_category = String(scope=Scope.settings)
+ discussion_target = String(scope=Scope.settings)
+ sort_key = String(scope=Scope.settings)
+
+
+class DiscussionModule(DiscussionFields, XModule):
js = {'coffee':
[resource_string(__name__, 'js/src/time.coffee'),
resource_string(__name__, 'js/src/discussion/display.coffee')]
}
js_module_name = "InlineDiscussion"
+
+
def get_html(self):
context = {
'discussion_id': self.discussion_id,
}
return self.system.render_template('discussion/_discussion_module.html', context)
- def __init__(self, system, location, definition, descriptor,
- instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
- if isinstance(instance_state, str):
- instance_state = json.loads(instance_state)
- xml_data = etree.fromstring(definition['data'])
- self.discussion_id = xml_data.attrib['id']
- self.title = xml_data.attrib['for']
- self.discussion_category = xml_data.attrib['discussion_category']
-
-
-class DiscussionDescriptor(RawDescriptor):
+class DiscussionDescriptor(DiscussionFields, RawDescriptor):
module_class = DiscussionModule
template_dir_name = "discussion"
+
+ # 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
+ metadata_translations = dict(RawDescriptor.metadata_translations)
+ metadata_translations['id'] = 'discussion_id'
+ metadata_translations['for'] = 'discussion_target'
diff --git a/common/lib/xmodule/xmodule/editing_module.py b/common/lib/xmodule/xmodule/editing_module.py
index e025179b63..b93727a96b 100644
--- a/common/lib/xmodule/xmodule/editing_module.py
+++ b/common/lib/xmodule/xmodule/editing_module.py
@@ -1,11 +1,16 @@
from pkg_resources import resource_string
from xmodule.mako_module import MakoModuleDescriptor
+from xblock.core import Scope, String
import logging
log = logging.getLogger(__name__)
-class EditingDescriptor(MakoModuleDescriptor):
+class EditingFields(object):
+ data = String(scope=Scope.content, default='')
+
+
+class EditingDescriptor(EditingFields, MakoModuleDescriptor):
"""
Module that provides a raw editing view of its data and children. It does not
perform any validation on its definition---just passes it along to the browser.
@@ -20,7 +25,7 @@ class EditingDescriptor(MakoModuleDescriptor):
def get_context(self):
_context = MakoModuleDescriptor.get_context(self)
# Add our specific template information (the raw data body)
- _context.update({'data': self.definition.get('data', '')})
+ _context.update({'data': self.data})
return _context
diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py
index 2df47e05e6..d2135302da 100644
--- a/common/lib/xmodule/xmodule/error_module.py
+++ b/common/lib/xmodule/xmodule/error_module.py
@@ -8,6 +8,7 @@ from xmodule.x_module import XModule
from xmodule.editing_module import JSONEditingDescriptor
from xmodule.errortracker import exc_info_to_str
from xmodule.modulestore import Location
+from xblock.core import String, Scope
log = logging.getLogger(__name__)
@@ -20,7 +21,14 @@ log = logging.getLogger(__name__)
# decides whether to create a staff or not-staff module.
-class ErrorModule(XModule):
+class ErrorFields(object):
+ contents = String(scope=Scope.content)
+ error_msg = String(scope=Scope.content)
+ display_name = String(scope=Scope.settings)
+
+
+class ErrorModule(ErrorFields, XModule):
+
def get_html(self):
'''Show an error to staff.
TODO (vshnayder): proper style, divs, etc.
@@ -28,12 +36,12 @@ class ErrorModule(XModule):
# staff get to see all the details
return self.system.render_template('module-error.html', {
'staff_access': True,
- 'data': self.definition['data']['contents'],
- 'error': self.definition['data']['error_msg'],
+ 'data': self.contents,
+ 'error': self.error_msg,
})
-class NonStaffErrorModule(XModule):
+class NonStaffErrorModule(ErrorFields, XModule):
def get_html(self):
'''Show an error to a student.
TODO (vshnayder): proper style, divs, etc.
@@ -46,7 +54,7 @@ class NonStaffErrorModule(XModule):
})
-class ErrorDescriptor(JSONEditingDescriptor):
+class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
"""
Module that provides a raw editing view of broken xml.
"""
@@ -66,26 +74,22 @@ class ErrorDescriptor(JSONEditingDescriptor):
name=hashlib.sha1(contents).hexdigest()
)
- definition = {
- 'data': {
- 'error_msg': str(error_msg),
- 'contents': contents,
- }
- }
-
# real metadata stays in the content, but add a display name
- metadata = {'display_name': 'Error: ' + location.name}
+ model_data = {
+ 'error_msg': str(error_msg),
+ 'contents': contents,
+ 'display_name': 'Error: ' + location.name
+ }
return ErrorDescriptor(
system,
- definition,
- location=location,
- metadata=metadata
+ location,
+ model_data,
)
def get_context(self):
return {
'module': self,
- 'data': self.definition['data']['contents'],
+ 'data': self.contents,
}
@classmethod
@@ -101,10 +105,7 @@ class ErrorDescriptor(JSONEditingDescriptor):
def from_descriptor(cls, descriptor, error_msg='Error not available'):
return cls._construct(
descriptor.system,
- json.dumps({
- 'definition': descriptor.definition,
- 'metadata': descriptor.metadata,
- }, indent=4),
+ descriptor._model_data,
error_msg,
location=descriptor.location,
)
@@ -148,14 +149,14 @@ class ErrorDescriptor(JSONEditingDescriptor):
files, etc. That would just get re-wrapped on import.
'''
try:
- xml = etree.fromstring(self.definition['data']['contents'])
+ xml = etree.fromstring(self.contents)
return etree.tostring(xml, encoding='unicode')
except etree.XMLSyntaxError:
# still not valid.
root = etree.Element('error')
- root.text = self.definition['data']['contents']
+ root.text = self.contents
err_node = etree.SubElement(root, 'error_msg')
- err_node.text = self.definition['data']['error_msg']
+ err_node.text = self.error_msg
return etree.tostring(root, encoding='unicode')
diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py
new file mode 100644
index 0000000000..fb80752e56
--- /dev/null
+++ b/common/lib/xmodule/xmodule/fields.py
@@ -0,0 +1,69 @@
+import time
+import logging
+import re
+
+from datetime import timedelta
+from xblock.core import ModelType
+
+log = logging.getLogger(__name__)
+
+
+class Date(ModelType):
+ time_format = "%Y-%m-%dT%H:%M"
+
+ def from_json(self, value):
+ """
+ Parse an optional metadata key containing a time: if present, complain
+ if it doesn't parse.
+ Return None if not present or invalid.
+ """
+ if value is None:
+ return None
+
+ try:
+ return time.strptime(value, self.time_format)
+ except ValueError as e:
+ msg = "Field {0} has bad value '{1}': '{2}'".format(
+ self._name, value, e)
+ log.warning(msg)
+ return None
+
+ def to_json(self, value):
+ """
+ Convert a time struct to a string
+ """
+ if value is None:
+ return None
+
+ return time.strftime(self.time_format, value)
+
+
+TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$')
+class Timedelta(ModelType):
+ def from_json(self, time_str):
+ """
+ time_str: A string with the following components:
+ day[s] (optional)
+ hour[s] (optional)
+ minute[s] (optional)
+ second[s] (optional)
+
+ Returns a datetime.timedelta parsed from the string
+ """
+ parts = TIMEDELTA_REGEX.match(time_str)
+ if not parts:
+ return
+ parts = parts.groupdict()
+ time_params = {}
+ for (name, param) in parts.iteritems():
+ if param:
+ time_params[name] = int(param)
+ return timedelta(**time_params)
+
+ def to_json(self, value):
+ values = []
+ for attr in ('days', 'hours', 'minutes', 'seconds'):
+ cur_value = getattr(value, attr, 0)
+ if cur_value > 0:
+ values.append("%d %s" % (cur_value, attr))
+ return ' '.join(values)
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py
index 88e29b4203..884f9e2df2 100644
--- a/common/lib/xmodule/xmodule/foldit_module.py
+++ b/common/lib/xmodule/xmodule/foldit_module.py
@@ -7,17 +7,27 @@ from pkg_resources import resource_string
from xmodule.editing_module import EditingDescriptor
from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor
+from xblock.core import Scope, Integer, String
log = logging.getLogger(__name__)
-class FolditModule(XModule):
+
+class FolditFields(object):
+ # default to what Spring_7012x uses
+ required_level = Integer(default=4, scope=Scope.settings)
+ required_sublevel = Integer(default=5, scope=Scope.settings)
+ due = String(help="Date that this problem is due by", scope=Scope.settings, default='')
+
+ show_basic_score = String(scope=Scope.settings, default='false')
+ show_leaderboard = String(scope=Scope.settings, default='false')
+
+
+class FolditModule(FolditFields, XModule):
css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]}
- def __init__(self, system, location, definition, descriptor,
- instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
+ def __init__(self, *args, **kwargs):
+ XModule.__init__(self, *args, **kwargs)
"""
Example:
@@ -26,25 +36,17 @@ class FolditModule(XModule):
required_sublevel="3"
show_leaderboard="false"/>
"""
- req_level = self.metadata.get("required_level")
- req_sublevel = self.metadata.get("required_sublevel")
-
- # default to what Spring_7012x uses
- self.required_level = req_level if req_level else 4
- self.required_sublevel = req_sublevel if req_sublevel else 5
-
def parse_due_date():
"""
Pull out the date, or None
"""
- s = self.metadata.get("due")
+ s = self.due
if s:
return parser.parse(s)
else:
return None
- self.due_str = self.metadata.get("due", "None")
- self.due = parse_due_date()
+ self.due_time = parse_due_date()
def is_complete(self):
"""
@@ -59,7 +61,7 @@ class FolditModule(XModule):
self.system.anonymous_student_id,
self.required_level,
self.required_sublevel,
- self.due)
+ self.due_time)
return complete
def completed_puzzles(self):
@@ -99,11 +101,11 @@ class FolditModule(XModule):
self.required_level,
self.required_sublevel)
- showbasic = (self.metadata.get("show_basic_score", "").lower() == "true")
- showleader = (self.metadata.get("show_leaderboard", "").lower() == "true")
+ showbasic = (self.show_basic_score.lower() == "true")
+ showleader = (self.show_leaderboard.lower() == "true")
context = {
- 'due': self.due_str,
+ 'due': self.due,
'success': self.is_complete(),
'goal_level': goal_level,
'completed': self.completed_puzzles(),
@@ -125,7 +127,7 @@ class FolditModule(XModule):
self.required_sublevel)
context = {
- 'due': self.due_str,
+ 'due': self.due,
'success': self.is_complete(),
'goal_level': goal_level,
'completed': self.completed_puzzles(),
@@ -155,7 +157,7 @@ class FolditModule(XModule):
-class FolditDescriptor(XmlDescriptor, EditingDescriptor):
+class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor):
"""
Module for adding Foldit problems to courses
"""
@@ -176,7 +178,8 @@ class FolditDescriptor(XmlDescriptor, EditingDescriptor):
@classmethod
def definition_from_xml(cls, xml_object, system):
- """
- Get the xml_object's attributes.
- """
- return {'metadata': xml_object.attrib}
+ return ({}, [])
+
+ def definition_to_xml(self):
+ xml_object = etree.Element('foldit')
+ return xml_object
diff --git a/common/lib/xmodule/xmodule/gst_module.py b/common/lib/xmodule/xmodule/gst_module.py
index ef1be96c84..00e8cf1f10 100644
--- a/common/lib/xmodule/xmodule/gst_module.py
+++ b/common/lib/xmodule/xmodule/gst_module.py
@@ -14,12 +14,18 @@ from xmodule.xml_module import XmlDescriptor
from xmodule.x_module import XModule
from xmodule.stringify import stringify_children
from pkg_resources import resource_string
+from xblock.core import String, Scope
log = logging.getLogger(__name__)
-class GraphicalSliderToolModule(XModule):
+class GraphicalSliderToolFields(object):
+ render = String(scope=Scope.content)
+ configuration = String(scope=Scope.content)
+
+
+class GraphicalSliderToolModule(GraphicalSliderToolFields, XModule):
''' Graphical-Slider-Tool Module
'''
@@ -43,15 +49,6 @@ class GraphicalSliderToolModule(XModule):
}
js_module_name = "GraphicalSliderTool"
- def __init__(self, system, location, definition, descriptor, instance_state=None,
- shared_state=None, **kwargs):
- """
- For XML file format please look at documentation. TODO - receive
- information where to store XML documentation.
- """
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
-
def get_html(self):
""" Renders parameters to template. """
@@ -60,14 +57,14 @@ class GraphicalSliderToolModule(XModule):
self.html_class = self.location.category
self.configuration_json = self.build_configuration_json()
params = {
- 'gst_html': self.substitute_controls(self.definition['render']),
+ 'gst_html': self.substitute_controls(self.render),
'element_id': self.html_id,
'element_class': self.html_class,
'configuration_json': self.configuration_json
}
- self.content = self.system.render_template(
+ content = self.system.render_template(
'graphical_slider_tool.html', params)
- return self.content
+ return content
def substitute_controls(self, html_string):
""" Substitutes control elements (slider, textbox and plot) in
@@ -139,10 +136,10 @@ class GraphicalSliderToolModule(XModule):
# added for interface compatibility with xmltodict.parse
# class added for javascript's part purposes
return json.dumps(xmltodict.parse('' + self.definition['configuration'] + ''))
+ '">' + self.configuration + ''))
-class GraphicalSliderToolDescriptor(MakoModuleDescriptor, XmlDescriptor):
+class GraphicalSliderToolDescriptor(GraphicalSliderToolFields, MakoModuleDescriptor, XmlDescriptor):
module_class = GraphicalSliderToolModule
template_dir_name = 'graphical_slider_tool'
@@ -177,14 +174,14 @@ class GraphicalSliderToolDescriptor(MakoModuleDescriptor, XmlDescriptor):
return {
'render': parse('render'),
'configuration': parse('configuration')
- }
+ }, []
def definition_to_xml(self, resource_fs):
'''Return an xml element representing this definition.'''
xml_object = etree.Element('graphical_slider_tool')
def add_child(k):
- child_str = '<{tag}>{body}{tag}>'.format(tag=k, body=self.definition[k])
+ child_str = '<{tag}>{body}{tag}>'.format(tag=k, body=getattr(self, k))
child_node = etree.fromstring(child_str)
xml_object.append(child_node)
diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py
index 456ea3cf10..e9cec32e3e 100644
--- a/common/lib/xmodule/xmodule/html_module.py
+++ b/common/lib/xmodule/xmodule/html_module.py
@@ -7,10 +7,9 @@ from lxml import etree
from path import path
from pkg_resources import resource_string
-from xmodule.contentstore.content import XASSET_SRCREF_PREFIX, StaticContent
+from xblock.core import Scope, String
from xmodule.editing_module import EditingDescriptor
from xmodule.html_checker import check_html
-from xmodule.modulestore import Location
from xmodule.stringify import stringify_children
from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor, name_to_pathname
@@ -18,7 +17,11 @@ from xmodule.xml_module import XmlDescriptor, name_to_pathname
log = logging.getLogger("mitx.courseware")
-class HtmlModule(XModule):
+class HtmlFields(object):
+ data = String(help="Html contents to display for this module", scope=Scope.content)
+
+
+class HtmlModule(HtmlFields, XModule):
js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/html/display.coffee')
@@ -28,17 +31,10 @@ class HtmlModule(XModule):
css = {'scss': [resource_string(__name__, 'css/html/display.scss')]}
def get_html(self):
- return self.html
-
- def __init__(self, system, location, definition, descriptor,
- instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
- self.html = self.definition['data']
+ return self.data
-
-class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
+class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
"""
Module for putting raw html in a course
"""
@@ -91,7 +87,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
if filename is None:
definition_xml = copy.deepcopy(xml_object)
cls.clean_metadata_from_xml(definition_xml)
- return {'data': stringify_children(definition_xml)}
+ return {'data': stringify_children(definition_xml)}, []
else:
# html is special. cls.filename_extension is 'xml', but
# if 'filename' is in the definition, that means to load
@@ -105,8 +101,6 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
filepath = "{base}/{name}.html".format(base=base, name=filename)
#log.debug("looking for html file for {0} at {1}".format(location, filepath))
-
-
# VS[compat]
# TODO (cpennington): If the file doesn't exist at the right path,
# give the class a chance to fix it up. The file will be written out
@@ -135,7 +129,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
# for Fall 2012 LMS migration: keep filename (and unmangled filename)
definition['filename'] = [filepath, filename]
- return definition
+ return definition, []
except (ResourceNotFoundError) as err:
msg = 'Unable to load file contents at path {0}: {1} '.format(
@@ -151,19 +145,18 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
string to filename.html.
'''
try:
- return etree.fromstring(self.definition['data'])
+ return etree.fromstring(self.data)
except etree.XMLSyntaxError:
pass
# Not proper format. Write html to file, return an empty tag
pathname = name_to_pathname(self.url_name)
- pathdir = path(pathname).dirname()
filepath = u'{category}/{pathname}.html'.format(category=self.category,
pathname=pathname)
resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True)
with resource_fs.open(filepath, 'w') as file:
- file.write(self.definition['data'].encode('utf-8'))
+ file.write(self.data.encode('utf-8'))
# write out the relative name
relname = path(pathname).basename()
@@ -175,8 +168,11 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
@property
def editable_metadata_fields(self):
"""Remove any metadata from the editable fields which have their own editor or shouldn't be edited by user."""
- subset = [field for field in super(HtmlDescriptor,self).editable_metadata_fields
- if field not in ['empty']]
+ subset = super(HtmlDescriptor, self).editable_metadata_fields
+
+ if 'empty' in subset:
+ del subset['empty']
+
return subset
diff --git a/common/lib/xmodule/xmodule/js/src/conditional/display.coffee b/common/lib/xmodule/xmodule/js/src/conditional/display.coffee
index 33dcb29079..857424c1dc 100644
--- a/common/lib/xmodule/xmodule/js/src/conditional/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/conditional/display.coffee
@@ -1,26 +1,35 @@
class @Conditional
- constructor: (element) ->
+ constructor: (element, callerElId) ->
@el = $(element).find('.conditional-wrapper')
- @id = @el.data('problem-id')
- @element_id = @el.attr('id')
+
+ @callerElId = callerElId
+
+ if callerElId isnt undefined
+ dependencies = @el.data('depends')
+ if (typeof dependencies is 'string') and (dependencies.length > 0) and (dependencies.indexOf(callerElId) is -1)
+ return
+
@url = @el.data('url')
- @render()
+ @render(element)
- $: (selector) ->
- $(selector, @el)
-
- updateProgress: (response) =>
- if response.progress_changed
- @el.attr progress: response.progress_status
- @el.trigger('progressChanged')
-
- render: (content) ->
- if content
- @el.html(content)
- XModule.loadModules(@el)
- else
+ render: (element) ->
$.postWithPrefix "#{@url}/conditional_get", (response) =>
- @el.html(response.html)
- XModule.loadModules(@el)
+ @el.html ''
+ @el.append(i) for i in response.html
+ parentEl = $(element).parent()
+ parentId = parentEl.attr 'id'
+
+ if response.message is false
+ if parentId.indexOf('vert') is 0
+ parentEl.hide()
+ else
+ $(element).hide()
+ else
+ if parentId.indexOf('vert') is 0
+ parentEl.show()
+ else
+ $(element).show()
+
+ XModule.loadModules @el
diff --git a/common/lib/xmodule/xmodule/js/src/poll/logme.js b/common/lib/xmodule/xmodule/js/src/poll/logme.js
new file mode 100644
index 0000000000..c045757044
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/poll/logme.js
@@ -0,0 +1,54 @@
+// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
+// define() functions from Require JS available inside the anonymous function.
+(function (requirejs, require, define) {
+
+define('logme', [], function () {
+ var debugMode;
+
+ // debugMode can be one of the following:
+ //
+ // true - All messages passed to logme will be written to the internal
+ // browser console.
+ // false - Suppress all output to the internal browser console.
+ //
+ // Obviously, if anywhere there is a direct console.log() call, we can't do
+ // anything about it. That's why use logme() - it will allow to turn off
+ // the output of debug information with a single change to a variable.
+ debugMode = true;
+
+ return logme;
+
+ /*
+ * function: logme
+ *
+ * A helper function that provides logging facilities. We don't want
+ * to call console.log() directly, because sometimes it is not supported
+ * by the browser. Also when everything is routed through this function.
+ * the logging output can be easily turned off.
+ *
+ * logme() supports multiple parameters. Each parameter will be passed to
+ * console.log() function separately.
+ *
+ */
+ function logme() {
+ var i;
+
+ if (
+ (typeof debugMode === 'undefined') ||
+ (debugMode !== true) ||
+ (typeof window.console === 'undefined')
+ ) {
+ return;
+ }
+
+ for (i = 0; i < arguments.length; i++) {
+ window.console.log(arguments[i]);
+ }
+ } // End-of: function logme
+});
+
+// End of wrapper for RequireJS. As you can see, we are passing
+// namespaced Require JS variables to an anonymous function. Within
+// it, you can use the standard requirejs(), require(), and define()
+// functions as if they were in the global namespace.
+}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
diff --git a/common/lib/xmodule/xmodule/js/src/poll/poll.js b/common/lib/xmodule/xmodule/js/src/poll/poll.js
new file mode 100644
index 0000000000..a2ccbc7c03
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/poll/poll.js
@@ -0,0 +1,5 @@
+window.Poll = function (el) {
+ RequireJS.require(['PollMain'], function (PollMain) {
+ new PollMain(el);
+ });
+};
diff --git a/common/lib/xmodule/xmodule/js/src/poll/poll_main.js b/common/lib/xmodule/xmodule/js/src/poll/poll_main.js
new file mode 100644
index 0000000000..74f2a488d7
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/poll/poll_main.js
@@ -0,0 +1,323 @@
+(function (requirejs, require, define) {
+define('PollMain', ['logme'], function (logme) {
+
+PollMain.prototype = {
+
+'showAnswerGraph': function (poll_answers, total) {
+ var _this, totalValue;
+
+ totalValue = parseFloat(total);
+ if (isFinite(totalValue) === false) {
+ return;
+ }
+
+ _this = this;
+
+ $.each(poll_answers, function (index, value) {
+ var numValue, percentValue;
+
+ numValue = parseFloat(value);
+ if (isFinite(numValue) === false) {
+ return;
+ }
+
+ percentValue = (numValue / totalValue) * 100.0;
+
+ _this.answersObj[index].statsEl.show();
+ _this.answersObj[index].numberEl.html('' + value + ' (' + percentValue.toFixed(1) + '%)');
+ _this.answersObj[index].percentEl.css({
+ 'width': '' + percentValue.toFixed(1) + '%'
+ });
+ });
+},
+
+'submitAnswer': function (answer, answerObj) {
+ var _this;
+
+ // Make sure that the user can answer a question only once.
+ if (this.questionAnswered === true) {
+ return;
+ }
+ this.questionAnswered = true;
+
+ _this = this;
+
+ console.log('submit answer');
+
+ answerObj.buttonEl.addClass('answered');
+
+ // Send the data to the server as an AJAX request. Attach a callback that will
+ // be fired on server's response.
+ $.postWithPrefix(
+ _this.ajax_url + '/' + answer, {},
+ function (response) {
+ console.log('success! response = ');
+ console.log(response);
+
+ _this.showAnswerGraph(response.poll_answers, response.total);
+
+ if (_this.canReset === true) {
+ _this.resetButton.show();
+ }
+
+ // Initialize Conditional constructors.
+ if (_this.wrapperSectionEl !== null) {
+ $(_this.wrapperSectionEl).find('.xmodule_ConditionalModule').each(function (index, value) {
+ new window.Conditional(value, _this.id.replace(/^poll_/, ''));
+ });
+ }
+ }
+ );
+
+}, // End-of: 'submitAnswer': function (answer, answerEl) {
+
+
+'submitReset': function () {
+ var _this;
+
+ _this = this;
+
+ console.log('submit reset');
+
+ // Send the data to the server as an AJAX request. Attach a callback that will
+ // be fired on server's response.
+ $.postWithPrefix(
+ this.ajax_url + '/' + 'reset_poll',
+ {},
+ function (response) {
+ console.log('success! response = ');
+ console.log(response);
+
+ if (
+ (response.hasOwnProperty('status') !== true) ||
+ (typeof response.status !== 'string') ||
+ (response.status.toLowerCase() !== 'success')) {
+ return;
+ }
+
+ _this.questionAnswered = false;
+ _this.questionEl.find('.button.answered').removeClass('answered');
+ _this.questionEl.find('.stats').hide();
+ _this.resetButton.hide();
+
+ // Initialize Conditional constructors. We will specify the third parameter as 'true'
+ // notifying the constructor that this is a reset operation.
+ if (_this.wrapperSectionEl !== null) {
+ $(_this.wrapperSectionEl).find('.xmodule_ConditionalModule').each(function (index, value) {
+ new window.Conditional(value, _this.id.replace(/^poll_/, ''));
+ });
+ }
+ }
+ );
+}, // End-of: 'submitAnswer': function (answer, answerEl) {
+
+'postInit': function () {
+ var _this;
+
+ // Access this object inside inner functions.
+ _this = this;
+
+ if (
+ (this.jsonConfig.poll_answer.length > 0) &&
+ (this.jsonConfig.answers.hasOwnProperty(this.jsonConfig.poll_answer) === false)
+ ) {
+ this.questionEl.append(
+ '
Error!
' +
+ '
XML data format changed. List of answers was modified, but poll data was not updated.
'
+ );
+
+ return;
+ }
+
+ // Get the DOM id of the question.
+ this.id = this.questionEl.attr('id');
+
+ // Get the URL to which we will post the users answer to the question.
+ this.ajax_url = this.questionEl.data('ajax-url');
+
+ this.questionHtmlMarkup = $('').html(this.jsonConfig.question).text();
+ this.questionEl.append(this.questionHtmlMarkup);
+
+ // When the user selects and answer, we will set this flag to true.
+ this.questionAnswered = false;
+
+ this.answersObj = {};
+ this.shortVersion = true;
+
+ $.each(this.jsonConfig.answers, function (index, value) {
+ if (value.length >= 18) {
+ _this.shortVersion = false;
+ }
+ });
+
+ $.each(this.jsonConfig.answers, function (index, value) {
+ var answer;
+
+ answer = {};
+
+ _this.answersObj[index] = answer;
+
+ answer.el = $('');
+
+ answer.questionEl = $('');
+ answer.buttonEl = $('');
+ answer.textEl = $('');
+ answer.questionEl.append(answer.buttonEl);
+ answer.questionEl.append(answer.textEl);
+
+ answer.el.append(answer.questionEl);
+
+ answer.statsEl = $('');
+ answer.barEl = $('');
+ answer.percentEl = $('');
+ answer.barEl.append(answer.percentEl);
+ answer.numberEl = $('');
+ answer.statsEl.append(answer.barEl);
+ answer.statsEl.append(answer.numberEl);
+
+ answer.statsEl.hide();
+
+ answer.el.append(answer.statsEl);
+
+ answer.textEl.html(value);
+
+ if (_this.shortVersion === true) {
+ $.each(answer, function (index, value) {
+ if (value instanceof jQuery) {
+ value.addClass('short');
+ }
+ });
+ }
+
+ answer.el.appendTo(_this.questionEl);
+
+ answer.textEl.on('click', function () {
+ _this.submitAnswer(index, answer);
+ });
+
+ answer.buttonEl.on('click', function () {
+ _this.submitAnswer(index, answer);
+ });
+
+ if (index === _this.jsonConfig.poll_answer) {
+ answer.buttonEl.addClass('answered');
+ _this.questionAnswered = true;
+ }
+ });
+
+ console.log(this.jsonConfig.reset);
+
+ if ((typeof this.jsonConfig.reset === 'string') && (this.jsonConfig.reset.toLowerCase() === 'true')) {
+ this.canReset = true;
+
+ this.resetButton = $('
Change your vote
');
+
+ if (this.questionAnswered === false) {
+ this.resetButton.hide();
+ }
+
+ this.resetButton.appendTo(this.questionEl);
+
+ this.resetButton.on('click', function () {
+ _this.submitReset();
+ });
+ } else {
+ this.canReset = false;
+ }
+
+ // If it turns out that the user already answered the question, show the answers graph.
+ if (this.questionAnswered === true) {
+ this.showAnswerGraph(this.jsonConfig.poll_answers, this.jsonConfig.total);
+ }
+} // End-of: 'postInit': function () {
+}; // End-of: PollMain.prototype = {
+
+return PollMain;
+
+function PollMain(el) {
+ var _this;
+
+ this.questionEl = $(el).find('.poll_question');
+ if (this.questionEl.length !== 1) {
+ // We require one question DOM element.
+ logme('ERROR: PollMain constructor requires one question DOM element.');
+
+ return;
+ }
+
+ // Just a safety precussion. If we run this code more than once, multiple 'click' callback handlers will be
+ // attached to the same DOM elements. We don't want this to happen.
+ if (this.questionEl.attr('poll_main_processed') === 'true') {
+ logme(
+ 'ERROR: PolMain JS constructor was called on a DOM element that has already been processed once.'
+ );
+
+ return;
+ }
+
+ // This element was not processed earlier.
+ // Make sure that next time we will not process this element a second time.
+ this.questionEl.attr('poll_main_processed', 'true');
+
+ // Access this object inside inner functions.
+ _this = this;
+
+ // DOM element which contains the current poll along with any conditionals. By default we assume that such
+ // element is not present. We will try to find it.
+ this.wrapperSectionEl = null;
+
+ (function (tempEl, c1) {
+ while (tempEl.tagName.toLowerCase() !== 'body') {
+ tempEl = $(tempEl).parent()[0];
+ c1 += 1;
+
+ if (
+ (tempEl.tagName.toLowerCase() === 'section') &&
+ ($(tempEl).hasClass('xmodule_WrapperModule') === true)
+ ) {
+ _this.wrapperSectionEl = tempEl;
+
+ break;
+ } else if (c1 > 50) {
+ // In case something breaks, and we enter an endless loop, a sane
+ // limit for loop iterations.
+
+ break;
+ }
+ }
+ }($(el)[0], 0));
+
+ try {
+ this.jsonConfig = JSON.parse(this.questionEl.children('.poll_question_div').html());
+
+ $.postWithPrefix(
+ '' + this.questionEl.data('ajax-url') + '/' + 'get_state', {},
+ function (response) {
+ _this.jsonConfig.poll_answer = response.poll_answer;
+ _this.jsonConfig.total = response.total;
+
+ $.each(response.poll_answers, function (index, value) {
+ _this.jsonConfig.poll_answers[index] = value;
+ });
+
+ _this.questionEl.children('.poll_question_div').html(JSON.stringify(_this.jsonConfig));
+
+ _this.postInit();
+ }
+ );
+
+ return;
+ } catch (err) {
+ logme(
+ 'ERROR: Invalid JSON config for poll ID "' + this.id + '".',
+ 'Error messsage: "' + err.message + '".'
+ );
+
+ return;
+ }
+} // End-of: function PollMain(el) {
+
+}); // End-of: define('PollMain', ['logme'], function (logme) {
+
+// End-of: (function (requirejs, require, define) {
+}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee
index 793e7f4f3c..0e4c9788ba 100644
--- a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee
@@ -56,7 +56,7 @@ class @Sequence
element.removeClass('progress-none')
.removeClass('progress-some')
.removeClass('progress-done')
-
+
switch progress
when 'none' then element.addClass('progress-none')
when 'in_progress' then element.addClass('progress-some')
@@ -65,6 +65,11 @@ class @Sequence
toggleArrows: =>
@$('.sequence-nav-buttons a').unbind('click')
+ if @contents.length == 0
+ @$('.sequence-nav-buttons .prev a').addClass('disabled')
+ @$('.sequence-nav-buttons .next a').addClass('disabled')
+ return
+
if @position == 1
@$('.sequence-nav-buttons .prev a').addClass('disabled')
else
@@ -105,8 +110,8 @@ class @Sequence
if (1 <= new_position) and (new_position <= @num_contents)
Logger.log "seq_goto", old: @position, new: new_position, id: @id
-
- # On Sequence chage, destroy any existing polling thread
+
+ # On Sequence chage, destroy any existing polling thread
# for queued submissions, see ../capa/display.coffee
if window.queuePollerID
window.clearTimeout(window.queuePollerID)
diff --git a/common/lib/xmodule/xmodule/js/src/video/display.coffee b/common/lib/xmodule/xmodule/js/src/video/display.coffee
index 1876330340..aadafbc8d0 100644
--- a/common/lib/xmodule/xmodule/js/src/video/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/video/display.coffee
@@ -4,7 +4,6 @@ class @Video
@id = @el.attr('id').replace(/video_/, '')
@start = @el.data('start')
@end = @el.data('end')
- @caption_data_dir = @el.data('caption-data-dir')
@caption_asset_path = @el.data('caption-asset-path')
@show_captions = @el.data('show-captions') == "true"
window.player = null
diff --git a/common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee b/common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee
new file mode 100644
index 0000000000..a13c5a8bc7
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee
@@ -0,0 +1,10 @@
+class @WrapperDescriptor extends XModule.Descriptor
+ constructor: (@element) ->
+ console.log 'WrapperDescriptor'
+ @$items = $(@element).find(".vert-mod")
+ @$items.sortable(
+ update: (event, ui) => @update()
+ )
+
+ save: ->
+ children: $('.vert-mod li', @element).map((idx, el) -> $(el).data('id')).toArray()
diff --git a/common/lib/xmodule/xmodule/mako_module.py b/common/lib/xmodule/xmodule/mako_module.py
index da96bfa212..84db6ad779 100644
--- a/common/lib/xmodule/xmodule/mako_module.py
+++ b/common/lib/xmodule/xmodule/mako_module.py
@@ -1,5 +1,5 @@
-from x_module import XModuleDescriptor, DescriptorSystem
-import logging
+from .x_module import XModuleDescriptor, DescriptorSystem
+from .modulestore.inheritance import own_metadata
class MakoDescriptorSystem(DescriptorSystem):
@@ -21,21 +21,21 @@ class MakoModuleDescriptor(XModuleDescriptor):
the descriptor as the `module` parameter to that template
"""
- def __init__(self, system, definition=None, **kwargs):
+ def __init__(self, system, location, model_data):
if getattr(system, 'render_template', None) is None:
raise TypeError('{system} must have a render_template function'
' in order to use a MakoDescriptor'.format(
system=system))
- super(MakoModuleDescriptor, self).__init__(system, definition, **kwargs)
+ super(MakoModuleDescriptor, self).__init__(system, location, model_data)
def get_context(self):
"""
Return the context to render the mako template with
"""
- return {'module': self,
- 'metadata': self.metadata,
- 'editable_metadata_fields': self.editable_metadata_fields
- }
+ return {
+ 'module': self,
+ 'editable_metadata_fields': self.editable_metadata_fields,
+ }
def get_html(self):
return self.system.render_template(
@@ -44,6 +44,10 @@ class MakoModuleDescriptor(XModuleDescriptor):
# cdodge: encapsulate a means to expose "editable" metadata fields (i.e. not internal system metadata)
@property
def editable_metadata_fields(self):
- subset = [name for name in self.metadata.keys() if name not in self.system_metadata_fields and
- name not in self._inherited_metadata]
- return subset
+ fields = {}
+ for field, value in own_metadata(self).items():
+ if field in self.system_metadata_fields:
+ continue
+
+ fields[field] = value
+ return fields
diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py
index 525527c93f..022e016a58 100644
--- a/common/lib/xmodule/xmodule/modulestore/__init__.py
+++ b/common/lib/xmodule/xmodule/modulestore/__init__.py
@@ -423,6 +423,7 @@ class ModuleStoreBase(ModuleStore):
Set up the error-tracking logic.
'''
self._location_errors = {} # location -> ErrorLog
+ self.metadata_inheritance_cache = None
def _get_errorlog(self, location):
"""
diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py
index 0b86c2fea4..b0a65273c7 100644
--- a/common/lib/xmodule/xmodule/modulestore/django.py
+++ b/common/lib/xmodule/xmodule/modulestore/django.py
@@ -33,11 +33,12 @@ def modulestore(name='default'):
class_ = load_function(settings.MODULESTORE[name]['ENGINE'])
options = {}
+
options.update(settings.MODULESTORE[name]['OPTIONS'])
for key in FUNCTION_KEYS:
if key in options:
options[key] = load_function(options[key])
-
+
_MODULESTORES[name] = class_(
**options
)
diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py
index 81f4da2780..71922c08df 100644
--- a/common/lib/xmodule/xmodule/modulestore/draft.py
+++ b/common/lib/xmodule/xmodule/modulestore/draft.py
@@ -15,11 +15,11 @@ def as_draft(location):
def wrap_draft(item):
"""
- Sets `item.metadata['is_draft']` to `True` if the item is a
- draft, and false otherwise. Sets the item's location to the
+ Sets `item.cms.is_draft` to `True` if the item is a
+ draft, and `False` otherwise. Sets the item's location to the
non-draft location in either case
"""
- item.metadata['is_draft'] = item.location.revision == DRAFT
+ item.cms.is_draft = item.location.revision == DRAFT
item.location = item.location._replace(revision=None)
return item
@@ -118,7 +118,7 @@ class DraftModuleStore(ModuleStoreBase):
"""
draft_loc = as_draft(location)
draft_item = self.get_item(location)
- if not draft_item.metadata['is_draft']:
+ if not draft_item.cms.is_draft:
self.clone_item(location, draft_loc)
return super(DraftModuleStore, self).update_item(draft_loc, data)
@@ -133,7 +133,7 @@ class DraftModuleStore(ModuleStoreBase):
"""
draft_loc = as_draft(location)
draft_item = self.get_item(location)
- if not draft_item.metadata['is_draft']:
+ if not draft_item.cms.is_draft:
self.clone_item(location, draft_loc)
return super(DraftModuleStore, self).update_children(draft_loc, children)
@@ -149,7 +149,7 @@ class DraftModuleStore(ModuleStoreBase):
draft_loc = as_draft(location)
draft_item = self.get_item(location)
- if not draft_item.metadata['is_draft']:
+ if not draft_item.cms.is_draft:
self.clone_item(location, draft_loc)
if 'is_draft' in metadata:
@@ -179,13 +179,11 @@ class DraftModuleStore(ModuleStoreBase):
Save a current draft to the underlying modulestore
"""
draft = self.get_item(location)
- metadata = {}
- metadata.update(draft.metadata)
- metadata['published_date'] = tuple(datetime.utcnow().timetuple())
- metadata['published_by'] = published_by_id
- super(DraftModuleStore, self).update_item(location, draft.definition.get('data', {}))
- super(DraftModuleStore, self).update_children(location, draft.definition.get('children', []))
- super(DraftModuleStore, self).update_metadata(location, metadata)
+ draft.cms.published_date = datetime.utcnow()
+ draft.cms.published_by = published_by_id
+ super(DraftModuleStore, self).update_item(location, draft._model_data._kvs._data)
+ super(DraftModuleStore, self).update_children(location, draft._model_data._kvs._children)
+ super(DraftModuleStore, self).update_metadata(location, draft._model_data._kvs._metadata)
self.delete_item(location)
def unpublish(self, location):
diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py
new file mode 100644
index 0000000000..d819abe367
--- /dev/null
+++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py
@@ -0,0 +1,67 @@
+from xblock.core import Scope
+
+# A list of metadata that this module can inherit from its parent module
+INHERITABLE_METADATA = (
+ 'graded', 'start', 'due', 'graceperiod', 'showanswer', 'rerandomize',
+ # TODO (ichuang): used for Fall 2012 xqa server access
+ 'xqa_key',
+ # How many days early to show a course element to beta testers (float)
+ # intended to be set per-course, but can be overridden in for specific
+ # elements. Can be a float.
+ 'days_early_for_beta'
+)
+
+def compute_inherited_metadata(descriptor):
+ """Given a descriptor, traverse all of its descendants and do metadata
+ inheritance. Should be called on a CourseDescriptor after importing a
+ course.
+
+ NOTE: This means that there is no such thing as lazy loading at the
+ moment--this accesses all the children."""
+ for child in descriptor.get_children():
+ inherit_metadata(child, descriptor._model_data)
+ compute_inherited_metadata(child)
+
+
+def inherit_metadata(descriptor, model_data):
+ """
+ Updates this module with metadata inherited from a containing module.
+ Only metadata specified in self.inheritable_metadata will
+ be inherited
+ """
+ if not hasattr(descriptor, '_inherited_metadata'):
+ setattr(descriptor, '_inherited_metadata', {})
+
+ # Set all inheritable metadata from kwargs that are
+ # in self.inheritable_metadata and aren't already set in metadata
+ for attr in INHERITABLE_METADATA:
+ if attr not in descriptor._model_data and attr in model_data:
+ descriptor._inherited_metadata[attr] = model_data[attr]
+ descriptor._model_data[attr] = model_data[attr]
+
+
+def own_metadata(module):
+ """
+ Return a dictionary that contains only non-inherited field keys,
+ mapped to their values
+ """
+ inherited_metadata = getattr(module, '_inherited_metadata', {})
+ metadata = {}
+ for field in module.fields + module.lms.fields:
+ # Only save metadata that wasn't inherited
+ if field.scope != Scope.settings:
+ continue
+
+ if field.name in inherited_metadata and module._model_data.get(field.name) == inherited_metadata.get(field.name):
+ continue
+
+ if field.name not in module._model_data:
+ continue
+
+ try:
+ metadata[field.name] = module._model_data[field.name]
+ except KeyError:
+ # Ignore any missing keys in _model_data
+ pass
+
+ return metadata
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py
index e2a4524188..aceebbf15f 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo.py
@@ -4,6 +4,7 @@ import logging
import copy
from bson.son import SON
+from collections import namedtuple
from fs.osfs import OSFS
from itertools import repeat
from path import path
@@ -11,20 +12,93 @@ from datetime import datetime, timedelta
from importlib import import_module
from xmodule.errortracker import null_error_tracker, exc_info_to_str
-from xmodule.x_module import XModuleDescriptor
from xmodule.mako_module import MakoDescriptorSystem
+from xmodule.x_module import XModuleDescriptor
from xmodule.error_module import ErrorDescriptor
+from xblock.runtime import DbModel, KeyValueStore, InvalidScopeError
+from xblock.core import Scope
from . import ModuleStoreBase, Location
from .draft import DraftModuleStore
from .exceptions import (ItemNotFoundError,
DuplicateItemError)
+from .inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata
+
+log = logging.getLogger(__name__)
# TODO (cpennington): This code currently operates under the assumption that
# there is only one revision for each item. Once we start versioning inside the CMS,
# that assumption will have to change
+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):
+ self._data = data
+ self._children = children
+ self._metadata = metadata
+
+ def get(self, key):
+ if key.scope == Scope.children:
+ return self._children
+ elif key.scope == Scope.parent:
+ return None
+ elif key.scope == Scope.settings:
+ return self._metadata[key.field_name]
+ elif key.scope == Scope.content:
+ if key.field_name == 'data' and not isinstance(self._data, dict):
+ return self._data
+ else:
+ return self._data[key.field_name]
+ else:
+ raise InvalidScopeError(key.scope)
+
+ def set(self, key, value):
+ if key.scope == Scope.children:
+ self._children = value
+ elif key.scope == Scope.settings:
+ self._metadata[key.field_name] = value
+ elif key.scope == Scope.content:
+ if key.field_name == 'data' and not isinstance(self._data, dict):
+ self._data = value
+ else:
+ self._data[key.field_name] = value
+ else:
+ raise InvalidScopeError(key.scope)
+
+ def delete(self, key):
+ if key.scope == Scope.children:
+ self._children = []
+ elif key.scope == Scope.settings:
+ if key.field_name in self._metadata:
+ del self._metadata[key.field_name]
+ elif key.scope == Scope.content:
+ if key.field_name == 'data' and not isinstance(self._data, dict):
+ self._data = None
+ else:
+ del self._data[key.field_name]
+ else:
+ raise InvalidScopeError(key.scope)
+
+ def has(self, key):
+ if key.scope in (Scope.children, Scope.parent):
+ return True
+ elif key.scope == Scope.settings:
+ return key.field_name in self._metadata
+ elif key.scope == Scope.content:
+ if key.field_name == 'data' and not isinstance(self._data, dict):
+ return True
+ else:
+ return key.field_name in self._data
+ else:
+ return False
+
+MongoUsage = namedtuple('MongoUsage', 'id, def_id')
+
+
class CachingDescriptorSystem(MakoDescriptorSystem):
"""
A system that has a cache of module json that it will use to load modules
@@ -72,12 +146,31 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
else:
# load the module and apply the inherited metadata
try:
- module = XModuleDescriptor.load_from_json(json_data, self, self.default_class)
+ class_ = XModuleDescriptor.load_class(
+ json_data['location']['category'],
+ self.default_class
+ )
+ definition = json_data.get('definition', {})
+ metadata = json_data.get('metadata', {})
+ for old_name, new_name in class_.metadata_translations.items():
+ if old_name in metadata:
+ metadata[new_name] = metadata[old_name]
+ del metadata[old_name]
+
+ kvs = MongoKeyValueStore(
+ definition.get('data', {}),
+ definition.get('children', []),
+ metadata,
+ )
+
+ model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location))
+ module = class_(self, location, model_data)
if self.metadata_inheritance_tree is not None:
- metadata_to_inherit = self.metadata_inheritance_tree.get('parent_metadata', {}).get(location.url(),{})
- module.inherit_metadata(metadata_to_inherit)
+ metadata_to_inherit = self.metadata_inheritance_tree.get('parent_metadata', {}).get(location.url(), {})
+ inherit_metadata(module, metadata_to_inherit)
return module
except:
+ log.warning("Failed to load descriptor", exc_info=True)
return ErrorDescriptor.from_json(
json_data,
self,
@@ -153,26 +246,21 @@ class MongoModuleStore(ModuleStoreBase):
self.fs_root = path(fs_root)
self.error_tracker = error_tracker
self.render_template = render_template
- self.metadata_inheritance_cache = {}
def get_metadata_inheritance_tree(self, location):
'''
TODO (cdodge) This method can be deleted when the 'split module store' work has been completed
'''
-
+
# get all collections in the course, this query should not return any leaf nodes
- query = {
+ # note this is a bit ugly as when we add new categories of containers, we have to add it here
+ query = {
'_id.org': location.org,
'_id.course': location.course,
- '$or': [
- {"_id.category":"course"},
- {"_id.category":"chapter"},
- {"_id.category":"sequential"},
- {"_id.category":"vertical"}
- ]
+ '_id.category': {'$in': [ 'course', 'chapter', 'sequential', 'vertical']}
}
# we just want the Location, children, and metadata
- record_filter = {'_id':1,'definition.children':1,'metadata':1}
+ record_filter = {'_id': 1, 'definition.children': 1, 'metadata': 1}
# call out to the DB
resultset = self.collection.find(query, record_filter)
@@ -190,9 +278,15 @@ class MongoModuleStore(ModuleStoreBase):
# now traverse the tree and compute down the inherited metadata
metadata_to_inherit = {}
def _compute_inherited_metadata(url):
- my_metadata = results_by_url[url]['metadata']
+ my_metadata = {}
+ # check for presence of metadata key. Note that a given module may not yet be fully formed.
+ # example: update_item -> update_children -> update_metadata sequence on new item create
+ # if we get called here without update_metadata called first then 'metadata' hasn't been set
+ # as we're not fully transactional at the DB layer. Same comment applies to below key name
+ # check
+ my_metadata = results_by_url[url].get('metadata', {})
for key in my_metadata.keys():
- if key not in XModuleDescriptor.inheritable_metadata:
+ if key not in INHERITABLE_METADATA:
del my_metadata[key]
results_by_url[url]['metadata'] = my_metadata
@@ -201,39 +295,45 @@ class MongoModuleStore(ModuleStoreBase):
for child in results_by_url[url].get('definition',{}).get('children',[]):
if child in results_by_url:
new_child_metadata = copy.deepcopy(my_metadata)
- new_child_metadata.update(results_by_url[child]['metadata'])
+ new_child_metadata.update(results_by_url[child].get('metadata', {}))
results_by_url[child]['metadata'] = new_child_metadata
metadata_to_inherit[child] = new_child_metadata
_compute_inherited_metadata(child)
else:
# this is likely a leaf node, so let's record what metadata we need to inherit
metadata_to_inherit[child] = my_metadata
-
+
+
if root is not None:
_compute_inherited_metadata(root)
- cache = {'parent_metadata': metadata_to_inherit,
+ return {'parent_metadata': metadata_to_inherit,
'timestamp' : datetime.now()}
- return cache
-
- def get_cached_metadata_inheritance_tree(self, location, max_age_allowed):
+ def get_cached_metadata_inheritance_tree(self, location, force_refresh=False):
'''
TODO (cdodge) This method can be deleted when the 'split module store' work has been completed
'''
- cache_name = '{0}/{1}'.format(location.org, location.course)
- cache = self.metadata_inheritance_cache.get(cache_name,{'parent_metadata': {},
- 'timestamp': datetime.now() - timedelta(hours=1)})
- age = (datetime.now() - cache['timestamp'])
+ key_name = '{0}/{1}'.format(location.org, location.course)
- if age.seconds >= max_age_allowed:
- logging.debug('loading entire inheritance tree for {0}'.format(cache_name))
- cache = self.get_metadata_inheritance_tree(location)
- self.metadata_inheritance_cache[cache_name] = cache
+ tree = None
+ if self.metadata_inheritance_cache is not None:
+ tree = self.metadata_inheritance_cache.get(key_name)
+ else:
+ # This is to help guard against an accident prod runtime without a cache
+ logging.warning('Running MongoModuleStore without metadata_inheritance_cache. This should not happen in production!')
- return cache
+ if tree is None or force_refresh:
+ tree = self.get_metadata_inheritance_tree(location)
+ if self.metadata_inheritance_cache is not None:
+ self.metadata_inheritance_cache.set(key_name, tree)
+ return tree
+ def clear_cached_metadata_inheritance_tree(self, location):
+ key_name = '{0}/{1}'.format(location.org, location.course)
+ if self.metadata_inheritance_cache is not None:
+ self.metadata_inheritance_cache.delete(key_name)
def _clean_item_data(self, item):
"""
@@ -280,7 +380,7 @@ class MongoModuleStore(ModuleStoreBase):
"""
Load an XModuleDescriptor from item, using the children stored in data_cache
"""
- data_dir = item.get('metadata', {}).get('data_dir', item['location']['course'])
+ data_dir = getattr(item, 'data_dir', item['location']['course'])
root = self.fs_root / data_dir
if not root.isdir():
@@ -288,12 +388,7 @@ class MongoModuleStore(ModuleStoreBase):
resource_fs = OSFS(root)
- metadata_inheritance_tree = None
-
- # if we are loading a course object, there is no parent to inherit the metadata from
- # so don't bother getting it
- if item['location']['category'] != 'course':
- metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location']), 300)
+ metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location']))
# TODO (cdodge): When the 'split module store' work has been completed, we should remove
# the 'metadata_inheritance_tree' parameter
@@ -407,14 +502,20 @@ class MongoModuleStore(ModuleStoreBase):
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.metadata.get('display_name'), 'url_slug': item.location.name})
+ 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.metadata)
+ self.update_metadata(course.location, course._model_data._kvs._metadata)
return item
except pymongo.errors.DuplicateKeyError:
raise DuplicateItemError(location)
+ # recompute (and update) the metadata inheritance tree which is cached
+ self.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True)
def get_course_for_item(self, location, depth=0):
'''
@@ -435,10 +536,10 @@ class MongoModuleStore(ModuleStoreBase):
# make sure we found exactly one match on this above course search
found_cnt = len(courses)
if found_cnt == 0:
- raise BaseException('Could not find course at {0}'.format(course_search_location))
+ raise Exception('Could not find course at {0}'.format(course_search_location))
if found_cnt > 1:
- raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
+ raise Exception('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
return courses[0]
@@ -480,6 +581,8 @@ class MongoModuleStore(ModuleStoreBase):
"""
self._update_single_item(location, {'definition.children': children})
+ # recompute (and update) the metadata inheritance tree which is cached
+ self.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True)
def update_metadata(self, location, metadata):
"""
@@ -501,10 +604,11 @@ class MongoModuleStore(ModuleStoreBase):
tab['name'] = metadata.get('display_name')
break
course.tabs = existing_tabs
- self.update_metadata(course.location, course.metadata)
+ self.update_metadata(course.location, own_metadata(course))
self._update_single_item(location, {'metadata': metadata})
-
+ # recompute (and update) the metadata inheritance tree which is cached
+ self.get_cached_metadata_inheritance_tree(loc, force_refresh = True)
def delete_item(self, location):
"""
@@ -520,9 +624,11 @@ 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]
- self.update_metadata(course.location, course.metadata)
+ self.update_metadata(course.location, own_metadata(course))
self.collection.remove({'_id': Location(location).dict()})
+ # recompute (and update) the metadata inheritance tree which is cached
+ self.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True)
def get_parent_locations(self, location, course_id):
diff --git a/common/lib/xmodule/xmodule/modulestore/store_utilities.py b/common/lib/xmodule/xmodule/modulestore/store_utilities.py
index 5146ac18c8..cb3cd375a7 100644
--- a/common/lib/xmodule/xmodule/modulestore/store_utilities.py
+++ b/common/lib/xmodule/xmodule/modulestore/store_utilities.py
@@ -41,22 +41,24 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
print "Cloning module {0} to {1}....".format(original_loc, module.location)
- if 'data' in module.definition:
- modulestore.update_item(module.location, module.definition['data'])
+ modulestore.update_item(module.location, module._model_data._kvs._data)
# repoint children
- if 'children' in module.definition:
+ if module.has_children:
new_children = []
- for child_loc_url in module.definition['children']:
+ for child_loc_url in module.children:
child_loc = Location(child_loc_url)
- child_loc = child_loc._replace(tag=dest_location.tag, org=dest_location.org,
- course=dest_location.course)
- new_children = new_children + [child_loc.url()]
+ child_loc = child_loc._replace(
+ tag=dest_location.tag,
+ org=dest_location.org,
+ course=dest_location.course
+ )
+ new_children.append(child_loc.url())
modulestore.update_children(module.location, new_children)
# save metadata
- modulestore.update_metadata(module.location, module.metadata)
+ modulestore.update_metadata(module.location, module._model_data._kvs._metadata)
# now iterate through all of the assets and clone them
# first the thumbnails
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
index f2a291d680..b842ffe9dd 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py
@@ -4,6 +4,7 @@ from uuid import uuid4
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.timeparse import stringify_time
+from xmodule.modulestore.inheritance import own_metadata
def XMODULE_COURSE_CREATION(class_to_create, **kwargs):
@@ -40,10 +41,9 @@ class XModuleCourseFactory(Factory):
# This metadata code was copied from cms/djangoapps/contentstore/views.py
if display_name is not None:
- new_course.metadata['display_name'] = display_name
+ new_course.display_name = display_name
- new_course.metadata['data_dir'] = uuid4().hex
- new_course.metadata['start'] = stringify_time(gmtime())
+ new_course.start = gmtime()
new_course.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
@@ -52,7 +52,7 @@ class XModuleCourseFactory(Factory):
{"type": "progress", "name": "Progress"}]
# Update the data in the mongo datastore
- store.update_metadata(new_course.location.url(), new_course.own_metadata)
+ store.update_metadata(new_course.location.url(), own_metadata(new_course))
return new_course
@@ -99,17 +99,14 @@ class XModuleItemFactory(Factory):
new_item = store.clone_item(template, dest_location)
- # TODO: This needs to be deleted when we have proper storage for static content
- new_item.metadata['data_dir'] = parent.metadata['data_dir']
-
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
- new_item.metadata['display_name'] = display_name
+ new_item.display_name = display_name
- store.update_metadata(new_item.location.url(), new_item.own_metadata)
+ store.update_metadata(new_item.location.url(), own_metadata(new_item))
if new_item.location.category not in DETACHED_CATEGORIES:
- store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
+ store.update_children(parent_location, parent.children + [new_item.location.url()])
return new_item
diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py
index 1bd27189e9..677f8b7d6a 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml.py
@@ -23,13 +23,14 @@ from xmodule.html_module import HtmlDescriptor
from . import ModuleStoreBase, Location
from .exceptions import ItemNotFoundError
+from .inheritance import compute_inherited_metadata
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True)
etree.set_default_parser(edx_xml_parser)
-log = logging.getLogger('mitx.' + __name__)
+log = logging.getLogger(__name__)
# VS[compat]
@@ -73,7 +74,8 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
# VS[compat]. Take this out once course conversion is done (perhaps leave the uniqueness check)
# tags that really need unique names--they store (or should store) state.
- need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', 'videosequence', 'timelimit')
+ need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter',
+ 'videosequence', 'poll_question', 'timelimit')
attr = xml_data.attrib
tag = xml_data.tag
@@ -161,7 +163,6 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
etree.tostring(xml_data, encoding='unicode'), self, self.org,
self.course, xmlstore.default_class)
except Exception as err:
- print err, self.load_error_modules
if not self.load_error_modules:
raise
@@ -174,7 +175,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
# Normally, we don't want lots of exception traces in our logs from common
# content problems. But if you're debugging the xml loading code itself,
# uncomment the next line.
- # log.exception(msg)
+ log.exception(msg)
self.error_tracker(msg)
err_msg = msg + "\n" + exc_info_to_str(sys.exc_info())
@@ -186,12 +187,13 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
err_msg
)
- descriptor.metadata['data_dir'] = course_dir
+ setattr(descriptor, 'data_dir', course_dir)
xmlstore.modules[course_id][descriptor.location] = descriptor
- for child in descriptor.get_children():
- parent_tracker.add_parent(child.location, descriptor.location)
+ if hasattr(descriptor, 'children'):
+ for child in descriptor.get_children():
+ parent_tracker.add_parent(child.location, descriptor.location)
return descriptor
render_template = lambda: ''
@@ -318,8 +320,6 @@ class XMLModuleStore(ModuleStoreBase):
# Didn't load course. Instead, save the errors elsewhere.
self.errored_courses[course_dir] = errorlog
-
-
def __unicode__(self):
'''
String representation - for debugging
@@ -345,8 +345,6 @@ class XMLModuleStore(ModuleStoreBase):
log.warning(msg + " " + str(err))
return {}
-
-
def load_course(self, course_dir, tracker):
"""
Load a course into this module store
@@ -430,7 +428,7 @@ class XMLModuleStore(ModuleStoreBase):
# breaks metadata inheritance via get_children(). Instead
# (actually, in addition to, for now), we do a final inheritance pass
# after we have the course descriptor.
- XModuleDescriptor.compute_inherited_metadata(course_descriptor)
+ compute_inherited_metadata(course_descriptor)
# now import all pieces of course_info which is expected to be stored
# in /info or /info/
@@ -449,7 +447,6 @@ class XMLModuleStore(ModuleStoreBase):
def load_extra_content(self, system, course_descriptor, category, base_dir, course_dir, url_name):
-
self._load_extra_content(system, course_descriptor, category, base_dir, course_dir)
# then look in a override folder based on the course run
@@ -460,26 +457,29 @@ class XMLModuleStore(ModuleStoreBase):
def _load_extra_content(self, system, course_descriptor, category, path, course_dir):
for filepath in glob.glob(path / '*'):
- if not os.path.isdir(filepath):
- with open(filepath) as f:
- try:
- html = f.read().decode('utf-8')
- # 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, definition={'data': html}, **{'location': loc})
- # 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
- if category == "static_tab":
- for tab in course_descriptor.tabs or []:
- if tab.get('url_slug') == slug:
- module.metadata['display_name'] = tab['name']
- module.metadata['data_dir'] = course_dir
- self.modules[course_descriptor.id][module.location] = module
- except Exception, e:
- logging.exception("Failed to load {0}. Skipping... Exception: {1}".format(filepath, str(e)))
- system.error_tracker("ERROR: " + str(e))
+ if not os.path.isfile(filepath):
+ continue
+
+ with open(filepath) as f:
+ try:
+ html = f.read().decode('utf-8')
+ # 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, loc, {'data': html})
+ # 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
+ if category == "static_tab":
+ for tab in course_descriptor.tabs or []:
+ if tab.get('url_slug') == slug:
+ module.display_name = tab['name']
+ module.data_dir = course_dir
+ self.modules[course_descriptor.id][module.location] = module
+ except Exception, e:
+ logging.exception("Failed to load {0}. Skipping... Exception: {1}".format(filepath, str(e)))
+ system.error_tracker("ERROR: " + str(e))
+
def get_instance(self, course_id, location, depth=0):
"""
diff --git a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py
index edf4708687..0724211ed3 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py
@@ -1,6 +1,7 @@
import logging
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.inheritance import own_metadata
from fs.osfs import OSFS
from json import dumps
@@ -31,14 +32,12 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
# export the grading policy
policies_dir = export_fs.makeopendir('policies')
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
- if 'grading_policy' in course.definition['data']:
- with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
- grading_policy.write(dumps(course.definition['data']['grading_policy']))
+ with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
+ grading_policy.write(dumps(course.grading_policy))
# export all of the course metadata in policy.json
with course_run_policy_dir.open('policy.json', 'w') as course_policy:
- policy = {}
- policy = {'course/' + course.location.name: course.metadata}
+ policy = {'course/' + course.location.name: own_metadata(course)}
course_policy.write(dumps(policy))
@@ -50,4 +49,4 @@ def export_extra_content(export_fs, modulestore, course_location, category_type,
item_dir = export_fs.makeopendir(dirname)
for item in items:
with item_dir.open(item.location.name + file_suffix, 'w') as item_file:
- item_file.write(item.definition['data'].encode('utf8'))
+ item_file.write(item.data.encode('utf8'))
diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py
index 0b77900ae9..fa232596f2 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py
@@ -8,6 +8,7 @@ from .xml import XMLModuleStore
from .exceptions import DuplicateItemError
from xmodule.modulestore import Location
from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX
+from .inheritance import own_metadata
log = logging.getLogger(__name__)
@@ -20,6 +21,8 @@ def import_static_content(modules, course_loc, course_data_path, static_content_
# now import all static assets
static_dir = course_data_path / subpath
+ verbose = True
+
for dirname, dirnames, filenames in os.walk(static_dir):
for filename in filenames:
@@ -95,6 +98,79 @@ def verify_content_links(module, base_dir, static_content_store, link, remap_dic
return link
+def import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False):
+ # remap module to the new namespace
+ if target_location_namespace is not None:
+ # This looks a bit wonky as we need to also change the 'name' of the imported course to be what
+ # the caller passed in
+ if module.location.category != 'course':
+ module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
+ course=target_location_namespace.course)
+ else:
+ module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
+ course=target_location_namespace.course, name=target_location_namespace.name)
+
+ # then remap children pointers since they too will be re-namespaced
+ if module.has_children:
+ children_locs = module.children
+ new_locs = []
+ for child in children_locs:
+ child_loc = Location(child)
+ new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
+ course=target_location_namespace.course)
+
+ new_locs.append(new_child_loc.url())
+
+ module.children = new_locs
+
+ if hasattr(module, 'data'):
+ # cdodge: now go through any link references to '/static/' and make sure we've imported
+ # it as a StaticContent asset
+ try:
+ remap_dict = {}
+
+ # use the rewrite_links as a utility means to enumerate through all links
+ # in the module data. We use that to load that reference into our asset store
+ # IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to
+ # do the rewrites natively in that code.
+ # For example, what I'm seeing is ->
+ # Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's
+ # no good, so we have to do this kludge
+ if isinstance(module.data, str) or isinstance(module.data, unicode): # some module 'data' fields are non strings which blows up the link traversal code
+ lxml_rewrite_links(module.data, lambda link: verify_content_links(module, course_data_path,
+ static_content_store, link, remap_dict))
+
+ for key in remap_dict.keys():
+ module.data = module.data.replace(key, remap_dict[key])
+
+ except Exception:
+ logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location))
+
+ modulestore.update_item(module.location, module.data)
+
+ if module.has_children:
+ modulestore.update_children(module.location, module.children)
+
+ modulestore.update_metadata(module.location, own_metadata(module))
+
+
+def import_course_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False):
+ # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which
+ # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS,
+ # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that -
+ # if there is *any* tabs - then there at least needs to be some predefined ones
+ if module.tabs is None or len(module.tabs) == 0:
+ module.tabs = [{"type": "courseware"},
+ {"type": "course_info", "name": "Course Info"},
+ {"type": "discussion", "name": "Discussion"},
+ {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
+
+ # a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg
+ # so let's make sure we import in case there are no other references to it in the modules
+ verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg')
+ import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace, verbose=verbose)
+
+
def import_from_xml(store, data_dir, course_dirs=None,
default_class='xmodule.raw_module.RawDescriptor',
load_error_modules=True, static_content_store=None, target_location_namespace=None, verbose=False):
@@ -135,7 +211,7 @@ def import_from_xml(store, data_dir, course_dirs=None,
# course module is committed first into the store
for module in module_store.modules[course_id].itervalues():
if module.category == 'course':
- course_data_path = path(data_dir) / module.metadata['data_dir']
+ course_data_path = path(data_dir) / module.data_dir
course_location = module.location
module = remap_namespace(module, target_location_namespace)
@@ -151,10 +227,10 @@ def import_from_xml(store, data_dir, course_dirs=None,
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
- store.update_item(module.location, module.definition['data'])
- if 'children' in module.definition:
- store.update_children(module.location, module.definition['children'])
- store.update_metadata(module.location, dict(module.own_metadata))
+ if hasattr(module, 'data'):
+ store.update_item(module.location, module.data)
+ store.update_children(module.location, module.children)
+ store.update_metadata(module.location, dict(own_metadata(module)))
# a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg
# so let's make sure we import in case there are no other references to it in the modules
@@ -186,8 +262,8 @@ def import_from_xml(store, data_dir, course_dirs=None,
if verbose:
log.debug('importing module location {0}'.format(module.location))
- if 'data' in module.definition:
- module_data = module.definition['data']
+ if hasattr(module, 'data'):
+ module_data = module.data
# cdodge: now go through any link references to '/static/' and make sure we've imported
# it as a StaticContent asset
@@ -213,16 +289,15 @@ def import_from_xml(store, data_dir, course_dirs=None,
store.update_item(module.location, module_data)
- if 'children' in module.definition:
- store.update_children(module.location, module.definition['children'])
+ if hasattr(module, 'children') and module.children != []:
+ store.update_children(module.location, module.children)
# NOTE: It's important to use own_metadata here to avoid writing
# inherited metadata everywhere.
- store.update_metadata(module.location, dict(module.own_metadata))
+ store.update_metadata(module.location, dict(own_metadata(module)))
return module_store, course_items
-
def remap_namespace(module, target_location_namespace):
if target_location_namespace is None:
return module
@@ -237,21 +312,21 @@ def remap_namespace(module, target_location_namespace):
course=target_location_namespace.course, name=target_location_namespace.name)
# then remap children pointers since they too will be re-namespaced
- children_locs = module.definition.get('children')
- if children_locs is not None:
- new_locs = []
- for child in children_locs:
- child_loc = Location(child)
- new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
- course=target_location_namespace.course)
+ if hasattr(module,'children'):
+ children_locs = module.children
+ if children_locs is not None and children_locs != []:
+ new_locs = []
+ for child in children_locs:
+ child_loc = Location(child)
+ new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
+ course=target_location_namespace.course)
- new_locs.append(new_child_loc.url())
+ new_locs.append(new_child_loc.url())
- module.definition['children'] = new_locs
+ module.children = new_locs
return module
-
def validate_category_hierarchy(module_store, course_id, parent_category, expected_child_category):
err_cnt = 0
@@ -262,7 +337,7 @@ def validate_category_hierarchy(module_store, course_id, parent_category, expect
parents.append(module)
for parent in parents:
- for child_loc in [Location(child) for child in parent.definition.get('children', [])]:
+ for child_loc in [Location(child) for child in parent.children]:
if child_loc.category != expected_child_category:
err_cnt += 1
print 'ERROR: child {0} of parent {1} was expected to be category of {2} but was {3}'.format(
@@ -274,7 +349,7 @@ def validate_category_hierarchy(module_store, course_id, parent_category, expect
def validate_data_source_path_existence(path, is_err=True, extra_msg=None):
_cnt = 0
if not os.path.exists(path):
- print ("{0}: Expected folder at {1}. {2}".format('ERROR' if is_err == True else 'WARNING', path, extra_msg if
+ print ("{0}: Expected folder at {1}. {2}".format('ERROR' if is_err == True else 'WARNING', path, extra_msg if
extra_msg is not None else ''))
_cnt = 1
return _cnt
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
index 20cedaab75..98a54601de 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
@@ -3,16 +3,14 @@ import logging
from lxml import etree
from lxml.html import rewrite_links
from xmodule.timeinfo import TimeInfo
-from xmodule.capa_module import only_one, ComplexEncoder
+from xmodule.capa_module import ComplexEncoder
from xmodule.editing_module import EditingDescriptor
-from xmodule.html_checker import check_html
from xmodule.progress import Progress
from xmodule.stringify import stringify_children
-from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor
import self_assessment_module
import open_ended_module
-from combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST
+from .combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST
log = logging.getLogger("mitx.courseware")
@@ -121,17 +119,10 @@ class CombinedOpenEndedV1Module():
"""
- self.metadata = metadata
- self.display_name = metadata.get('display_name', "Open Ended")
+ self.instance_state = instance_state
+ self.display_name = instance_state.get('display_name', "Open Ended")
self.rewrite_content_links = static_data.get('rewrite_content_links', "")
-
- # Load instance state
- if instance_state is not None:
- instance_state = json.loads(instance_state)
- else:
- instance_state = {}
-
#We need to set the location here so the child modules can use it
system.set('location', location)
self.system = system
@@ -143,18 +134,18 @@ class CombinedOpenEndedV1Module():
#Overall state of the combined open ended module
self.state = instance_state.get('state', self.INITIAL)
- self.attempts = instance_state.get('attempts', 0)
+ self.student_attempts = instance_state.get('student_attempts', 0)
#Allow reset is true if student has failed the criteria to move to the next child task
- self.allow_reset = instance_state.get('ready_to_reset', False)
- self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
- self.is_scored = self.metadata.get('is_graded', IS_SCORED) in TRUE_DICT
- self.accept_file_upload = self.metadata.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT
- self.skip_basic_checks = self.metadata.get('skip_spelling_checks', SKIP_BASIC_CHECKS)
+ 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
- display_due_date_string = self.metadata.get('due', None)
+ display_due_date_string = self.instance_state.get('due', None)
- grace_period_string = self.metadata.get('graceperiod', None)
+ grace_period_string = self.instance_state.get('graceperiod', None)
try:
self.timeinfo = TimeInfo(display_due_date_string, grace_period_string)
except:
@@ -164,7 +155,7 @@ class CombinedOpenEndedV1Module():
# Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect).
- self._max_score = int(self.metadata.get('max_score', MAX_SCORE))
+ self._max_score = self.instance_state.get('max_score', MAX_SCORE)
self.rubric_renderer = CombinedOpenEndedRubric(system, True)
rubric_string = stringify_children(definition['rubric'])
@@ -173,7 +164,7 @@ class CombinedOpenEndedV1Module():
#Static data is passed to the child modules to render
self.static_data = {
'max_score': self._max_score,
- 'max_attempts': self.max_attempts,
+ 'max_attempts': self.attempts,
'prompt': definition['prompt'],
'rubric': definition['rubric'],
'display_name': self.display_name,
@@ -207,10 +198,10 @@ class CombinedOpenEndedV1Module():
last_response = last_response_data['response']
loaded_task_state = json.loads(current_task_state)
- if loaded_task_state['state'] == self.INITIAL:
- loaded_task_state['state'] = self.ASSESSING
- loaded_task_state['created'] = True
- loaded_task_state['history'].append({'answer': last_response})
+ if loaded_task_state['child_state'] == self.INITIAL:
+ loaded_task_state['child_state'] = self.ASSESSING
+ loaded_task_state['child_created'] = True
+ loaded_task_state['child_history'].append({'answer': last_response})
current_task_state = json.dumps(loaded_task_state)
return current_task_state
@@ -249,8 +240,8 @@ class CombinedOpenEndedV1Module():
self.current_task_xml = self.task_xml[self.current_task_number]
if self.current_task_number > 0:
- self.allow_reset = self.check_allow_reset()
- if self.allow_reset:
+ self.ready_to_reset = self.check_allow_reset()
+ if self.ready_to_reset:
self.current_task_number = self.current_task_number - 1
current_task_type = self.get_tag_name(self.current_task_xml)
@@ -276,12 +267,12 @@ class CombinedOpenEndedV1Module():
last_response_data = self.get_last_response(self.current_task_number - 1)
last_response = last_response_data['response']
current_task_state = json.dumps({
- 'state': self.ASSESSING,
+ 'child_state': self.ASSESSING,
'version': self.STATE_VERSION,
'max_score': self._max_score,
- 'attempts': 0,
- 'created': True,
- 'history': [{'answer': last_response}],
+ 'child_attempts': 0,
+ 'child_created': True,
+ 'child_history': [{'answer': last_response}],
})
self.current_task = child_task_module(self.system, self.location,
self.current_task_parsed_xml, self.current_task_descriptor,
@@ -306,7 +297,7 @@ class CombinedOpenEndedV1Module():
Input: None
Output: the allow_reset attribute of the current module.
"""
- if not self.allow_reset:
+ if not self.ready_to_reset:
if self.current_task_number > 0:
last_response_data = self.get_last_response(self.current_task_number - 1)
current_response_data = self.get_current_attributes(self.current_task_number)
@@ -314,9 +305,9 @@ class CombinedOpenEndedV1Module():
if (current_response_data['min_score_to_attempt'] > last_response_data['score']
or current_response_data['max_score_to_attempt'] < last_response_data['score']):
self.state = self.DONE
- self.allow_reset = True
+ self.ready_to_reset = True
- return self.allow_reset
+ return self.ready_to_reset
def get_context(self):
"""
@@ -330,7 +321,7 @@ class CombinedOpenEndedV1Module():
context = {
'items': [{'content': task_html}],
'ajax_url': self.system.ajax_url,
- 'allow_reset': self.allow_reset,
+ 'allow_reset': self.ready_to_reset,
'state': self.state,
'task_count': len(self.task_xml),
'task_number': self.current_task_number + 1,
@@ -426,7 +417,7 @@ class CombinedOpenEndedV1Module():
else:
last_post_evaluation = task.format_feedback_with_evaluation(self.system, last_post_assessment)
last_post_assessment = last_post_evaluation
- rubric_data = task._parse_score_msg(task.history[-1].get('post_assessment', ""), self.system)
+ rubric_data = task._parse_score_msg(task.child_history[-1].get('post_assessment', ""), self.system)
rubric_scores = rubric_data['rubric_scores']
grader_types = rubric_data['grader_types']
feedback_items = rubric_data['feedback_items']
@@ -440,7 +431,7 @@ class CombinedOpenEndedV1Module():
last_post_assessment = ""
last_correctness = task.is_last_response_correct()
max_score = task.max_score()
- state = task.state
+ state = task.child_state
if task_type in HUMAN_TASK_TYPE:
human_task_name = HUMAN_TASK_TYPE[task_type]
else:
@@ -490,10 +481,10 @@ class CombinedOpenEndedV1Module():
Output: boolean indicating whether or not the task state changed.
"""
changed = False
- if not self.allow_reset:
+ if not self.ready_to_reset:
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
current_task_state = json.loads(self.task_states[self.current_task_number])
- if current_task_state['state'] == self.DONE:
+ if current_task_state['child_state'] == self.DONE:
self.current_task_number += 1
if self.current_task_number >= (len(self.task_xml)):
self.state = self.DONE
@@ -647,7 +638,7 @@ class CombinedOpenEndedV1Module():
Output: Dictionary to be rendered
"""
self.update_task_states()
- return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.allow_reset}
+ return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.ready_to_reset}
def reset(self, get):
"""
@@ -656,26 +647,26 @@ class CombinedOpenEndedV1Module():
Output: AJAX dictionary to tbe rendered
"""
if self.state != self.DONE:
- if not self.allow_reset:
+ if not self.ready_to_reset:
return self.out_of_sync_error(get)
- if self.attempts > self.max_attempts:
+ if self.student_attempts > self.attempts:
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.attempts, self.max_attempts)
+ self.student_attempts, self.attempts)
}
self.state = self.INITIAL
- self.allow_reset = False
+ self.ready_to_reset = False
for i in xrange(0, len(self.task_xml)):
self.current_task_number = i
self.setup_next_task(reset=True)
self.current_task.reset(self.system)
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
self.current_task_number = 0
- self.allow_reset = False
+ self.ready_to_reset = False
self.setup_next_task()
return {'success': True, 'html': self.get_html_nonsystem()}
@@ -691,8 +682,8 @@ class CombinedOpenEndedV1Module():
'current_task_number': self.current_task_number,
'state': self.state,
'task_states': self.task_states,
- 'attempts': self.attempts,
- 'ready_to_reset': self.allow_reset,
+ 'student_attempts': self.student_attempts,
+ 'ready_to_reset': self.ready_to_reset,
}
return json.dumps(state)
@@ -727,7 +718,7 @@ class CombinedOpenEndedV1Module():
entirely, in which case they will be in the self.DONE state), and if it is scored or not.
@return: Boolean corresponding to the above.
"""
- return (self.state == self.DONE or self.allow_reset) and self.is_scored
+ return (self.state == self.DONE or self.ready_to_reset) and self.is_scored
def get_score(self):
"""
@@ -778,7 +769,7 @@ class CombinedOpenEndedV1Module():
return progress_object
-class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor):
+class CombinedOpenEndedV1Descriptor():
"""
Module for adding combined open ended questions
"""
@@ -790,6 +781,9 @@ class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor):
has_score = True
template_dir_name = "combinedopenended"
+ def __init__(self, system):
+ self.system =system
+
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py
index 287aeb5c24..bceb12e444 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py
@@ -101,7 +101,7 @@ class CombinedOpenEndedRubric(object):
log.error(error_message)
raise RubricParsingError(error_message)
- if total != max_score:
+ if int(total) != int(max_score):
#This is a staff_facing_error
error_msg = "The max score {0} for problem {1} does not match the total number of points in the rubric {2}. Contact the learning sciences group for assistance.".format(
max_score, location, total)
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py
index 21715c8e57..08f2a95387 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/controller_query_service.py
@@ -1,5 +1,5 @@
import logging
-from grading_service_module import GradingService
+from .grading_service_module import GradingService
log = logging.getLogger(__name__)
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py
index 0f961794d5..f3f6568b1e 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py
@@ -5,7 +5,7 @@ import requests
from requests.exceptions import RequestException, ConnectionError, HTTPError
import sys
-from combined_open_ended_rubric import CombinedOpenEndedRubric
+from .combined_open_ended_rubric import CombinedOpenEndedRubric
from lxml import etree
log = logging.getLogger(__name__)
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py
index fc53a62c06..1f84d2ab8c 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py
@@ -22,7 +22,7 @@ from numpy import median
from datetime import datetime
-from combined_open_ended_rubric import CombinedOpenEndedRubric
+from .combined_open_ended_rubric import CombinedOpenEndedRubric
log = logging.getLogger("mitx.courseware")
@@ -65,17 +65,17 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
if oeparam is None:
#This is a staff_facing_error
raise ValueError(error_message.format('oeparam'))
- if self.prompt is None:
+ if self.child_prompt is None:
raise ValueError(error_message.format('prompt'))
- if self.rubric is None:
+ if self.child_rubric is None:
raise ValueError(error_message.format('rubric'))
- self._parse(oeparam, self.prompt, self.rubric, system)
+ self._parse(oeparam, self.child_prompt, self.child_rubric, system)
- if self.created == True and self.state == self.ASSESSING:
- self.created = False
+ if self.child_created == True and self.child_state == self.ASSESSING:
+ self.child_created = False
self.send_to_grader(self.latest_answer(), system)
- self.created = False
+ self.child_created = False
def _parse(self, oeparam, prompt, rubric, system):
'''
@@ -89,8 +89,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
# Note that OpenEndedResponse is agnostic to the specific contents of grader_payload
prompt_string = stringify_children(prompt)
rubric_string = stringify_children(rubric)
- self.prompt = prompt_string
- self.rubric = rubric_string
+ self.child_prompt = prompt_string
+ self.child_rubric = rubric_string
grader_payload = oeparam.find('grader_payload')
grader_payload = grader_payload.text if grader_payload is not None else ''
@@ -131,7 +131,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
@param system: ModuleSystem
@return: Success indicator
"""
- self.state = self.DONE
+ self.child_state = self.DONE
return {'success': True}
def message_post(self, get, system):
@@ -171,7 +171,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
anonymous_student_id = system.anonymous_student_id
queuekey = xqueue_interface.make_hashkey(str(system.seed) + qtime +
anonymous_student_id +
- str(len(self.history)))
+ str(len(self.child_history)))
xheader = xqueue_interface.make_xheader(
lms_callback_url=system.xqueue['callback_url'],
@@ -198,7 +198,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
if error:
success = False
- self.state = self.DONE
+ self.child_state = self.DONE
#This is a student_facing_message
return {'success': success, 'msg': "Successfully submitted your feedback."}
@@ -222,7 +222,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
# Generate header
queuekey = xqueue_interface.make_hashkey(str(system.seed) + qtime +
anonymous_student_id +
- str(len(self.history)))
+ str(len(self.child_history)))
xheader = xqueue_interface.make_xheader(lms_callback_url=system.xqueue['callback_url'],
lms_key=queuekey,
@@ -265,7 +265,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
self.record_latest_score(new_score_msg['score'])
self.record_latest_post_assessment(score_msg)
- self.state = self.POST_ASSESSMENT
+ self.child_state = self.POST_ASSESSMENT
return True
@@ -542,16 +542,16 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
@param short_feedback: If the long feedback is wanted or not
@return: Returns formatted feedback
"""
- if not self.history:
+ if not self.child_history:
return ""
- feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), system,
+ feedback_dict = self._parse_score_msg(self.child_history[-1].get('post_assessment', ""), system,
join_feedback=join_feedback)
if not short_feedback:
return feedback_dict['feedback'] if feedback_dict['valid'] else ''
if feedback_dict['valid']:
short_feedback = self._convert_longform_feedback_to_html(
- json.loads(self.history[-1].get('post_assessment', "")))
+ json.loads(self.child_history[-1].get('post_assessment', "")))
return short_feedback if feedback_dict['valid'] else ''
def format_feedback_with_evaluation(self, system, feedback):
@@ -604,7 +604,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
@param system: Modulesystem (needed to align with other ajax functions)
@return: Returns the current state
"""
- state = self.state
+ state = self.child_state
return {'state': state}
def save_answer(self, get, system):
@@ -620,7 +620,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
if closed:
return msg
- if self.state != self.INITIAL:
+ if self.child_state != self.INITIAL:
return self.out_of_sync_error(get)
# add new history element with answer and empty score and hint.
@@ -667,13 +667,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
"""
#set context variables and render template
eta_string = None
- if self.state != self.INITIAL:
+ if self.child_state != self.INITIAL:
latest = self.latest_answer()
previous_answer = latest if latest is not None else self.initial_display
post_assessment = self.latest_post_assessment(system)
score = self.latest_score()
correct = 'correct' if self.is_submission_correct(score) else 'incorrect'
- if self.state == self.ASSESSING:
+ if self.child_state == self.ASSESSING:
eta_string = self.get_eta()
else:
post_assessment = ""
@@ -681,9 +681,9 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
previous_answer = self.initial_display
context = {
- 'prompt': self.prompt,
+ 'prompt': self.child_prompt,
'previous_answer': previous_answer,
- 'state': self.state,
+ 'state': self.child_state,
'allow_reset': self._allow_reset(),
'rows': 30,
'cols': 80,
@@ -698,7 +698,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
return html
-class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
+class OpenEndedDescriptor():
"""
Module for adding open ended response questions to courses
"""
@@ -710,6 +710,9 @@ class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
has_score = True
template_dir_name = "openended"
+ def __init__(self, system):
+ self.system =system
+
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
@@ -731,7 +734,7 @@ class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
"""Assumes that xml_object has child k"""
return xml_object.xpath(k)[0]
- return {'oeparam': parse('openendedparam'), }
+ return {'oeparam': parse('openendedparam')}
def definition_to_xml(self, resource_fs):
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py
index 922a4f9b77..2e49565bec 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py
@@ -1,19 +1,9 @@
-import copy
-from fs.errors import ResourceNotFoundError
-import itertools
import json
import logging
-from lxml import etree
-from lxml.html import rewrite_links
from lxml.html.clean import Cleaner, autolink_html
-from path import path
-import os
-import sys
-import hashlib
-import capa.xqueue_interface as xqueue_interface
import re
-from xmodule.capa_module import only_one, ComplexEncoder
+from xmodule.capa_module import ComplexEncoder
import open_ended_image_submission
from xmodule.editing_module import EditingDescriptor
from xmodule.html_checker import check_html
@@ -22,7 +12,7 @@ from xmodule.stringify import stringify_children
from xmodule.xml_module import XmlDescriptor
from xmodule.modulestore import Location
from capa.util import *
-from peer_grading_service import PeerGradingService, MockPeerGradingService
+from .peer_grading_service import PeerGradingService, MockPeerGradingService
import controller_query_service
from datetime import datetime
@@ -77,8 +67,12 @@ class OpenEndedChild(object):
def __init__(self, system, location, definition, descriptor, static_data,
instance_state=None, shared_state=None, **kwargs):
# Load instance state
+
if instance_state is not None:
- instance_state = json.loads(instance_state)
+ try:
+ instance_state = json.loads(instance_state)
+ except:
+ log.error("Could not load instance state for open ended. Setting it to nothing.: {0}".format(instance_state))
else:
instance_state = {}
@@ -86,26 +80,24 @@ class OpenEndedChild(object):
# None for any element, and score and hint can be None for the last (current)
# element.
# Scores are on scale from 0 to max_score
- self.history = instance_state.get('history', [])
- self.state = instance_state.get('state', self.INITIAL)
+ self.child_history=instance_state.get('child_history',[])
+ self.child_state=instance_state.get('child_state', self.INITIAL)
+ self.child_created = instance_state.get('child_created', False)
+ self.child_attempts = instance_state.get('child_attempts', 0)
- self.created = instance_state.get('created', False)
-
- self.attempts = instance_state.get('attempts', 0)
self.max_attempts = static_data['max_attempts']
-
- self.prompt = static_data['prompt']
- self.rubric = static_data['rubric']
+ self.child_prompt = static_data['prompt']
+ self.child_rubric = static_data['rubric']
self.display_name = static_data['display_name']
self.accept_file_upload = static_data['accept_file_upload']
self.close_date = static_data['close_date']
self.s3_interface = static_data['s3_interface']
self.skip_basic_checks = static_data['skip_basic_checks']
+ self._max_score = static_data['max_score']
# Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect).
- self._max_score = static_data['max_score']
if system.open_ended_grading_interface:
self.peer_gs = PeerGradingService(system.open_ended_grading_interface, system)
self.controller_qs = controller_query_service.ControllerQueryService(system.open_ended_grading_interface,
@@ -147,33 +139,34 @@ class OpenEndedChild(object):
#This is a student_facing_error
'error': 'The problem close date has passed, and this problem is now closed.'
}
- elif self.attempts > self.max_attempts:
+ elif self.child_attempts > self.max_attempts:
return True, {
'success': False,
#This is a student_facing_error
'error': 'You have attempted this problem {0} times. You are allowed {1} attempts.'.format(
- self.attempts, self.max_attempts)
+ self.child_attempts, self.max_attempts
+ )
}
else:
return False, {}
def latest_answer(self):
"""Empty string if not available"""
- if not self.history:
+ if not self.child_history:
return ""
- return self.history[-1].get('answer', "")
+ return self.child_history[-1].get('answer', "")
def latest_score(self):
"""None if not available"""
- if not self.history:
+ if not self.child_history:
return None
- return self.history[-1].get('score')
+ return self.child_history[-1].get('score')
def latest_post_assessment(self, system):
"""Empty string if not available"""
- if not self.history:
+ if not self.child_history:
return ""
- return self.history[-1].get('post_assessment', "")
+ return self.child_history[-1].get('post_assessment', "")
@staticmethod
def sanitize_html(answer):
@@ -195,30 +188,30 @@ class OpenEndedChild(object):
@return: None
"""
answer = OpenEndedChild.sanitize_html(answer)
- self.history.append({'answer': answer})
+ self.child_history.append({'answer': answer})
def record_latest_score(self, score):
"""Assumes that state is right, so we're adding a score to the latest
history element"""
- self.history[-1]['score'] = score
+ self.child_history[-1]['score'] = score
def record_latest_post_assessment(self, post_assessment):
"""Assumes that state is right, so we're adding a score to the latest
history element"""
- self.history[-1]['post_assessment'] = post_assessment
+ self.child_history[-1]['post_assessment'] = post_assessment
def change_state(self, new_state):
"""
A centralized place for state changes--allows for hooks. If the
current state matches the old state, don't run any hooks.
"""
- if self.state == new_state:
+ if self.child_state == new_state:
return
- self.state = new_state
+ self.child_state = new_state
- if self.state == self.DONE:
- self.attempts += 1
+ if self.child_state == self.DONE:
+ self.child_attempts += 1
def get_instance_state(self):
"""
@@ -227,17 +220,17 @@ class OpenEndedChild(object):
state = {
'version': self.STATE_VERSION,
- 'history': self.history,
- 'state': self.state,
+ 'child_history': self.child_history,
+ 'child_state': self.child_state,
'max_score': self._max_score,
- 'attempts': self.attempts,
- 'created': False,
+ 'child_attempts': self.child_attempts,
+ 'child_created': False,
}
return json.dumps(state)
def _allow_reset(self):
"""Can the module be reset?"""
- return (self.state == self.DONE and self.attempts < self.max_attempts)
+ return (self.child_state == self.DONE and self.child_attempts < self.max_attempts)
def max_score(self):
"""
@@ -269,10 +262,10 @@ class OpenEndedChild(object):
'''
if self._max_score > 0:
try:
- return Progress(self.get_score()['score'], self._max_score)
+ return Progress(int(self.get_score()['score']), int(self._max_score))
except Exception as err:
#This is a dev_facing_error
- log.exception("Got bad progress from open ended child module. Max Score: {1}".format(self._max_score))
+ log.exception("Got bad progress from open ended child module. Max Score: {0}".format(self._max_score))
return None
return None
@@ -282,7 +275,7 @@ class OpenEndedChild(object):
"""
#This is a dev_facing_error
log.warning("Open ended child state out sync. state: %r, get: %r. %s",
- self.state, get, msg)
+ self.child_state, get, msg)
#This is a student_facing_error
return {'success': False,
'error': 'The problem state got out-of-sync. Please try reloading the page.'}
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py
index 5daf1b83b5..85c7a98132 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py
@@ -1,7 +1,7 @@
import json
import logging
-from grading_service_module import GradingService
+from .grading_service_module import GradingService
log = logging.getLogger(__name__)
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py
index 8911e2890f..5fb901d49c 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py
@@ -3,13 +3,11 @@ import logging
from lxml import etree
from xmodule.capa_module import ComplexEncoder
-from xmodule.editing_module import EditingDescriptor
from xmodule.progress import Progress
from xmodule.stringify import stringify_children
-from xmodule.xml_module import XmlDescriptor
import openendedchild
-from combined_open_ended_rubric import CombinedOpenEndedRubric
+from .combined_open_ended_rubric import CombinedOpenEndedRubric
log = logging.getLogger("mitx.courseware")
@@ -31,8 +29,12 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
"""
-
TEMPLATE_DIR = "combinedopenended/selfassessment"
+ # states
+ INITIAL = 'initial'
+ ASSESSING = 'assessing'
+ REQUEST_HINT = 'request_hint'
+ DONE = 'done'
def setup_response(self, system, location, definition, descriptor):
"""
@@ -43,8 +45,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
@param descriptor: SelfAssessmentDescriptor
@return: None
"""
- self.prompt = stringify_children(self.prompt)
- self.rubric = stringify_children(self.rubric)
+ self.child_prompt = stringify_children(self.child_prompt)
+ self.child_rubric = stringify_children(self.child_rubric)
def get_html(self, system):
"""
@@ -53,18 +55,18 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
@return: Rendered HTML
"""
#set context variables and render template
- if self.state != self.INITIAL:
+ if self.child_state != self.INITIAL:
latest = self.latest_answer()
previous_answer = latest if latest is not None else ''
else:
previous_answer = ''
context = {
- 'prompt': self.prompt,
+ 'prompt': self.child_prompt,
'previous_answer': previous_answer,
'ajax_url': system.ajax_url,
'initial_rubric': self.get_rubric_html(system),
- 'state': self.state,
+ 'state': self.child_state,
'allow_reset': self._allow_reset(),
'child_type': 'selfassessment',
'accept_file_upload': self.accept_file_upload,
@@ -109,11 +111,11 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
"""
Return the appropriate version of the rubric, based on the state.
"""
- if self.state == self.INITIAL:
+ if self.child_state == self.INITIAL:
return ''
rubric_renderer = CombinedOpenEndedRubric(system, False)
- rubric_dict = rubric_renderer.render_rubric(self.rubric)
+ rubric_dict = rubric_renderer.render_rubric(self.child_rubric)
success = rubric_dict['success']
rubric_html = rubric_dict['html']
@@ -122,13 +124,13 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
'max_score': self._max_score,
}
- if self.state == self.ASSESSING:
+ if self.child_state == self.ASSESSING:
context['read_only'] = False
- elif self.state in (self.POST_ASSESSMENT, self.DONE):
+ elif self.child_state in (self.POST_ASSESSMENT, self.DONE):
context['read_only'] = True
else:
#This is a dev_facing_error
- raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.state))
+ raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.child_state))
return system.render_template('{0}/self_assessment_rubric.html'.format(self.TEMPLATE_DIR), context)
@@ -136,10 +138,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
"""
Return the appropriate version of the hint view, based on state.
"""
- if self.state in (self.INITIAL, self.ASSESSING):
+ if self.child_state in (self.INITIAL, self.ASSESSING):
return ''
- if self.state == self.DONE:
+ if self.child_state == self.DONE:
# display the previous hint
latest = self.latest_post_assessment(system)
hint = latest if latest is not None else ''
@@ -148,13 +150,13 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
context = {'hint': hint}
- if self.state == self.POST_ASSESSMENT:
+ if self.child_state == self.POST_ASSESSMENT:
context['read_only'] = False
- elif self.state == self.DONE:
+ elif self.child_state == self.DONE:
context['read_only'] = True
else:
#This is a dev_facing_error
- raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.state))
+ raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.child_state))
return system.render_template('{0}/self_assessment_hint.html'.format(self.TEMPLATE_DIR), context)
@@ -175,7 +177,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
if closed:
return msg
- if self.state != self.INITIAL:
+ if self.child_state != self.INITIAL:
return self.out_of_sync_error(get)
error_message = ""
@@ -216,7 +218,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
'message_html' only if success is true
"""
- if self.state != self.ASSESSING:
+ if self.child_state != self.ASSESSING:
return self.out_of_sync_error(get)
try:
@@ -239,7 +241,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
self.change_state(self.DONE)
d['allow_reset'] = self._allow_reset()
- d['state'] = self.state
+ d['state'] = self.child_state
return d
def save_hint(self, get, system):
@@ -253,7 +255,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
with the error key only present if success is False and message_html
only if True.
'''
- if self.state != self.POST_ASSESSMENT:
+ if self.child_state != self.POST_ASSESSMENT:
# Note: because we only ask for hints on wrong answers, may not have
# the same number of hints and answers.
return self.out_of_sync_error(get)
@@ -276,7 +278,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
return [rubric_scores]
-class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
+class SelfAssessmentDescriptor():
"""
Module for adding self assessment questions to courses
"""
@@ -288,6 +290,9 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
has_score = True
template_dir_name = "selfassessment"
+ def __init__(self, system):
+ self.system =system
+
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
@@ -318,7 +323,7 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
elt = etree.Element('selfassessment')
def add_child(k):
- child_str = '<{tag}>{body}{tag}>'.format(tag=k, body=self.definition[k])
+ child_str = '<{tag}>{body}{tag}>'.format(tag=k, body=getattr(self, k))
child_node = etree.fromstring(child_str)
elt.append(child_node)
diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py
index 2ea8ab0db5..e18f2ceca3 100644
--- a/common/lib/xmodule/xmodule/peer_grading_module.py
+++ b/common/lib/xmodule/xmodule/peer_grading_module.py
@@ -6,13 +6,13 @@ from lxml import etree
from datetime import datetime
from pkg_resources import resource_string
from .capa_module import ComplexEncoder
-from .editing_module import EditingDescriptor
from .stringify import stringify_children
from .x_module import XModule
-from .xml_module import XmlDescriptor
+from xmodule.raw_module import RawDescriptor
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
-from timeinfo import TimeInfo
+from .timeinfo import TimeInfo
+from xblock.core import Object, Integer, Boolean, String, Scope
from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService
@@ -27,7 +27,17 @@ IS_GRADED = True
EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please notify course staff."
-class PeerGradingModule(XModule):
+class PeerGradingFields(object):
+ use_for_single_location = Boolean(help="Whether to use this for a single location or as a panel.", default=USE_FOR_SINGLE_LOCATION, scope=Scope.settings)
+ link_to_location = String(help="The location this problem is linked to.", default=LINK_TO_LOCATION, scope=Scope.settings)
+ is_graded = Boolean(help="Whether or not this module is scored.",default=IS_GRADED, scope=Scope.settings)
+ display_due_date_string = String(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 receieve for this problem.", default=MAX_SCORE, scope=Scope.settings)
+ student_data_for_location = Object(help="Student data for a given peer grading problem.", default=json.dumps({}),scope=Scope.student_state)
+
+
+class PeerGradingModule(PeerGradingFields, XModule):
_VERSION = 1
js = {'coffee': [resource_string(__name__, 'js/src/peergrading/peer_grading.coffee'),
@@ -39,16 +49,8 @@ class PeerGradingModule(XModule):
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
- def __init__(self, system, location, definition, descriptor,
- instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
-
- # Load instance state
- if instance_state is not None:
- instance_state = json.loads(instance_state)
- else:
- instance_state = {}
+ def __init__(self, system, location, descriptor, model_data):
+ XModule.__init__(self, system, location, descriptor, model_data)
#We need to set the location here so the child modules can use it
system.set('location', location)
@@ -58,43 +60,34 @@ class PeerGradingModule(XModule):
else:
self.peer_gs = MockPeerGradingService()
- self.use_for_single_location = self.metadata.get('use_for_single_location', USE_FOR_SINGLE_LOCATION)
- if isinstance(self.use_for_single_location, basestring):
- self.use_for_single_location = (self.use_for_single_location in TRUE_DICT)
-
- self.link_to_location = self.metadata.get('link_to_location', USE_FOR_SINGLE_LOCATION)
- if self.use_for_single_location == True:
+ if self.use_for_single_location in TRUE_DICT:
try:
self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location)
except:
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.metadata.get('peer_grading_due', None)
+ due_date = self.linked_problem._model_data.get('peer_grading_due', None)
if due_date:
- self.metadata['due'] = due_date
-
- self.is_graded = self.metadata.get('is_graded', IS_GRADED)
- if isinstance(self.is_graded, basestring):
- self.is_graded = (self.is_graded in TRUE_DICT)
-
- display_due_date_string = self.metadata.get('due', None)
- grace_period_string = self.metadata.get('graceperiod', None)
+ self._model_data['due'] = due_date
try:
- self.timeinfo = TimeInfo(display_due_date_string, grace_period_string)
+ self.timeinfo = TimeInfo(self.display_due_date_string, self.grace_period_string)
except:
log.error("Error parsing due date information in location {0}".format(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:
+ pass
+
self.ajax_url = self.system.ajax_url
if not self.ajax_url.endswith("/"):
self.ajax_url = self.ajax_url + "/"
- self.student_data_for_location = instance_state.get('student_data_for_location', {})
- self.max_grade = instance_state.get('max_grade', MAX_SCORE)
if not isinstance(self.max_grade, (int, long)):
#This could result in an exception, but not wrapping in a try catch block so it moves up the stack
self.max_grade = int(self.max_grade)
@@ -129,7 +122,7 @@ class PeerGradingModule(XModule):
"""
if self.closed():
return self.peer_grading_closed()
- if not self.use_for_single_location:
+ if self.use_for_single_location not in TRUE_DICT:
return self.peer_grading()
else:
return self.peer_grading_problem({'location': self.link_to_location})['html']
@@ -180,7 +173,7 @@ class PeerGradingModule(XModule):
pass
def get_score(self):
- if not self.use_for_single_location or not self.is_graded:
+ if self.use_for_single_location not in TRUE_DICT or self.is_graded not in TRUE_DICT:
return None
try:
@@ -214,7 +207,7 @@ class PeerGradingModule(XModule):
randomization, and 5/7 on another
'''
max_grade = None
- if self.use_for_single_location and self.is_graded:
+ if self.use_for_single_location in TRUE_DICT and self.is_graded in TRUE_DICT:
max_grade = self.max_grade
return max_grade
@@ -467,11 +460,13 @@ class PeerGradingModule(XModule):
except GradingServiceError:
#This is a student_facing_error
error_text = EXTERNAL_GRADER_NO_CONTACT_ERROR
+ log.error(error_text)
success = False
# catch error if if the json loads fails
except ValueError:
#This is a student_facing_error
error_text = "Could not get list of problems to peer grade. Please notify course staff."
+ log.error(error_text)
success = False
except:
log.exception("Could not contact peer grading service.")
@@ -494,8 +489,8 @@ class PeerGradingModule(XModule):
problem_location = problem['location']
descriptor = _find_corresponding_module_for_location(problem_location)
if descriptor:
- problem['due'] = descriptor.metadata.get('peer_grading_due', None)
- grace_period_string = descriptor.metadata.get('graceperiod', None)
+ problem['due'] = descriptor._model_data.get('peer_grading_due', None)
+ grace_period_string = descriptor._model_data.get('graceperiod', None)
try:
problem_timeinfo = TimeInfo(problem['due'], grace_period_string)
except:
@@ -506,7 +501,7 @@ class PeerGradingModule(XModule):
else:
problem['closed'] = False
else:
- # if we can't find the due date, assume that it doesn't have one
+ # if we can't find the due date, assume that it doesn't have one
problem['due'] = None
problem['closed'] = False
@@ -529,7 +524,7 @@ class PeerGradingModule(XModule):
Show individual problem interface
'''
if get is None or get.get('location') is None:
- if not self.use_for_single_location:
+ if self.use_for_single_location not in TRUE_DICT:
#This is an error case, because it must be set to use a single location to be called without get parameters
#This is a dev_facing_error
log.error(
@@ -567,9 +562,9 @@ class PeerGradingModule(XModule):
return json.dumps(state)
-class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor):
+class PeerGradingDescriptor(PeerGradingFields, RawDescriptor):
"""
- Module for adding combined open ended questions
+ Module for adding peer grading questions
"""
mako_template = "widgets/raw-edit.html"
module_class = PeerGradingModule
@@ -578,42 +573,3 @@ class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor):
stores_state = True
has_score = True
template_dir_name = "peer_grading"
-
- js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
- js_module_name = "HTMLEditingDescriptor"
-
- @classmethod
- def definition_from_xml(cls, xml_object, system):
- """
- Pull out the individual tasks, the rubric, and the prompt, and parse
-
- Returns:
- {
- 'rubric': 'some-html',
- 'prompt': 'some-html',
- 'task_xml': dictionary of xml strings,
- }
- """
- expected_children = []
- for child in expected_children:
- if len(xml_object.xpath(child)) == 0:
- #This is a staff_facing_error
- raise ValueError(
- "Peer grading definition must include at least one '{0}' tag. Contact the learning sciences group for assistance.".format(
- child))
-
- def parse_task(k):
- """Assumes that xml_object has child k"""
- return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))]
-
- def parse(k):
- """Assumes that xml_object has child k"""
- return xml_object.xpath(k)[0]
-
- return {}
-
-
- def definition_to_xml(self, resource_fs):
- '''Return an xml element representing this definition.'''
- elt = etree.Element('peergrading')
- return elt
diff --git a/common/lib/xmodule/xmodule/plugin.py b/common/lib/xmodule/xmodule/plugin.py
new file mode 100644
index 0000000000..5cf9c647aa
--- /dev/null
+++ b/common/lib/xmodule/xmodule/plugin.py
@@ -0,0 +1,64 @@
+import pkg_resources
+import logging
+
+log = logging.getLogger(__name__)
+
+class PluginNotFoundError(Exception):
+ pass
+
+
+class Plugin(object):
+ """
+ Base class for a system that uses entry_points to load plugins.
+
+ Implementing classes are expected to have the following attributes:
+
+ entry_point: The name of the entry point to load plugins from
+ """
+
+ _plugin_cache = None
+
+ @classmethod
+ def load_class(cls, identifier, default=None):
+ """
+ Loads a single class instance specified by identifier. If identifier
+ specifies more than a single class, then logs a warning and returns the
+ first class identified.
+
+ If default is not None, will return default if no entry_point matching
+ identifier is found. Otherwise, will raise a ModuleMissingError
+ """
+ if cls._plugin_cache is None:
+ cls._plugin_cache = {}
+
+ if identifier not in cls._plugin_cache:
+ identifier = identifier.lower()
+ classes = list(pkg_resources.iter_entry_points(
+ cls.entry_point, name=identifier))
+
+ if len(classes) > 1:
+ log.warning("Found multiple classes for {entry_point} with "
+ "identifier {id}: {classes}. "
+ "Returning the first one.".format(
+ entry_point=cls.entry_point,
+ id=identifier,
+ classes=", ".join(
+ class_.module_name for class_ in classes)))
+
+ if len(classes) == 0:
+ if default is not None:
+ return default
+ raise PluginNotFoundError(identifier)
+
+ cls._plugin_cache[identifier] = classes[0].load()
+ return cls._plugin_cache[identifier]
+
+ @classmethod
+ def load_classes(cls):
+ """
+ Returns a list of containing the identifiers and their corresponding classes for all
+ of the available instances of this plugin
+ """
+ return [(class_.name, class_.load())
+ for class_
+ in pkg_resources.iter_entry_points(cls.entry_point)]
diff --git a/common/lib/xmodule/xmodule/poll_module.py b/common/lib/xmodule/xmodule/poll_module.py
new file mode 100644
index 0000000000..0fb3bfb496
--- /dev/null
+++ b/common/lib/xmodule/xmodule/poll_module.py
@@ -0,0 +1,205 @@
+"""Poll module is ungraded xmodule used by students to
+to do set of polls.
+
+On the client side we show:
+If student does not yet anwered - Question with set of choices.
+If student have answered - Question with statistics for each answers.
+
+Student can't change his answer.
+"""
+
+import cgi
+import json
+import logging
+from copy import deepcopy
+from collections import OrderedDict
+
+from lxml import etree
+from pkg_resources import resource_string
+
+from xmodule.x_module import XModule
+from xmodule.stringify import stringify_children
+from xmodule.mako_module import MakoModuleDescriptor
+from xmodule.xml_module import XmlDescriptor
+from xblock.core import Scope, String, Object, Boolean, List
+
+log = logging.getLogger(__name__)
+
+
+class PollFields(object):
+ # Name of poll to use in links to this poll
+ display_name = String(help="Display name for this module", scope=Scope.settings)
+
+ voted = Boolean(help="Whether this student has voted on the poll", scope=Scope.student_state, default=False)
+ poll_answer = String(help="Student answer", scope=Scope.student_state, default='')
+ poll_answers = Object(help="All possible answers for the poll fro other students", scope=Scope.content)
+
+ answers = List(help="Poll answers from xml", scope=Scope.content, default=[])
+ question = String(help="Poll question", scope=Scope.content, default='')
+
+
+class PollModule(PollFields, XModule):
+ """Poll Module"""
+ js = {
+ 'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee')],
+ 'js': [resource_string(__name__, 'js/src/poll/logme.js'),
+ resource_string(__name__, 'js/src/poll/poll.js'),
+ resource_string(__name__, 'js/src/poll/poll_main.js')]
+ }
+ css = {'scss': [resource_string(__name__, 'css/poll/display.scss')]}
+ js_module_name = "Poll"
+
+ def handle_ajax(self, dispatch, get):
+ """Ajax handler.
+
+ Args:
+ dispatch: string request slug
+ get: dict request get parameters
+
+ Returns:
+ json string
+ """
+ if dispatch in self.poll_answers and not self.voted:
+ # FIXME: fix this, when xblock will support mutable types.
+ # Now we use this hack.
+ temp_poll_answers = self.poll_answers
+ temp_poll_answers[dispatch] += 1
+ self.poll_answers = temp_poll_answers
+
+ self.voted = True
+ self.poll_answer = dispatch
+ return json.dumps({'poll_answers': self.poll_answers,
+ 'total': sum(self.poll_answers.values()),
+ 'callback': {'objectName': 'Conditional'}
+ })
+ elif dispatch == 'get_state':
+ return json.dumps({'poll_answer': self.poll_answer,
+ 'poll_answers': self.poll_answers,
+ 'total': sum(self.poll_answers.values())
+ })
+ elif dispatch == 'reset_poll' and self.voted and \
+ self.descriptor.xml_attributes.get('reset', 'True').lower() != 'false':
+ self.voted = False
+
+ # FIXME: fix this, when xblock will support mutable types.
+ # Now we use this hack.
+ temp_poll_answers = self.poll_answers
+ temp_poll_answers[self.poll_answer] -= 1
+ self.poll_answers = temp_poll_answers
+
+ self.poll_answer = ''
+ return json.dumps({'status': 'success'})
+ else: # return error message
+ return json.dumps({'error': 'Unknown Command!'})
+
+ def get_html(self):
+ """Renders parameters to template."""
+ params = {
+ 'element_id': self.location.html_id(),
+ 'element_class': self.location.category,
+ 'ajax_url': self.system.ajax_url,
+ 'configuration_json': self.dump_poll(),
+ }
+ self.content = self.system.render_template('poll.html', params)
+ return self.content
+
+ def dump_poll(self):
+ """Dump poll information.
+
+ Returns:
+ string - Serialize json.
+ """
+ # FIXME: hack for resolving caching `default={}` during definition
+ # poll_answers field
+ if self.poll_answers is None:
+ self.poll_answers = {}
+
+ answers_to_json = OrderedDict()
+
+ # FIXME: fix this, when xblock support mutable types.
+ # Now we use this hack.
+ temp_poll_answers = self.poll_answers
+
+ # Fill self.poll_answers, prepare data for template context.
+ for answer in self.answers:
+ # Set default count for answer = 0.
+ if answer['id'] not in temp_poll_answers:
+ temp_poll_answers[answer['id']] = 0
+ answers_to_json[answer['id']] = cgi.escape(answer['text'])
+ self.poll_answers = temp_poll_answers
+
+ return json.dumps({'answers': answers_to_json,
+ 'question': cgi.escape(self.question),
+ # to show answered poll after reload:
+ 'poll_answer': self.poll_answer,
+ 'poll_answers': self.poll_answers if self.voted else {},
+ 'total': sum(self.poll_answers.values()) if self.voted else 0,
+ 'reset': str(self.descriptor.xml_attributes.get('reset', 'true')).lower()})
+
+
+class PollDescriptor(PollFields, MakoModuleDescriptor, XmlDescriptor):
+ _tag_name = 'poll_question'
+ _child_tag_name = 'answer'
+
+ module_class = PollModule
+ template_dir_name = 'poll'
+ stores_state = True
+
+ @classmethod
+ def definition_from_xml(cls, xml_object, system):
+ """Pull out the data into dictionary.
+
+ Args:
+ xml_object: xml from file.
+ system: `system` object.
+
+ Returns:
+ (definition, children) - tuple
+ definition - dict:
+ {
+ 'answers': ,
+ 'question':
+ }
+ """
+ # Check for presense of required tags in xml.
+ if len(xml_object.xpath(cls._child_tag_name)) == 0:
+ raise ValueError("Poll_question definition must include \
+ at least one 'answer' tag")
+
+ xml_object_copy = deepcopy(xml_object)
+ answers = []
+ for element_answer in xml_object_copy.findall(cls._child_tag_name):
+ answer_id = element_answer.get('id', None)
+ if answer_id:
+ answers.append({
+ 'id': answer_id,
+ 'text': stringify_children(element_answer)
+ })
+ xml_object_copy.remove(element_answer)
+
+ definition = {
+ 'answers': answers,
+ 'question': stringify_children(xml_object_copy)
+ }
+ children = []
+
+ return (definition, children)
+
+ def definition_to_xml(self, resource_fs):
+ """Return an xml element representing to this definition."""
+ poll_str = '<{tag_name}>{text}{tag_name}>'.format(
+ tag_name=self._tag_name, text=self.question)
+ xml_object = etree.fromstring(poll_str)
+ xml_object.set('display_name', self.display_name)
+
+ def add_child(xml_obj, answer):
+ child_str = '<{tag_name} id="{id}">{text}{tag_name}>'.format(
+ tag_name=self._child_tag_name, id=answer['id'],
+ text=answer['text'])
+ child_node = etree.fromstring(child_str)
+ xml_object.append(child_node)
+
+ for answer in self.answers:
+ add_child(xml_object, answer)
+
+ return xml_object
diff --git a/common/lib/xmodule/xmodule/randomize_module.py b/common/lib/xmodule/xmodule/randomize_module.py
index b336789193..6620ab3cf7 100644
--- a/common/lib/xmodule/xmodule/randomize_module.py
+++ b/common/lib/xmodule/xmodule/randomize_module.py
@@ -1,19 +1,19 @@
-import json
import logging
import random
-from xmodule.mako_module import MakoModuleDescriptor
from xmodule.x_module import XModule
-from xmodule.xml_module import XmlDescriptor
-from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor
-from pkg_resources import resource_string
+from xblock.core import Scope, Integer
log = logging.getLogger('mitx.' + __name__)
-class RandomizeModule(XModule):
+class RandomizeFields(object):
+ choice = Integer(help="Which random child was chosen", scope=Scope.student_state)
+
+
+class RandomizeModule(RandomizeFields, XModule):
"""
Chooses a random child module. Chooses the same one every time for each student.
@@ -35,30 +35,23 @@ class RandomizeModule(XModule):
grading interaction is a tangle between super and subclasses of descriptors and
modules.
"""
-
- def __init__(self, system, location, definition, descriptor,
- instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
+ def __init__(self, *args, **kwargs):
+ XModule.__init__(self, *args, **kwargs)
# NOTE: calling self.get_children() creates a circular reference--
# it calls get_child_descriptors() internally, but that doesn't work until
# we've picked a choice
num_choices = len(self.descriptor.get_children())
- self.choice = None
- if instance_state is not None:
- state = json.loads(instance_state)
- self.choice = state.get('choice', None)
- if self.choice > num_choices:
- # Oops. Children changed. Reset.
- self.choice = None
+ if self.choice > num_choices:
+ # Oops. Children changed. Reset.
+ self.choice = None
if self.choice is None:
# choose one based on the system seed, or randomly if that's not available
if num_choices > 0:
- if system.seed is not None:
- self.choice = system.seed % num_choices
+ if self.system.seed is not None:
+ self.choice = self.system.seed % num_choices
else:
self.choice = random.randrange(0, num_choices)
@@ -72,11 +65,6 @@ class RandomizeModule(XModule):
self.child_descriptor = None
self.child = None
-
- def get_instance_state(self):
- return json.dumps({'choice': self.choice})
-
-
def get_child_descriptors(self):
"""
For grading--return just the chosen child.
@@ -98,7 +86,7 @@ class RandomizeModule(XModule):
return self.child.get_icon_class() if self.child else 'other'
-class RandomizeDescriptor(SequenceDescriptor):
+class RandomizeDescriptor(RandomizeFields, SequenceDescriptor):
# the editing interface can be the same as for sequences -- just a container
module_class = RandomizeModule
@@ -107,6 +95,7 @@ class RandomizeDescriptor(SequenceDescriptor):
stores_state = True
def definition_to_xml(self, resource_fs):
+
xml_object = etree.Element('randomize')
for child in self.get_children():
xml_object.append(
diff --git a/common/lib/xmodule/xmodule/raw_module.py b/common/lib/xmodule/xmodule/raw_module.py
index 4a2bfbceaf..2c6e157018 100644
--- a/common/lib/xmodule/xmodule/raw_module.py
+++ b/common/lib/xmodule/xmodule/raw_module.py
@@ -3,6 +3,7 @@ from xmodule.editing_module import XMLEditingDescriptor
from xmodule.xml_module import XmlDescriptor
import logging
import sys
+from xblock.core import String, Scope
log = logging.getLogger(__name__)
@@ -12,17 +13,19 @@ 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)
+
@classmethod
def definition_from_xml(cls, xml_object, system):
- return {'data': etree.tostring(xml_object, pretty_print=True, encoding='unicode')}
+ return {'data': etree.tostring(xml_object, pretty_print=True, encoding='unicode')}, []
def definition_to_xml(self, resource_fs):
try:
- return etree.fromstring(self.definition['data'])
+ return etree.fromstring(self.data)
except etree.XMLSyntaxError as err:
# Can't recover here, so just add some info and
# re-raise
- lines = self.definition['data'].split('\n')
+ lines = self.data.split('\n')
line, offset = err.position
msg = ("Unable to create xml for problem {loc}. "
"Context: '{context}'".format(
diff --git a/common/lib/xmodule/xmodule/schematic_module.py b/common/lib/xmodule/xmodule/schematic_module.py
index 21dd33a897..d15d629c24 100644
--- a/common/lib/xmodule/xmodule/schematic_module.py
+++ b/common/lib/xmodule/xmodule/schematic_module.py
@@ -1,6 +1,6 @@
import json
-from x_module import XModule, XModuleDescriptor
+from .x_module import XModule, XModuleDescriptor
class ModuleDescriptor(XModuleDescriptor):
diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py
index 36011744f5..f8e982f1a0 100644
--- a/common/lib/xmodule/xmodule/seq_module.py
+++ b/common/lib/xmodule/xmodule/seq_module.py
@@ -8,6 +8,7 @@ from xmodule.xml_module import XmlDescriptor
from xmodule.x_module import XModule
from xmodule.progress import Progress
from xmodule.exceptions import NotFoundError
+from xblock.core import Integer, Scope
from pkg_resources import resource_string
log = logging.getLogger(__name__)
@@ -17,7 +18,15 @@ log = logging.getLogger(__name__)
class_priority = ['video', 'problem']
-class SequenceModule(XModule):
+class SequenceFields(object):
+ has_children = True
+
+ # NOTE: Position is 1-indexed. This is silly, but there are now student
+ # positions saved on prod, so it's not easy to fix.
+ position = Integer(help="Last tab viewed in this sequence", scope=Scope.student_state)
+
+
+class SequenceModule(SequenceFields, XModule):
''' Layout module which lays out content in a temporal sequence
'''
js = {'coffee': [resource_string(__name__,
@@ -26,22 +35,13 @@ class SequenceModule(XModule):
css = {'scss': [resource_string(__name__, 'css/sequence/display.scss')]}
js_module_name = "Sequence"
- def __init__(self, system, location, definition, descriptor, instance_state=None,
- shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
- # NOTE: Position is 1-indexed. This is silly, but there are now student
- # positions saved on prod, so it's not easy to fix.
- self.position = 1
- if instance_state is not None:
- state = json.loads(instance_state)
- if 'position' in state:
- self.position = int(state['position'])
+ def __init__(self, *args, **kwargs):
+ XModule.__init__(self, *args, **kwargs)
# if position is specified in system, then use that instead
- if system.get('position'):
- self.position = int(system.get('position'))
+ if self.system.get('position'):
+ self.position = int(self.system.get('position'))
self.rendered = False
@@ -70,6 +70,11 @@ class SequenceModule(XModule):
raise NotFoundError('Unexpected dispatch type')
def render(self):
+ # If we're rendering this sequence, but no position is set yet,
+ # default the position to the first element
+ if self.position is None:
+ self.position = 1
+
if self.rendered:
return
## Returns a set of all types of all sub-children
@@ -79,9 +84,9 @@ class SequenceModule(XModule):
childinfo = {
'content': child.get_html(),
'title': "\n".join(
- grand_child.display_name.strip()
+ grand_child.display_name
for grand_child in child.get_children()
- if 'display_name' in grand_child.metadata
+ if grand_child.display_name is not None
),
'progress_status': Progress.to_js_status_str(progress),
'progress_detail': Progress.to_js_detail_str(progress),
@@ -89,7 +94,7 @@ class SequenceModule(XModule):
'id': child.id,
}
if childinfo['title'] == '':
- childinfo['title'] = child.metadata.get('display_name', '')
+ childinfo['title'] = child.display_name_with_default
contents.append(childinfo)
params = {'items': contents,
@@ -112,11 +117,11 @@ class SequenceModule(XModule):
return new_class
-class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor):
+class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor):
mako_template = 'widgets/sequence-edit.html'
module_class = SequenceModule
- stores_state = True # For remembering where in the sequence the student is
+ stores_state = True # For remembering where in the sequence the student is
js = {'coffee': [resource_string(__name__, 'js/src/sequence/edit.coffee')]}
js_module_name = "SequenceDescriptor"
@@ -132,7 +137,7 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor):
if system.error_tracker is not None:
system.error_tracker("ERROR: " + str(e))
continue
- return {'children': children}
+ return {}, children
def definition_to_xml(self, resource_fs):
xml_object = etree.Element('sequential')
diff --git a/common/lib/xmodule/xmodule/stringify.py b/common/lib/xmodule/xmodule/stringify.py
index 5a640e91b1..35587d3b09 100644
--- a/common/lib/xmodule/xmodule/stringify.py
+++ b/common/lib/xmodule/xmodule/stringify.py
@@ -1,4 +1,5 @@
-from itertools import chain
+# -*- coding: utf-8 -*-
+
from lxml import etree
diff --git a/common/lib/xmodule/xmodule/template_module.py b/common/lib/xmodule/xmodule/template_module.py
index 5f376945eb..d79d2a163e 100644
--- a/common/lib/xmodule/xmodule/template_module.py
+++ b/common/lib/xmodule/xmodule/template_module.py
@@ -28,11 +28,6 @@ class CustomTagModule(XModule):
More information given in the text
"""
- def __init__(self, system, location, definition, descriptor,
- instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
-
def get_html(self):
return self.descriptor.rendered_html
@@ -62,19 +57,15 @@ class CustomTagDescriptor(RawDescriptor):
# cdodge: look up the template as a module
template_loc = self.location._replace(category='custom_tag_template', name=template_name)
- template_module = self.system.load_item(template_loc)
- template_module_data = template_module.definition['data']
+ template_module = modulestore().get_instance(system.course_id, template_loc)
+ template_module_data = template_module.data
template = Template(template_module_data)
return template.render(**params)
- def __init__(self, system, definition, **kwargs):
- '''Render and save the template for this descriptor instance'''
- super(CustomTagDescriptor, self).__init__(system, definition, **kwargs)
-
@property
def rendered_html(self):
- return self.render_template(self.system, self.definition['data'])
+ return self.render_template(self.system, self.data)
def export_to_file(self):
"""
diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py
index 43c2bbe24d..1a10654f6c 100644
--- a/common/lib/xmodule/xmodule/tests/__init__.py
+++ b/common/lib/xmodule/xmodule/tests/__init__.py
@@ -54,6 +54,7 @@ def test_system():
debug=True,
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10},
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
+ xblock_model_data=lambda descriptor: descriptor._model_data,
anonymous_student_id='student',
open_ended_grading_interface= open_ended_grading_interface
)
diff --git a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py
index 30f9c9ff92..43eae8e43e 100644
--- a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py
@@ -28,13 +28,11 @@ class AnnotatableModuleTestCase(unittest.TestCase):
The Iliad of Homer by Samuel Butler
'''
- definition = { 'data': sample_xml }
descriptor = Mock()
- instance_state = None
- shared_state = None
+ module_data = {'data': sample_xml}
def setUp(self):
- self.annotatable = AnnotatableModule(test_system(), self.location, self.definition, self.descriptor, self.instance_state, self.shared_state)
+ self.annotatable = AnnotatableModule(test_system(), self.location, self.descriptor, self.module_data)
def test_annotation_data_attr(self):
el = etree.fromstring('test')
diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py
index cb77921957..d2458cb3d0 100644
--- a/common/lib/xmodule/xmodule/tests/test_capa_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py
@@ -59,7 +59,8 @@ class CapaFactory(object):
force_save_button=None,
attempts=None,
problem_state=None,
- correct=False
+ correct=False,
+ done=None
):
"""
All parameters are optional, and are added to the created problem if specified.
@@ -77,48 +78,42 @@ class CapaFactory(object):
attempts: also added to instance state. Will be converted to an int.
"""
- definition = {'data': CapaFactory.sample_problem_xml, }
location = Location(["i4x", "edX", "capa_test", "problem",
- "SampleProblem%d" % CapaFactory.next_num()])
- metadata = {}
- if graceperiod is not None:
- metadata['graceperiod'] = graceperiod
- if due is not None:
- metadata['due'] = due
- if max_attempts is not None:
- metadata['attempts'] = max_attempts
- if showanswer is not None:
- metadata['showanswer'] = showanswer
- if force_save_button is not None:
- metadata['force_save_button'] = force_save_button
- if rerandomize is not None:
- metadata['rerandomize'] = rerandomize
+ "SampleProblem{0}".format(CapaFactory.next_num())])
+ model_data = {'data': CapaFactory.sample_problem_xml}
+ if graceperiod is not None:
+ model_data['graceperiod'] = graceperiod
+ if due is not None:
+ model_data['due'] = due
+ if max_attempts is not None:
+ model_data['max_attempts'] = max_attempts
+ if showanswer is not None:
+ model_data['showanswer'] = showanswer
+ if force_save_button is not None:
+ model_data['force_save_button'] = force_save_button
+ if rerandomize is not None:
+ model_data['rerandomize'] = rerandomize
+ if done is not None:
+ model_data['done'] = done
descriptor = Mock(weight="1")
- instance_state_dict = {}
if problem_state is not None:
- instance_state_dict = problem_state
-
+ model_data.update(problem_state)
if attempts is not None:
# converting to int here because I keep putting "0" and "1" in the tests
# since everything else is a string.
- instance_state_dict['attempts'] = int(attempts)
-
- if len(instance_state_dict) > 0:
- instance_state = json.dumps(instance_state_dict)
- else:
- instance_state = None
+ model_data['attempts'] = int(attempts)
system = test_system()
system.render_template = Mock(return_value="
Test Template HTML
")
- module = CapaModule(system, location,
- definition, descriptor,
- instance_state, None, metadata=metadata)
+ module = CapaModule(system, location, descriptor, model_data)
if correct:
# TODO: probably better to actually set the internal state properly, but...
module.get_score = lambda: {'score': 1, 'total': 1}
+ else:
+ module.get_score = lambda: {'score': 0, 'total': 1}
return module
@@ -356,7 +351,7 @@ class CapaModuleTest(unittest.TestCase):
valid_get_dict = self._querydict_from_dict({'input_2[]': ['test1', 'test2']})
result = CapaModule.make_dict_of_responses(valid_get_dict)
self.assertTrue('2' in result)
- self.assertEqual(['test1','test2'], result['2'])
+ self.assertEqual(['test1', 'test2'], result['2'])
# If we use [] at the end of a key name, we should always
# get a list, even if there's just one value
@@ -374,7 +369,7 @@ class CapaModuleTest(unittest.TestCase):
# One of the values would overwrite the other, so detect this
# and raise an exception
invalid_get_dict = self._querydict_from_dict({'input_1[]': 'test 1',
- 'input_1': 'test 2' })
+ 'input_1': 'test 2'})
with self.assertRaises(ValueError):
result = CapaModule.make_dict_of_responses(invalid_get_dict)
@@ -412,7 +407,7 @@ class CapaModuleTest(unittest.TestCase):
mock_html.return_value = "Test HTML"
# Check the problem
- get_request_dict = { CapaFactory.input_key(): '3.14' }
+ get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
# Expect that the problem is marked correct
@@ -424,7 +419,6 @@ class CapaModuleTest(unittest.TestCase):
# Expect that the number of attempts is incremented by 1
self.assertEqual(module.attempts, 2)
-
def test_check_problem_incorrect(self):
module = CapaFactory.create(attempts=0)
@@ -434,7 +428,7 @@ class CapaModuleTest(unittest.TestCase):
mock_is_correct.return_value = False
# Check the problem
- get_request_dict = { CapaFactory.input_key(): '0' }
+ get_request_dict = { CapaFactory.input_key(): '0'}
result = module.check_problem(get_request_dict)
# Expect that the problem is marked correct
@@ -452,38 +446,33 @@ class CapaModuleTest(unittest.TestCase):
with patch('xmodule.capa_module.CapaModule.closed') as mock_closed:
mock_closed.return_value = True
with self.assertRaises(xmodule.exceptions.NotFoundError):
- get_request_dict = { CapaFactory.input_key(): '3.14' }
+ get_request_dict = { CapaFactory.input_key(): '3.14'}
module.check_problem(get_request_dict)
# Expect that number of attempts NOT incremented
self.assertEqual(module.attempts, 3)
-
def test_check_problem_resubmitted_with_randomize(self):
# Randomize turned on
module = CapaFactory.create(rerandomize='always', attempts=0)
# Simulate that the problem is completed
- module.lcp.done = True
+ module.done = True
# Expect that we cannot submit
with self.assertRaises(xmodule.exceptions.NotFoundError):
- get_request_dict = { CapaFactory.input_key(): '3.14' }
+ get_request_dict = {CapaFactory.input_key(): '3.14'}
module.check_problem(get_request_dict)
# Expect that number of attempts NOT incremented
self.assertEqual(module.attempts, 0)
-
def test_check_problem_resubmitted_no_randomize(self):
# Randomize turned off
- module = CapaFactory.create(rerandomize='never', attempts=0)
-
- # Simulate that the problem is completed
- module.lcp.done = True
+ module = CapaFactory.create(rerandomize='never', attempts=0, done=True)
# Expect that we can submit successfully
- get_request_dict = { CapaFactory.input_key(): '3.14' }
+ get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
self.assertEqual(result['success'], 'correct')
@@ -491,7 +480,6 @@ class CapaModuleTest(unittest.TestCase):
# Expect that number of attempts IS incremented
self.assertEqual(module.attempts, 1)
-
def test_check_problem_queued(self):
module = CapaFactory.create(attempts=1)
@@ -504,7 +492,7 @@ class CapaModuleTest(unittest.TestCase):
mock_is_queued.return_value = True
mock_get_queuetime.return_value = datetime.datetime.now()
- get_request_dict = { CapaFactory.input_key(): '3.14' }
+ get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
# Expect an AJAX alert message in 'success'
@@ -521,7 +509,7 @@ class CapaModuleTest(unittest.TestCase):
with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade:
mock_grade.side_effect = capa.responsetypes.StudentInputError('test error')
- get_request_dict = { CapaFactory.input_key(): '3.14' }
+ get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
# Expect an AJAX alert message in 'success'
@@ -532,13 +520,8 @@ class CapaModuleTest(unittest.TestCase):
def test_reset_problem(self):
- module = CapaFactory.create()
-
- # Mock the module's capa problem
- # to simulate that the problem is done
- mock_problem = MagicMock(capa.capa_problem.LoncapaProblem)
- mock_problem.done = True
- module.lcp = mock_problem
+ module = CapaFactory.create(done=True)
+ module.new_lcp = Mock(wraps=module.new_lcp)
# Stub out HTML rendering
with patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html:
@@ -556,7 +539,7 @@ class CapaModuleTest(unittest.TestCase):
self.assertEqual(result['html'], "
Test HTML
")
# Expect that the problem was reset
- mock_problem.do_reset.assert_called_once_with()
+ module.new_lcp.assert_called_once_with({'seed': None})
def test_reset_problem_closed(self):
@@ -575,10 +558,8 @@ class CapaModuleTest(unittest.TestCase):
def test_reset_problem_not_done(self):
- module = CapaFactory.create()
-
# Simulate that the problem is NOT done
- module.lcp.done = False
+ module = CapaFactory.create(done=False)
# Try to reset the problem
get_request_dict = {}
@@ -589,17 +570,14 @@ class CapaModuleTest(unittest.TestCase):
def test_save_problem(self):
- module = CapaFactory.create()
-
- # Simulate that the problem is not done (not attempted or reset)
- module.lcp.done = False
+ module = CapaFactory.create(done=False)
# Save the problem
- get_request_dict = { CapaFactory.input_key(): '3.14' }
+ get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Expect that answers are saved to the problem
- expected_answers = { CapaFactory.answer_key(): '3.14' }
+ expected_answers = { CapaFactory.answer_key(): '3.14'}
self.assertEqual(module.lcp.student_answers, expected_answers)
# Expect that the result is success
@@ -607,17 +585,14 @@ class CapaModuleTest(unittest.TestCase):
def test_save_problem_closed(self):
- module = CapaFactory.create()
-
- # Simulate that the problem is NOT done (not attempted or reset)
- module.lcp.done = False
+ module = CapaFactory.create(done=False)
# Simulate that the problem is closed
with patch('xmodule.capa_module.CapaModule.closed') as mock_closed:
mock_closed.return_value = True
# Try to save the problem
- get_request_dict = { CapaFactory.input_key(): '3.14' }
+ get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Expect that the result is failure
@@ -625,13 +600,10 @@ class CapaModuleTest(unittest.TestCase):
def test_save_problem_submitted_with_randomize(self):
- module = CapaFactory.create(rerandomize='always')
-
- # Simulate that the problem is completed
- module.lcp.done = True
+ module = CapaFactory.create(rerandomize='always', done=True)
# Try to save
- get_request_dict = { CapaFactory.input_key(): '3.14' }
+ get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Expect that we cannot save
@@ -639,13 +611,10 @@ class CapaModuleTest(unittest.TestCase):
def test_save_problem_submitted_no_randomize(self):
- module = CapaFactory.create(rerandomize='never')
-
- # Simulate that the problem is completed
- module.lcp.done = True
+ module = CapaFactory.create(rerandomize='never', done=True)
# Try to save
- get_request_dict = { CapaFactory.input_key(): '3.14' }
+ get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Expect that we succeed
@@ -657,7 +626,7 @@ class CapaModuleTest(unittest.TestCase):
# Just in case, we also check what happens if we have
# more attempts than allowed.
attempts = random.randint(1, 10)
- module = CapaFactory.create(attempts=attempts-1, max_attempts=attempts)
+ module = CapaFactory.create(attempts=attempts -1, max_attempts=attempts)
self.assertEqual(module.check_button_name(), "Final Check")
module = CapaFactory.create(attempts=attempts, max_attempts=attempts)
@@ -667,14 +636,14 @@ class CapaModuleTest(unittest.TestCase):
self.assertEqual(module.check_button_name(), "Final Check")
# Otherwise, button name is "Check"
- module = CapaFactory.create(attempts=attempts-2, max_attempts=attempts)
+ module = CapaFactory.create(attempts=attempts -2, max_attempts=attempts)
self.assertEqual(module.check_button_name(), "Check")
- module = CapaFactory.create(attempts=attempts-3, max_attempts=attempts)
+ module = CapaFactory.create(attempts=attempts -3, max_attempts=attempts)
self.assertEqual(module.check_button_name(), "Check")
# If no limit on attempts, then always show "Check"
- module = CapaFactory.create(attempts=attempts-3)
+ module = CapaFactory.create(attempts=attempts -3)
self.assertEqual(module.check_button_name(), "Check")
module = CapaFactory.create(attempts=0)
@@ -682,7 +651,7 @@ class CapaModuleTest(unittest.TestCase):
def test_should_show_check_button(self):
- attempts = random.randint(1,10)
+ attempts = random.randint(1, 10)
# If we're after the deadline, do NOT show check button
module = CapaFactory.create(due=self.yesterday_str)
@@ -699,8 +668,7 @@ class CapaModuleTest(unittest.TestCase):
# If user submitted a problem but hasn't reset,
# do NOT show the check button
# Note: we can only reset when rerandomize="always"
- module = CapaFactory.create(rerandomize="always")
- module.lcp.done = True
+ module = CapaFactory.create(rerandomize="always", done=True)
self.assertFalse(module.should_show_check_button())
# Otherwise, DO show the check button
@@ -711,105 +679,101 @@ class CapaModuleTest(unittest.TestCase):
# and we do NOT have a reset button, then we can show the check button
# Setting rerandomize to "never" ensures that the reset button
# is not shown
- module = CapaFactory.create(rerandomize="never")
- module.lcp.done = True
+ module = CapaFactory.create(rerandomize="never", done=True)
self.assertTrue(module.should_show_check_button())
def test_should_show_reset_button(self):
- attempts = random.randint(1,10)
+ attempts = random.randint(1, 10)
# If we're after the deadline, do NOT show the reset button
- module = CapaFactory.create(due=self.yesterday_str)
- module.lcp.done = True
+ module = CapaFactory.create(due=self.yesterday_str, done=True)
self.assertFalse(module.should_show_reset_button())
# If the user is out of attempts, do NOT show the reset button
- module = CapaFactory.create(attempts=attempts, max_attempts=attempts)
- module.lcp.done = True
+ module = CapaFactory.create(attempts=attempts, max_attempts=attempts, done=True)
self.assertFalse(module.should_show_reset_button())
# If we're NOT randomizing, then do NOT show the reset button
- module = CapaFactory.create(rerandomize="never")
- module.lcp.done = True
+ module = CapaFactory.create(rerandomize="never", done=True)
self.assertFalse(module.should_show_reset_button())
# If the user hasn't submitted an answer yet,
# then do NOT show the reset button
- module = CapaFactory.create()
- module.lcp.done = False
+ module = CapaFactory.create(done=False)
self.assertFalse(module.should_show_reset_button())
# Otherwise, DO show the reset button
- module = CapaFactory.create()
- module.lcp.done = True
+ module = CapaFactory.create(done=True)
self.assertTrue(module.should_show_reset_button())
# If survey question for capa (max_attempts = 0),
# DO show the reset button
- module = CapaFactory.create(max_attempts=0)
- module.lcp.done = True
+ module = CapaFactory.create(max_attempts=0, done=True)
self.assertTrue(module.should_show_reset_button())
def test_should_show_save_button(self):
- attempts = random.randint(1,10)
+ attempts = random.randint(1, 10)
# If we're after the deadline, do NOT show the save button
- module = CapaFactory.create(due=self.yesterday_str)
- module.lcp.done = True
+ module = CapaFactory.create(due=self.yesterday_str, done=True)
self.assertFalse(module.should_show_save_button())
# If the user is out of attempts, do NOT show the save button
- module = CapaFactory.create(attempts=attempts, max_attempts=attempts)
- module.lcp.done = True
+ module = CapaFactory.create(attempts=attempts, max_attempts=attempts, done=True)
self.assertFalse(module.should_show_save_button())
# If user submitted a problem but hasn't reset, do NOT show the save button
- module = CapaFactory.create(rerandomize="always")
- module.lcp.done = True
+ module = CapaFactory.create(rerandomize="always", done=True)
+ self.assertFalse(module.should_show_save_button())
+
+ # If the user has unlimited attempts and we are not randomizing,
+ # then do NOT show a save button
+ # because they can keep using "Check"
+ module = CapaFactory.create(max_attempts=None, rerandomize="never", done=False)
+ self.assertFalse(module.should_show_save_button())
+
+ module = CapaFactory.create(max_attempts=None, rerandomize="never", done=True)
self.assertFalse(module.should_show_save_button())
# Otherwise, DO show the save button
- module = CapaFactory.create()
- module.lcp.done = False
+ module = CapaFactory.create(done=False)
self.assertTrue(module.should_show_save_button())
- # If we're not randomizing, then we can re-save
- module = CapaFactory.create(rerandomize="never")
- module.lcp.done = True
+ # If we're not randomizing and we have limited attempts, then we can save
+ module = CapaFactory.create(rerandomize="never", max_attempts=2, done=True)
self.assertTrue(module.should_show_save_button())
# If survey question for capa (max_attempts = 0),
# DO show the save button
- module = CapaFactory.create(max_attempts=0)
- module.lcp.done = False
+ module = CapaFactory.create(max_attempts=0, done=False)
self.assertTrue(module.should_show_save_button())
def test_should_show_save_button_force_save_button(self):
# If we're after the deadline, do NOT show the save button
# even though we're forcing a save
module = CapaFactory.create(due=self.yesterday_str,
- force_save_button="true")
- module.lcp.done = True
+ force_save_button="true",
+ done=True)
self.assertFalse(module.should_show_save_button())
# If the user is out of attempts, do NOT show the save button
- attempts = random.randint(1,10)
+ attempts = random.randint(1, 10)
module = CapaFactory.create(attempts=attempts,
max_attempts=attempts,
- force_save_button="true")
- module.lcp.done = True
+ force_save_button="true",
+ done=True)
self.assertFalse(module.should_show_save_button())
# Otherwise, if we force the save button,
# then show it even if we would ordinarily
# require a reset first
module = CapaFactory.create(force_save_button="true",
- rerandomize="always")
- module.lcp.done = True
+ rerandomize="always",
+ done=True)
self.assertTrue(module.should_show_save_button())
def test_no_max_attempts(self):
@@ -823,9 +787,9 @@ class CapaModuleTest(unittest.TestCase):
# We've tested the show/hide button logic in other tests,
# so here we hard-wire the values
- show_check_button = bool(random.randint(0,1) % 2)
- show_reset_button = bool(random.randint(0,1) % 2)
- show_save_button = bool(random.randint(0,1) % 2)
+ show_check_button = bool(random.randint(0, 1) % 2)
+ show_reset_button = bool(random.randint(0, 1) % 2)
+ show_save_button = bool(random.randint(0, 1) % 2)
module.should_show_check_button = Mock(return_value=show_check_button)
module.should_show_reset_button = Mock(return_value=show_reset_button)
@@ -848,7 +812,7 @@ class CapaModuleTest(unittest.TestCase):
self.assertEqual(html, "
Test Template HTML
")
# Check the rendering context
- render_args,_ = module.system.render_template.call_args
+ render_args, _ = module.system.render_template.call_args
self.assertEqual(len(render_args), 2)
template_name = render_args[0]
@@ -889,7 +853,7 @@ class CapaModuleTest(unittest.TestCase):
html = module.get_problem_html()
# Check the rendering context
- render_args,_ = module.system.render_template.call_args
+ render_args, _ = module.system.render_template.call_args
context = render_args[1]
self.assertTrue("error" in context['problem']['html'])
diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
index 8a14e03ded..09c86baf27 100644
--- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
+++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
@@ -57,7 +57,8 @@ class OpenEndedChildTest(unittest.TestCase):
def setUp(self):
self.test_system = test_system()
self.openendedchild = OpenEndedChild(self.test_system, self.location,
- self.definition, self.descriptor, self.static_data, self.metadata)
+ self.definition, self.descriptor, self.static_data, self.metadata)
+
def test_latest_answer_empty(self):
answer = self.openendedchild.latest_answer()
@@ -123,7 +124,7 @@ class OpenEndedChildTest(unittest.TestCase):
def test_reset(self):
self.openendedchild.reset(self.test_system)
state = json.loads(self.openendedchild.get_instance_state())
- self.assertEqual(state['state'], OpenEndedChild.INITIAL)
+ self.assertEqual(state['child_state'], OpenEndedChild.INITIAL)
def test_is_last_response_correct(self):
new_answer = "New Answer"
@@ -209,7 +210,7 @@ class OpenEndedModuleTest(unittest.TestCase):
self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY)
state = json.loads(self.openendedmodule.get_instance_state())
- self.assertIsNotNone(state['state'], OpenEndedModule.DONE)
+ self.assertIsNotNone(state['child_state'], OpenEndedModule.DONE)
def test_send_to_grader(self):
submission = "This is a student submission"
@@ -335,12 +336,15 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
def setUp(self):
self.test_system = test_system()
+ # TODO: this constructor call is definitely wrong, but neither branch
+ # of the merge matches the module constructor. Someone (Vik?) should fix this.
self.combinedoe = CombinedOpenEndedV1Module(self.test_system,
self.location,
self.definition,
self.descriptor,
static_data=self.static_data,
- metadata=self.metadata)
+ metadata=self.metadata,
+ instance_state={})
def test_get_tag_name(self):
name = self.combinedoe.get_tag_name("Tag")
diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py
index 16bd222b9e..1b2da0b74a 100644
--- a/common/lib/xmodule/xmodule/tests/test_conditional.py
+++ b/common/lib/xmodule/xmodule/tests/test_conditional.py
@@ -73,24 +73,21 @@ class ConditionalModuleTest(unittest.TestCase):
"""Make sure that conditional module works"""
print "Starting import"
- course = self.get_course('conditional')
+ course = self.get_course('conditional_and_poll')
print "Course: ", course
print "id: ", course.id
- instance_states = dict(problem=None)
- shared_state = None
-
def inner_get_module(descriptor):
if isinstance(descriptor, Location):
location = descriptor
descriptor = self.modulestore.get_instance(course.id, location, depth=None)
location = descriptor.location
- instance_state = instance_states.get(location.category, None)
- print "inner_get_module, location=%s, inst_state=%s" % (location, instance_state)
- return descriptor.xmodule_constructor(self.test_system)(instance_state, shared_state)
+ return descriptor.xmodule(self.test_system)
- location = Location(["i4x", "edX", "cond_test", "conditional", "condone"])
+ # edx - HarvardX
+ # cond_test - ER22x
+ location = Location(["i4x", "HarvardX", "ER22x", "conditional", "condone"])
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None):
return text
@@ -99,26 +96,28 @@ class ConditionalModuleTest(unittest.TestCase):
module = inner_get_module(location)
print "module: ", module
- print "module definition: ", module.definition
+ print "module.conditions_map: ", module.conditions_map
print "module children: ", module.get_children()
print "module display items (children): ", module.get_display_items()
html = module.get_html()
print "html type: ", type(html)
print "html: ", html
- html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}"
+ html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-HarvardX-ER22x-conditional-condone', 'id': 'i4x://HarvardX/ER22x/conditional/condone', 'depends': 'i4x-HarvardX-ER22x-problem-choiceprob'}"
self.assertEqual(html, html_expect)
gdi = module.get_display_items()
print "gdi=", gdi
ajax = json.loads(module.handle_ajax('', ''))
- self.assertTrue('xmodule.conditional_module' in ajax['html'])
print "ajax: ", ajax
+ html = ajax['html']
+ self.assertFalse(any(['This is a secret' in item for item in html]))
# now change state of the capa problem to make it completed
- instance_states['problem'] = json.dumps({'attempts': 1})
+ inner_get_module(Location('i4x://HarvardX/ER22x/problem/choiceprob')).attempts = 1
ajax = json.loads(module.handle_ajax('', ''))
- self.assertTrue('This is a secret' in ajax['html'])
print "post-attempt ajax: ", ajax
+ html = ajax['html']
+ self.assertTrue(any(['This is a secret' in item for item in html]))
diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py
index 712b095696..59099b0dff 100644
--- a/common/lib/xmodule/xmodule/tests/test_course_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_course_module.py
@@ -39,7 +39,7 @@ class DummySystem(ImportSystem):
class IsNewCourseTestCase(unittest.TestCase):
"""Make sure the property is_new works on courses"""
@staticmethod
- def get_dummy_course(start, announcement=None, is_new=None):
+ def get_dummy_course(start, announcement=None, is_new=None, advertised_start=None):
"""Get a dummy course"""
system = DummySystem(load_error_modules=True)
@@ -49,71 +49,87 @@ class IsNewCourseTestCase(unittest.TestCase):
is_new = to_attrb('is_new', is_new)
announcement = to_attrb('announcement', announcement)
+ advertised_start = to_attrb('advertised_start', advertised_start)
start_xml = '''
+ {is_new}
+ {advertised_start}>
Two houses, ...
'''.format(org=ORG, course=COURSE, start=start, is_new=is_new,
- announcement=announcement)
+ announcement=announcement, advertised_start=advertised_start)
return system.process_xml(start_xml)
@patch('xmodule.course_module.time.gmtime')
def test_sorting_score(self, gmtime_mock):
gmtime_mock.return_value = NOW
- dates = [('2012-10-01T12:00', '2012-09-01T12:00'), # 0
- ('2012-12-01T12:00', '2012-11-01T12:00'), # 1
- ('2013-02-01T12:00', '2012-12-01T12:00'), # 2
- ('2013-02-01T12:00', '2012-11-10T12:00'), # 3
- ('2013-02-01T12:00', None), # 4
- ('2013-03-01T12:00', None), # 5
- ('2013-04-01T12:00', None), # 6
- ('2012-11-01T12:00', None), # 7
- ('2012-09-01T12:00', None), # 8
- ('1990-01-01T12:00', None), # 9
- ('2013-01-02T12:00', None), # 10
- ('2013-01-10T12:00', '2012-12-31T12:00'), # 11
- ('2013-01-10T12:00', '2013-01-01T12:00'), # 12
+
+ day1 = '2012-01-01T12:00'
+ day2 = '2012-01-02T12:00'
+
+ dates = [
+ # Announce date takes priority over actual start
+ # and courses announced on a later date are newer
+ # than courses announced for an earlier date
+ ((day1, day2, None), (day1, day1, None), self.assertLess),
+ ((day1, day1, None), (day2, day1, None), self.assertEqual),
+
+ # Announce dates take priority over advertised starts
+ ((day1, day2, day1), (day1, day1, day1), self.assertLess),
+ ((day1, day1, day2), (day2, day1, day2), self.assertEqual),
+
+ # Later start == newer course
+ ((day2, None, None), (day1, None, None), self.assertLess),
+ ((day1, None, None), (day1, None, None), self.assertEqual),
+
+ # Non-parseable advertised starts are ignored in preference
+ # to actual starts
+ ((day2, None, "Spring 2013"), (day1, None, "Fall 2012"), self.assertLess),
+ ((day1, None, "Spring 2013"), (day1, None, "Fall 2012"), self.assertEqual),
+
+ # Parseable advertised starts take priority over start dates
+ ((day1, None, day2), (day1, None, day1), self.assertLess),
+ ((day2, None, day2), (day1, None, day2), self.assertEqual),
+
]
data = []
- for i, d in enumerate(dates):
- descriptor = self.get_dummy_course(start=d[0], announcement=d[1])
- score = descriptor.sorting_score
- data.append((score, i))
+ for a, b, assertion in dates:
+ a_score = self.get_dummy_course(start=a[0], announcement=a[1], advertised_start=a[2]).sorting_score
+ b_score = self.get_dummy_course(start=b[0], announcement=b[1], advertised_start=b[2]).sorting_score
+ print "Comparing %s to %s" % (a, b)
+ assertion(a_score, b_score)
- result = [d[1] for d in sorted(data)]
- assert(result == [12, 11, 2, 3, 1, 0, 6, 5, 4, 10, 7, 8, 9])
@patch('xmodule.course_module.time.gmtime')
- def test_is_new(self, gmtime_mock):
+ def test_is_newish(self, gmtime_mock):
gmtime_mock.return_value = NOW
descriptor = self.get_dummy_course(start='2012-12-02T12:00', is_new=True)
- assert(descriptor.is_new is True)
+ assert(descriptor.is_newish is True)
descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=False)
- assert(descriptor.is_new is False)
+ assert(descriptor.is_newish is False)
descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=True)
- assert(descriptor.is_new is True)
+ assert(descriptor.is_newish is True)
descriptor = self.get_dummy_course(start='2013-01-15T12:00')
- assert(descriptor.is_new is True)
+ assert(descriptor.is_newish is True)
descriptor = self.get_dummy_course(start='2013-03-00T12:00')
- assert(descriptor.is_new is True)
+ assert(descriptor.is_newish is True)
descriptor = self.get_dummy_course(start='2012-10-15T12:00')
- assert(descriptor.is_new is False)
+ assert(descriptor.is_newish is False)
descriptor = self.get_dummy_course(start='2012-12-31T12:00')
- assert(descriptor.is_new is True)
+ assert(descriptor.is_newish is True)
diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py
index e9fb89e9f6..443014f9ef 100644
--- a/common/lib/xmodule/xmodule/tests/test_export.py
+++ b/common/lib/xmodule/xmodule/tests/test_export.py
@@ -18,27 +18,16 @@ TEST_DIR = TEST_DIR / 'test'
DATA_DIR = TEST_DIR / 'data'
-def strip_metadata(descriptor, key):
- """
- Recursively strips tag from all children.
- """
- print "strip {key} from {desc}".format(key=key, desc=descriptor.location.url())
- descriptor.metadata.pop(key, None)
- for d in descriptor.get_children():
- strip_metadata(d, key)
-
-
def strip_filenames(descriptor):
"""
Recursively strips 'filename' from all children's definitions.
"""
print "strip filename from {desc}".format(desc=descriptor.location.url())
- descriptor.definition.pop('filename', None)
+ descriptor._model_data.pop('filename', None)
for d in descriptor.get_children():
strip_filenames(d)
-
class RoundTripTestCase(unittest.TestCase):
''' Check that our test courses roundtrip properly.
Same course imported , than exported, then imported again.
@@ -77,10 +66,6 @@ class RoundTripTestCase(unittest.TestCase):
exported_course = courses2[0]
print "Checking course equality"
- # HACK: data_dir metadata tags break equality because they
- # aren't real metadata, and depend on paths. Remove them.
- strip_metadata(initial_course, 'data_dir')
- strip_metadata(exported_course, 'data_dir')
# HACK: filenames change when changing file formats
# during imports from old-style courses. Ignore them.
@@ -105,7 +90,6 @@ class RoundTripTestCase(unittest.TestCase):
self.assertEquals(initial_import.modules[course_id][location],
second_import.modules[course_id][location])
-
def setUp(self):
self.maxDiff = None
self.temp_dir = mkdtemp()
@@ -120,6 +104,9 @@ class RoundTripTestCase(unittest.TestCase):
def test_full_roundtrip(self):
self.check_export_roundtrip(DATA_DIR, "full")
+ def test_conditional_and_poll_roundtrip(self):
+ self.check_export_roundtrip(DATA_DIR, "conditional_and_poll")
+
def test_selfassessment_roundtrip(self):
#Test selfassessment xmodule to see if it exports correctly
self.check_export_roundtrip(DATA_DIR, "self_assessment")
diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py
index 42072ffe4d..37b1d35938 100644
--- a/common/lib/xmodule/xmodule/tests/test_import.py
+++ b/common/lib/xmodule/xmodule/tests/test_import.py
@@ -1,3 +1,5 @@
+# -*- coding: utf-8 -*-
+
from path import path
import unittest
from fs.memoryfs import MemoryFS
@@ -12,6 +14,7 @@ from xmodule.errortracker import make_error_tracker
from xmodule.modulestore import Location
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
from xmodule.modulestore.exceptions import ItemNotFoundError
+from xmodule.modulestore.inheritance import compute_inherited_metadata
from .test_export import DATA_DIR
@@ -75,7 +78,6 @@ class ImportTestCase(BaseCourseTestCase):
self.assertEqual(descriptor.__class__.__name__,
'ErrorDescriptor')
-
def test_unique_url_names(self):
'''Check that each error gets its very own url_name'''
bad_xml = ''''''
@@ -87,7 +89,6 @@ class ImportTestCase(BaseCourseTestCase):
self.assertNotEqual(descriptor1.location, descriptor2.location)
-
def test_reimport(self):
'''Make sure an already-exported error xml tag loads properly'''
@@ -103,8 +104,10 @@ class ImportTestCase(BaseCourseTestCase):
self.assertEqual(re_import_descriptor.__class__.__name__,
'ErrorDescriptor')
- self.assertEqual(descriptor.definition['data'],
- re_import_descriptor.definition['data'])
+ self.assertEqual(descriptor.contents,
+ re_import_descriptor.contents)
+ self.assertEqual(descriptor.error_msg,
+ re_import_descriptor.error_msg)
def test_fixed_xml_tag(self):
"""Make sure a tag that's been fixed exports as the original tag type"""
@@ -138,23 +141,20 @@ class ImportTestCase(BaseCourseTestCase):
url_name = 'test1'
start_xml = '''
+ due="{due}" url_name="{url_name}" unicorn="purple">
Two houses, ...
- '''.format(grace=v, org=ORG, course=COURSE, url_name=url_name)
+ '''.format(due=v, org=ORG, course=COURSE, url_name=url_name)
descriptor = system.process_xml(start_xml)
+ compute_inherited_metadata(descriptor)
- print descriptor, descriptor.metadata
- self.assertEqual(descriptor.metadata['graceperiod'], v)
- self.assertEqual(descriptor.metadata['unicorn'], 'purple')
+ print descriptor, descriptor._model_data
+ self.assertEqual(descriptor.lms.due, v)
- # Check that the child inherits graceperiod correctly
+ # Check that the child inherits due correctly
child = descriptor.get_children()[0]
- self.assertEqual(child.metadata['graceperiod'], v)
-
- # check that the child does _not_ inherit any unicorns
- self.assertTrue('unicorn' not in child.metadata)
+ self.assertEqual(child.lms.due, v)
# Now export and check things
resource_fs = MemoryFS()
@@ -181,12 +181,12 @@ class ImportTestCase(BaseCourseTestCase):
# did we successfully strip the url_name from the definition contents?
self.assertTrue('url_name' not in course_xml.attrib)
- # Does the chapter tag now have a graceperiod attribute?
+ # Does the chapter tag now have a due attribute?
# hardcoded path to child
with resource_fs.open('chapter/ch.xml') as f:
chapter_xml = etree.fromstring(f.read())
self.assertEqual(chapter_xml.tag, 'chapter')
- self.assertFalse('graceperiod' in chapter_xml.attrib)
+ self.assertFalse('due' in chapter_xml.attrib)
def test_is_pointer_tag(self):
"""
@@ -224,13 +224,12 @@ class ImportTestCase(BaseCourseTestCase):
def check_for_key(key, node):
"recursive check for presence of key"
print "Checking {0}".format(node.location.url())
- self.assertTrue(key in node.metadata)
+ self.assertTrue(key in node._model_data)
for c in node.get_children():
check_for_key(key, c)
check_for_key('graceperiod', course)
-
def test_policy_loading(self):
"""Make sure that when two courses share content with the same
org and course names, policy applies to the right one."""
@@ -252,8 +251,7 @@ class ImportTestCase(BaseCourseTestCase):
# Also check that keys from policy are run through the
# appropriate attribute maps -- 'graded' should be True, not 'true'
- self.assertEqual(toy.metadata['graded'], True)
-
+ self.assertEqual(toy.lms.graded, True)
def test_definition_loading(self):
"""When two courses share the same org and course name and
@@ -271,9 +269,8 @@ class ImportTestCase(BaseCourseTestCase):
location = Location(["i4x", "edX", "toy", "video", "Welcome"])
toy_video = modulestore.get_instance(toy_id, location)
two_toy_video = modulestore.get_instance(two_toy_id, location)
- self.assertEqual(toy_video.metadata['youtube'], "1.0:p2Q6BrNhdh8")
- self.assertEqual(two_toy_video.metadata['youtube'], "1.0:p2Q6BrNhdh9")
-
+ self.assertEqual(etree.fromstring(toy_video.data).get('youtube'), "1.0:p2Q6BrNhdh8")
+ self.assertEqual(etree.fromstring(two_toy_video.data).get('youtube'), "1.0:p2Q6BrNhdh9")
def test_colon_in_url_name(self):
"""Ensure that colons in url_names convert to file paths properly"""
@@ -331,6 +328,22 @@ class ImportTestCase(BaseCourseTestCase):
self.assertEqual(len(video.url_name), len('video_') + 12)
+ def test_poll_and_conditional_xmodule(self):
+ modulestore = XMLModuleStore(DATA_DIR, course_dirs=['conditional_and_poll'])
+
+ course = modulestore.get_courses()[0]
+ chapters = course.get_children()
+ ch1 = chapters[0]
+ sections = ch1.get_children()
+
+ self.assertEqual(len(sections), 1)
+
+ location = course.location
+ location = Location(location.tag, location.org, location.course,
+ 'sequential', 'Problem_Demos')
+ module = modulestore.get_instance(course.id, location)
+ self.assertEqual(len(module.children), 2)
+
def test_error_on_import(self):
'''Check that when load_error_module is false, an exception is raised, rather than returning an ErrorModule'''
@@ -354,7 +367,7 @@ class ImportTestCase(BaseCourseTestCase):
render_string_from_sample_gst_xml = """
\
""".strip()
- self.assertEqual(gst_sample.definition['render'], render_string_from_sample_gst_xml)
+ self.assertEqual(gst_sample.render, render_string_from_sample_gst_xml)
def test_cohort_config(self):
"""
@@ -370,13 +383,13 @@ class ImportTestCase(BaseCourseTestCase):
self.assertFalse(course.is_cohorted)
# empty config -> False
- course.metadata['cohort_config'] = {}
+ course.cohort_config = {}
self.assertFalse(course.is_cohorted)
# false config -> False
- course.metadata['cohort_config'] = {'cohorted': False}
+ course.cohort_config = {'cohorted': False}
self.assertFalse(course.is_cohorted)
# and finally...
- course.metadata['cohort_config'] = {'cohorted': True}
+ course.cohort_config = {'cohorted': True}
self.assertTrue(course.is_cohorted)
diff --git a/common/lib/xmodule/xmodule/tests/test_logic.py b/common/lib/xmodule/xmodule/tests/test_logic.py
new file mode 100644
index 0000000000..018b40427e
--- /dev/null
+++ b/common/lib/xmodule/xmodule/tests/test_logic.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+
+import json
+import unittest
+
+from xmodule.poll_module import PollDescriptor
+from xmodule.conditional_module import ConditionalDescriptor
+
+
+class LogicTest(unittest.TestCase):
+ """Base class for testing xmodule logic."""
+ descriptor_class = None
+ raw_model_data = {}
+
+ def setUp(self):
+ class EmptyClass: pass
+
+ self.system = None
+ self.location = None
+ self.descriptor = EmptyClass()
+
+ self.xmodule_class = self.descriptor_class.module_class
+ self.xmodule = self.xmodule_class(self.system, self.location,
+ self.descriptor, self.raw_model_data)
+
+ def ajax_request(self, dispatch, get):
+ return json.loads(self.xmodule.handle_ajax(dispatch, get))
+
+
+class PollModuleTest(LogicTest):
+ descriptor_class = PollDescriptor
+ raw_model_data = {
+ 'poll_answers': {'Yes': 1, 'Dont_know': 0, 'No': 0},
+ 'voted': False,
+ 'poll_answer': ''
+ }
+
+ def test_bad_ajax_request(self):
+ response = self.ajax_request('bad_answer', {})
+ self.assertDictEqual(response, {'error': 'Unknown Command!'})
+
+ def test_good_ajax_request(self):
+ response = self.ajax_request('No', {})
+
+ poll_answers = response['poll_answers']
+ total = response['total']
+ callback = response['callback']
+
+ self.assertDictEqual(poll_answers, {'Yes': 1, 'Dont_know': 0, 'No': 1})
+ self.assertEqual(total, 2)
+ self.assertDictEqual(callback, {'objectName': 'Conditional'})
+ self.assertEqual(self.xmodule.poll_answer, 'No')
+
+
+class ConditionalModuleTest(LogicTest):
+ descriptor_class = ConditionalDescriptor
+
+ def test_ajax_request(self):
+ # Mock is_condition_satisfied
+ self.xmodule.is_condition_satisfied = lambda: True
+ setattr(self.xmodule.descriptor, 'get_children', lambda: [])
+
+ response = self.ajax_request('No', {})
+ html = response['html']
+
+ self.assertEqual(html, [])
diff --git a/common/lib/xmodule/xmodule/tests/test_randomize_module.py b/common/lib/xmodule/xmodule/tests/test_randomize_module.py
index 456fd379a5..59cf5a59f3 100644
--- a/common/lib/xmodule/xmodule/tests/test_randomize_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_randomize_module.py
@@ -13,7 +13,7 @@ COURSE = 'test_course'
START = '2013-01-01T01:00:00'
-from test_course_module import DummySystem as DummyImportSystem
+from .test_course_module import DummySystem as DummyImportSystem
from . import test_system
diff --git a/common/lib/xmodule/xmodule/tests/test_self_assessment.py b/common/lib/xmodule/xmodule/tests/test_self_assessment.py
index a7f2a9fdfe..593b3fea01 100644
--- a/common/lib/xmodule/xmodule/tests/test_self_assessment.py
+++ b/common/lib/xmodule/xmodule/tests/test_self_assessment.py
@@ -29,8 +29,6 @@ class SelfAssessmentTest(unittest.TestCase):
location = Location(["i4x", "edX", "sa_test", "selfassessment",
"SampleQuestion"])
- metadata = {'attempts': '10'}
-
descriptor = Mock()
def setUp(self):
@@ -54,9 +52,9 @@ class SelfAssessmentTest(unittest.TestCase):
}
self.module = SelfAssessmentModule(test_system(), self.location,
- self.definition, self.descriptor,
- static_data,
- state, metadata=self.metadata)
+ self.definition,
+ self.descriptor,
+ static_data)
def test_get_html(self):
html = self.module.get_html(self.module.system)
@@ -85,18 +83,18 @@ class SelfAssessmentTest(unittest.TestCase):
self.module.save_answer({'student_answer': "I am an answer"},
self.module.system)
- self.assertEqual(self.module.state, self.module.ASSESSING)
+ self.assertEqual(self.module.child_state, self.module.ASSESSING)
self.module.save_assessment(mock_query_dict, self.module.system)
- self.assertEqual(self.module.state, self.module.DONE)
+ self.assertEqual(self.module.child_state, self.module.DONE)
d = self.module.reset({})
self.assertTrue(d['success'])
- self.assertEqual(self.module.state, self.module.INITIAL)
+ self.assertEqual(self.module.child_state, self.module.INITIAL)
# if we now assess as right, skip the REQUEST_HINT state
self.module.save_answer({'student_answer': 'answer 4'},
self.module.system)
responses['assessment'] = '1'
self.module.save_assessment(mock_query_dict, self.module.system)
- self.assertEqual(self.module.state, self.module.DONE)
+ self.assertEqual(self.module.child_state, self.module.DONE)
diff --git a/common/lib/xmodule/xmodule/timeinfo.py b/common/lib/xmodule/xmodule/timeinfo.py
index 6c6a72e700..615a7b2c73 100644
--- a/common/lib/xmodule/xmodule/timeinfo.py
+++ b/common/lib/xmodule/xmodule/timeinfo.py
@@ -1,7 +1,7 @@
import dateutil
import dateutil.parser
import datetime
-from timeparse import parse_timedelta
+from .timeparse import parse_timedelta
import logging
log = logging.getLogger(__name__)
diff --git a/common/lib/xmodule/xmodule/timelimit_module.py b/common/lib/xmodule/xmodule/timelimit_module.py
index 9abb5d183f..efa47a5dca 100644
--- a/common/lib/xmodule/xmodule/timelimit_module.py
+++ b/common/lib/xmodule/xmodule/timelimit_module.py
@@ -9,35 +9,31 @@ from xmodule.xml_module import XmlDescriptor
from xmodule.x_module import XModule
from xmodule.progress import Progress
from xmodule.exceptions import NotFoundError
+from xblock.core import Float, String, Boolean, Scope
log = logging.getLogger(__name__)
-class TimeLimitModule(XModule):
- '''
+
+class TimeLimitFields(object):
+ beginning_at = Float(help="The time this timer was started", scope=Scope.student_state)
+ ending_at = Float(help="The time this timer will end", scope=Scope.student_state)
+ accomodation_code = String(help="A code indicating accommodations to be given the student", scope=Scope.student_state)
+ time_expired_redirect_url = String(help="Url to redirect users to after the timelimit has expired", scope=Scope.settings)
+ duration = Float(help="The length of this timer", scope=Scope.settings)
+ suppress_toplevel_navigation = Boolean(help="Whether the toplevel navigation should be suppressed when viewing this module", scope=Scope.settings)
+
+
+class TimeLimitModule(TimeLimitFields, XModule):
+ '''
Wrapper module which imposes a time constraint for the completion of its child.
'''
- def __init__(self, system, location, definition, descriptor, instance_state=None,
- shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
+ def __init__(self, *args, **kwargs):
+ XModule.__init__(self, *args, **kwargs)
self.rendered = False
- self.beginning_at = None
- self.ending_at = None
- self.accommodation_code = None
-
- if instance_state is not None:
- state = json.loads(instance_state)
- if 'beginning_at' in state:
- self.beginning_at = state['beginning_at']
- if 'ending_at' in state:
- self.ending_at = state['ending_at']
- if 'accommodation_code' in state:
- self.accommodation_code = state['accommodation_code']
-
# For a timed activity, we are only interested here
# in time-related accommodations, and these should be disjoint.
# (For proctored exams, it is possible to have multiple accommodations
@@ -50,7 +46,7 @@ class TimeLimitModule(XModule):
)
def _get_accommodated_duration(self, duration):
- '''
+ '''
Get duration for activity, as adjusted for accommodations.
Input and output are expressed in seconds.
'''
@@ -70,35 +66,25 @@ class TimeLimitModule(XModule):
@property
def has_begun(self):
return self.beginning_at is not None
-
- @property
+
+ @property
def has_ended(self):
if not self.ending_at:
return False
return self.ending_at < time()
-
+
def begin(self, duration):
- '''
+ '''
Sets the starting time and ending time for the activity,
based on the duration provided (in seconds).
'''
self.beginning_at = time()
modified_duration = self._get_accommodated_duration(duration)
self.ending_at = self.beginning_at + modified_duration
-
+
def get_remaining_time_in_ms(self):
return int((self.ending_at - time()) * 1000)
- def get_instance_state(self):
- state = {}
- if self.beginning_at:
- state['beginning_at'] = self.beginning_at
- if self.ending_at:
- state['ending_at'] = self.ending_at
- if self.accommodation_code:
- state['accommodation_code'] = self.accommodation_code
- return json.dumps(state)
-
def get_html(self):
self.render()
return self.content
@@ -133,12 +119,12 @@ class TimeLimitModule(XModule):
else:
return "other"
-class TimeLimitDescriptor(XMLEditingDescriptor, XmlDescriptor):
+class TimeLimitDescriptor(TimeLimitFields, XMLEditingDescriptor, XmlDescriptor):
module_class = TimeLimitModule
# For remembering when a student started, and when they should end
- stores_state = True
+ stores_state = True
@classmethod
def definition_from_xml(cls, xml_object, system):
@@ -151,7 +137,7 @@ class TimeLimitDescriptor(XMLEditingDescriptor, XmlDescriptor):
if system.error_tracker is not None:
system.error_tracker("ERROR: " + str(e))
continue
- return {'children': children}
+ return {}, children
def definition_to_xml(self, resource_fs):
xml_object = etree.Element('timelimit')
diff --git a/common/lib/xmodule/xmodule/vertical_module.py b/common/lib/xmodule/xmodule/vertical_module.py
index 5827ea96a9..610d180c11 100644
--- a/common/lib/xmodule/xmodule/vertical_module.py
+++ b/common/lib/xmodule/xmodule/vertical_module.py
@@ -8,11 +8,15 @@ from pkg_resources import resource_string
class_priority = ['video', 'problem']
-class VerticalModule(XModule):
+class VerticalFields(object):
+ has_children = True
+
+
+class VerticalModule(VerticalFields, XModule):
''' Layout module for laying out submodules vertically.'''
- def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs)
+ def __init__(self, *args, **kwargs):
+ XModule.__init__(self, *args, **kwargs)
self.contents = None
def get_html(self):
@@ -42,7 +46,7 @@ class VerticalModule(XModule):
return new_class
-class VerticalDescriptor(SequenceDescriptor):
+class VerticalDescriptor(VerticalFields, SequenceDescriptor):
module_class = VerticalModule
js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index 27388f7630..0203299b40 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -8,9 +8,8 @@ from django.http import Http404
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
-from xmodule.modulestore.xml import XMLModuleStore
-from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
+from xblock.core import Integer, Scope, String
import datetime
import time
@@ -18,7 +17,13 @@ import time
log = logging.getLogger(__name__)
-class VideoModule(XModule):
+class VideoFields(object):
+ data = String(help="XML data for the problem", scope=Scope.content)
+ position = Integer(help="Current position in the video", scope=Scope.student_state, default=0)
+ display_name = String(help="Display name for this module", scope=Scope.settings)
+
+
+class VideoModule(VideoFields, XModule):
video_time = 0
icon_class = 'video'
@@ -32,23 +37,16 @@ class VideoModule(XModule):
css = {'scss': [resource_string(__name__, 'css/video/display.scss')]}
js_module_name = "Video"
- def __init__(self, system, location, definition, descriptor,
- instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
- xmltree = etree.fromstring(self.definition['data'])
+ def __init__(self, *args, **kwargs):
+ XModule.__init__(self, *args, **kwargs)
+
+ xmltree = etree.fromstring(self.data)
self.youtube = xmltree.get('youtube')
- self.position = 0
self.show_captions = xmltree.get('show_captions', 'true')
self.source = self._get_source(xmltree)
self.track = self._get_track(xmltree)
self.start_time, self.end_time = self._get_timeframe(xmltree)
- if instance_state is not None:
- state = json.loads(instance_state)
- if 'position' in state:
- self.position = int(float(state['position']))
-
def _get_source(self, xmltree):
# find the first valid source
return self._get_first_external(xmltree, 'source')
@@ -120,13 +118,6 @@ class VideoModule(XModule):
return self.youtube
def get_html(self):
- if isinstance(modulestore(), XMLModuleStore):
- # VS[compat]
- # cdodge: filesystem static content support.
- caption_asset_path = "/static/{0}/subs/".format(self.metadata['data_dir'])
- else:
- caption_asset_path = StaticContent.get_base_url_path_for_course_assets(self.location) + '/subs_'
-
# We normally let JS parse this, but in the case that we need a hacked
# out
'
);
- targetEl.appendTo(state.baseImageEl.parent());
+
+ if (fromTargetField === true) {
+ targetEl.appendTo(draggableObj.iconEl);
+ } else {
+ targetEl.appendTo(state.baseImageEl.parent());
+ }
+
targetEl.mousedown(function (event) {
event.preventDefault();
});
@@ -68,8 +124,13 @@ define(['logme'], function (logme) {
}
targetObj = {
+ 'uniqueId': state.getUniqueId(),
+
'id': obj.id,
+ 'x': obj.x,
+ 'y': obj.y,
+
'w': obj.w,
'h': obj.h,
@@ -86,9 +147,21 @@ define(['logme'], function (logme) {
'updateNumTextEl': updateNumTextEl,
'removeDraggable': removeDraggable,
- 'addDraggable': addDraggable
+ 'addDraggable': addDraggable,
+
+ 'type': 'base',
+ 'draggableObj': null
};
+ if (fromTargetField === true) {
+ targetObj.offset = draggableObj.iconEl.position();
+ targetObj.offset.top += obj.y;
+ targetObj.offset.left += obj.x;
+
+ targetObj.type = 'on_drag';
+ targetObj.draggableObj = draggableObj;
+ }
+
if (state.config.onePerTarget === false) {
numTextEl.appendTo(state.baseImageEl.parent());
numTextEl.mousedown(function (event) {
@@ -99,7 +172,11 @@ define(['logme'], function (logme) {
});
}
- state.targets.push(targetObj);
+ targetObj.indexInStateArray = state.targets.push(targetObj) - 1;
+
+ if (fromTargetField === true) {
+ draggableObj.targetField.push(targetObj);
+ }
}
function removeDraggable(draggable) {
@@ -121,6 +198,10 @@ define(['logme'], function (logme) {
draggable.onTarget = null;
draggable.onTargetIndex = null;
+ if (this.type === 'on_drag') {
+ this.draggableObj.numDraggablesOnMe -= 1;
+ }
+
this.updateNumTextEl();
}
@@ -128,6 +209,10 @@ define(['logme'], function (logme) {
draggable.onTarget = this;
draggable.onTargetIndex = this.draggableList.push(draggable) - 1;
+ if (this.type === 'on_drag') {
+ this.draggableObj.numDraggablesOnMe += 1;
+ }
+
this.updateNumTextEl();
}
@@ -183,10 +268,5 @@ define(['logme'], function (logme) {
this.numTextEl.html(this.draggableList.length);
}
}
-});
-
-// End of wrapper for RequireJS. As you can see, we are passing
-// namespaced Require JS variables to an anonymous function. Within
-// it, you can use the standard requirejs(), require(), and define()
-// functions as if they were in the global namespace.
-}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
+}); // End-of: define(['logme'], function (logme) {
+}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) {
diff --git a/common/static/js/capa/drag_and_drop/update_input.js b/common/static/js/capa/drag_and_drop/update_input.js
index 04715a3ecf..804b0bed97 100644
--- a/common/static/js/capa/drag_and_drop/update_input.js
+++ b/common/static/js/capa/drag_and_drop/update_input.js
@@ -1,9 +1,4 @@
-// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
-// define() functions from Require JS available inside the anonymous function.
-//
-// See https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system
(function (requirejs, require, define) {
-
define(['logme'], function (logme) {
return {
'check': check,
@@ -37,7 +32,12 @@ define(['logme'], function (logme) {
(function (c2) {
while (c2 < state.targets[c1].draggableList.length) {
tempObj = {};
- tempObj[state.targets[c1].draggableList[c2].id] = state.targets[c1].id;
+
+ if (state.targets[c1].type === 'base') {
+ tempObj[state.targets[c1].draggableList[c2].id] = state.targets[c1].id;
+ } else {
+ addTargetRecursively(tempObj, state.targets[c1].draggableList[c2], state.targets[c1]);
+ }
draggables.push(tempObj);
tempObj = null;
@@ -50,7 +50,18 @@ define(['logme'], function (logme) {
}(0));
}
- $('#input_' + state.problemId).val(JSON.stringify({'draggables': draggables}));
+ $('#input_' + state.problemId).val(JSON.stringify(draggables));
+ }
+
+ function addTargetRecursively(tempObj, draggable, target) {
+ if (target.type === 'base') {
+ tempObj[draggable.id] = target.id;
+ } else {
+ tempObj[draggable.id] = {};
+ tempObj[draggable.id][target.id] = {};
+
+ addTargetRecursively(tempObj[draggable.id][target.id], target.draggableObj, target.draggableObj.onTarget);
+ }
}
// Check if input has an answer from server. If yes, then position
@@ -59,6 +70,7 @@ define(['logme'], function (logme) {
var inputElVal;
inputElVal = $('#input_' + state.problemId).val();
+
if (inputElVal.length === 0) {
return false;
}
@@ -68,95 +80,147 @@ define(['logme'], function (logme) {
return true;
}
- function getUseTargets(answer) {
- if ($.isArray(answer.draggables) === false) {
- logme('ERROR: answer.draggables is not an array.');
+ function processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i) {
+ var baseDraggableId, baseDraggable, baseTargetId, baseTarget,
+ layeredDraggableId, layeredDraggable, layeredTargetId, layeredTarget,
+ chain;
- return;
- } else if (answer.draggables.length === 0) {
- return;
- }
-
- if ($.isPlainObject(answer.draggables[0]) === false) {
- logme('ERROR: answer.draggables array does not contain objects.');
+ if (depth === 0) {
+ // We are at the lowest depth? The end.
return;
}
- for (c1 in answer.draggables[0]) {
- if (answer.draggables[0].hasOwnProperty(c1) === false) {
- continue;
- }
+ if (answerSortedByDepth.hasOwnProperty(depth) === false) {
+ // We have a depth that ts not valid, we decrease the depth by one.
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth - 1, 0);
- if (typeof answer.draggables[0][c1] === 'string') {
- // use_targets = true;
-
- return true;
- } else if (
- ($.isArray(answer.draggables[0][c1]) === true) &&
- (answer.draggables[0][c1].length === 2)
- ) {
- // use_targets = false;
-
- return false;
- } else {
- logme('ERROR: answer.draggables[0] is inconsidtent.');
-
- return;
- }
+ return;
}
- logme('ERROR: answer.draggables[0] is an empty object.');
+ if (answerSortedByDepth[depth].length <= i) {
+ // We ran out of answers at this depth, go to the next depth down.
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth - 1, 0);
+
+ return;
+ }
+
+ chain = answerSortedByDepth[depth][i];
+
+ baseDraggableId = Object.keys(chain)[0];
+
+ // This is a hack. For now we will work with depths 1 and 3.
+ if (depth === 1) {
+ baseTargetId = chain[baseDraggableId];
+
+ layeredTargetId = null;
+ layeredDraggableId = null;
+
+ // createBaseDraggableOnTarget(state, baseDraggableId, baseTargetId);
+ } else if (depth === 3) {
+ layeredDraggableId = baseDraggableId;
+
+ layeredTargetId = Object.keys(chain[layeredDraggableId])[0];
+
+ baseDraggableId = Object.keys(chain[layeredDraggableId][layeredTargetId])[0];
+
+ baseTargetId = chain[layeredDraggableId][layeredTargetId][baseDraggableId];
+ }
+
+ checkBaseDraggable();
return;
+
+ function checkBaseDraggable() {
+ if ((baseDraggable = getById(state, 'draggables', baseDraggableId, null, false, baseTargetId)) === null) {
+ createBaseDraggableOnTarget(state, baseDraggableId, baseTargetId, true, function () {
+ if ((baseDraggable = getById(state, 'draggables', baseDraggableId, null, false, baseTargetId)) === null) {
+ console.log('ERROR: Could not successfully create a base draggable on a base target.');
+ } else {
+ baseTarget = baseDraggable.onTarget;
+
+ if ((layeredTargetId === null) || (layeredDraggableId === null)) {
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1);
+ } else {
+ checklayeredDraggable();
+ }
+ }
+ });
+ } else {
+ baseTarget = baseDraggable.onTarget;
+
+ if ((layeredTargetId === null) || (layeredDraggableId === null)) {
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1);
+ } else {
+ checklayeredDraggable();
+ }
+ }
+ }
+
+ function checklayeredDraggable() {
+ if ((layeredDraggable = getById(state, 'draggables', layeredDraggableId, null, false, layeredTargetId, baseDraggableId, baseTargetId)) === null) {
+ layeredDraggable = getById(state, 'draggables', layeredDraggableId);
+ layeredTarget = null;
+ baseDraggable.targetField.every(function (target) {
+ if (target.id === layeredTargetId) {
+ layeredTarget = target;
+ }
+
+ return true;
+ });
+
+ if ((layeredDraggable !== null) && (layeredTarget !== null)) {
+ layeredDraggable.moveDraggableTo('target', layeredTarget, function () {
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1);
+ });
+ } else {
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1);
+ }
+ } else {
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1);
+ }
+ }
}
- function processAnswerTargets(state, answer) {
- var draggableId, draggable, targetId, target;
+ function createBaseDraggableOnTarget(state, draggableId, targetId, reportError, funcCallback) {
+ var draggable, target;
- (function (c1) {
- while (c1 < answer.draggables.length) {
- for (draggableId in answer.draggables[c1]) {
- if (answer.draggables[c1].hasOwnProperty(draggableId) === false) {
- continue;
- }
-
- if ((draggable = getById(state, 'draggables', draggableId)) === null) {
- logme(
- 'ERROR: In answer there exists a ' +
- 'draggable ID "' + draggableId + '". No ' +
- 'draggable with this ID could be found.'
- );
-
- continue;
- }
-
- targetId = answer.draggables[c1][draggableId];
- if ((target = getById(state, 'targets', targetId)) === null) {
- logme(
- 'ERROR: In answer there exists a target ' +
- 'ID "' + targetId + '". No target with this ' +
- 'ID could be found.'
- );
-
- continue;
- }
-
- draggable.moveDraggableTo('target', target);
- }
-
- c1 += 1;
+ if ((draggable = getById(state, 'draggables', draggableId)) === null) {
+ if (reportError !== false) {
+ logme(
+ 'ERROR: In answer there exists a ' +
+ 'draggable ID "' + draggableId + '". No ' +
+ 'draggable with this ID could be found.'
+ );
}
- }(0));
+
+ return false;
+ }
+
+ if ((target = getById(state, 'targets', targetId)) === null) {
+ if (reportError !== false) {
+ logme(
+ 'ERROR: In answer there exists a target ' +
+ 'ID "' + targetId + '". No target with this ' +
+ 'ID could be found.'
+ );
+ }
+
+ return false;
+ }
+
+ draggable.moveDraggableTo('target', target, funcCallback);
+
+ return true;
}
function processAnswerPositions(state, answer) {
var draggableId, draggable;
(function (c1) {
- while (c1 < answer.draggables.length) {
- for (draggableId in answer.draggables[c1]) {
- if (answer.draggables[c1].hasOwnProperty(draggableId) === false) {
+ while (c1 < answer.length) {
+ for (draggableId in answer[c1]) {
+ if (answer[c1].hasOwnProperty(draggableId) === false) {
continue;
}
@@ -171,8 +235,8 @@ define(['logme'], function (logme) {
}
draggable.moveDraggableTo('XY', {
- 'x': answer.draggables[c1][draggableId][0],
- 'y': answer.draggables[c1][draggableId][1]
+ 'x': answer[c1][draggableId][0],
+ 'y': answer[c1][draggableId][1]
});
}
@@ -182,33 +246,110 @@ define(['logme'], function (logme) {
}
function repositionDraggables(state, answer) {
- if (answer.draggables.length === 0) {
+ var answerSortedByDepth, minDepth, maxDepth;
+
+ answerSortedByDepth = {};
+ minDepth = 1000;
+ maxDepth = 0;
+
+ answer.every(function (chain) {
+ var depth;
+
+ depth = findDepth(chain, 0);
+
+ if (depth < minDepth) {
+ minDepth = depth;
+ }
+ if (depth > maxDepth) {
+ maxDepth = depth;
+ }
+
+ if (answerSortedByDepth.hasOwnProperty(depth) === false) {
+ answerSortedByDepth[depth] = [];
+ }
+
+ answerSortedByDepth[depth].push(chain);
+
+ return true;
+ });
+
+ if (answer.length === 0) {
return;
}
- if (state.config.individualTargets !== getUseTargets(answer)) {
- logme('ERROR: JSON config is not consistent with server response.');
-
+ // For now we support only one case.
+ if ((minDepth < 1) || (maxDepth > 3)) {
return;
}
if (state.config.individualTargets === true) {
- processAnswerTargets(state, answer);
+ processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, maxDepth, 0);
} else if (state.config.individualTargets === false) {
processAnswerPositions(state, answer);
}
}
- function getById(state, type, id) {
+ function findDepth(tempObj, depth) {
+ var i;
+
+ if ($.isPlainObject(tempObj) === false) {
+ return depth;
+ }
+
+ depth += 1;
+
+ for (i in tempObj) {
+ if (tempObj.hasOwnProperty(i) === true) {
+ depth = findDepth(tempObj[i], depth);
+ }
+ }
+
+ return depth;
+ }
+
+ function getById(state, type, id, fromTargetField, inContainer, targetId, baseDraggableId, baseTargetId) {
return (function (c1) {
while (c1 < state[type].length) {
if (type === 'draggables') {
- if ((state[type][c1].id === id) && (state[type][c1].isOriginal === true)) {
- return state[type][c1];
+ if ((targetId !== undefined) && (inContainer === false) && (baseDraggableId !== undefined) && (baseTargetId !== undefined)) {
+ if (
+ (state[type][c1].id === id) &&
+ (state[type][c1].inContainer === false) &&
+ (state[type][c1].onTarget.id === targetId) &&
+ (state[type][c1].onTarget.type === 'on_drag') &&
+ (state[type][c1].onTarget.draggableObj.id === baseDraggableId) &&
+ (state[type][c1].onTarget.draggableObj.onTarget.id === baseTargetId)
+ ) {
+ return state[type][c1];
+ }
+ } else if ((targetId !== undefined) && (inContainer === false)) {
+ if (
+ (state[type][c1].id === id) &&
+ (state[type][c1].inContainer === false) &&
+ (state[type][c1].onTarget.id === targetId)
+ ) {
+ return state[type][c1];
+ }
+ } else {
+ if (inContainer === false) {
+ if ((state[type][c1].id === id) && (state[type][c1].inContainer === false)) {
+ return state[type][c1];
+ }
+ } else {
+ if ((state[type][c1].id === id) && (state[type][c1].inContainer === true)) {
+ return state[type][c1];
+ }
+ }
}
} else { // 'targets'
- if (state[type][c1].id === id) {
- return state[type][c1];
+ if (fromTargetField === true) {
+ if ((state[type][c1].id === id) && (state[type][c1].type === 'on_drag')) {
+ return state[type][c1];
+ }
+ } else {
+ if ((state[type][c1].id === id) && (state[type][c1].type === 'base')) {
+ return state[type][c1];
+ }
}
}
@@ -218,10 +359,5 @@ define(['logme'], function (logme) {
return null;
}(0));
}
-});
-
-// End of wrapper for RequireJS. As you can see, we are passing
-// namespaced Require JS variables to an anonymous function. Within
-// it, you can use the standard requirejs(), require(), and define()
-// functions as if they were in the global namespace.
-}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
+}); // End-of: define(['logme'], function (logme) {
+}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) {
diff --git a/common/static/js/capa/genex/2DDA730EDABB80B88A6B0DFA3AFEACA2.cache.html b/common/static/js/capa/genex/3F4ADBED36D589545A9300A1EA686D36.cache.html
similarity index 91%
rename from common/static/js/capa/genex/2DDA730EDABB80B88A6B0DFA3AFEACA2.cache.html
rename to common/static/js/capa/genex/3F4ADBED36D589545A9300A1EA686D36.cache.html
index 743492768b..c5ad0d1b89 100644
--- a/common/static/js/capa/genex/2DDA730EDABB80B88A6B0DFA3AFEACA2.cache.html
+++ b/common/static/js/capa/genex/3F4ADBED36D589545A9300A1EA686D36.cache.html
@@ -1,4 +1,4 @@
-
\ No newline at end of file
diff --git a/common/static/js/capa/genex/4EEB1DCF4B30D366C27968D1B5C0BD04.cache.html b/common/static/js/capa/genex/FF175D5583BDD5ACF40C7F0AFF9A374B.cache.html
similarity index 87%
rename from common/static/js/capa/genex/4EEB1DCF4B30D366C27968D1B5C0BD04.cache.html
rename to common/static/js/capa/genex/FF175D5583BDD5ACF40C7F0AFF9A374B.cache.html
index 4aa12e55d4..ca07bf3292 100644
--- a/common/static/js/capa/genex/4EEB1DCF4B30D366C27968D1B5C0BD04.cache.html
+++ b/common/static/js/capa/genex/FF175D5583BDD5ACF40C7F0AFF9A374B.cache.html
@@ -1,4 +1,4 @@
-
\ No newline at end of file
diff --git a/common/static/js/capa/genex/genex.css b/common/static/js/capa/genex/genex.css
index a05f31110b..459c854f92 100644
--- a/common/static/js/capa/genex/genex.css
+++ b/common/static/js/capa/genex/genex.css
@@ -57,10 +57,15 @@ pre, #dna-strand {
background: white;
}
.gwt-DialogBox .dialogBottomCenter {
+ background: url(images/hborder.png) repeat-x 0px -2945px;
+ -background: url(images/hborder_ie6.png) repeat-x 0px -2144px;
}
.gwt-DialogBox .dialogMiddleLeft {
+ background: url(images/vborder.png) repeat-y -31px 0px;
}
.gwt-DialogBox .dialogMiddleRight {
+ background: url(images/vborder.png) repeat-y -32px 0px;
+ -background: url(images/vborder_ie6.png) repeat-y -32px 0px;
}
.gwt-DialogBox .dialogTopLeftInner {
width: 10px;
@@ -82,12 +87,20 @@ pre, #dna-strand {
zoom: 1;
}
.gwt-DialogBox .dialogTopLeft {
+ background: url(images/circles.png) no-repeat -20px 0px;
+ -background: url(images/circles_ie6.png) no-repeat -20px 0px;
}
.gwt-DialogBox .dialogTopRight {
+ background: url(images/circles.png) no-repeat -28px 0px;
+ -background: url(images/circles_ie6.png) no-repeat -28px 0px;
}
.gwt-DialogBox .dialogBottomLeft {
+ background: url(images/circles.png) no-repeat 0px -36px;
+ -background: url(images/circles_ie6.png) no-repeat 0px -36px;
}
.gwt-DialogBox .dialogBottomRight {
+ background: url(images/circles.png) no-repeat -8px -36px;
+ -background: url(images/circles_ie6.png) no-repeat -8px -36px;
}
* html .gwt-DialogBox .dialogTopLeftInner {
width: 10px;
diff --git a/common/static/js/capa/genex/genex.nocache.js b/common/static/js/capa/genex/genex.nocache.js
index 07da038234..fe892a53dc 100644
--- a/common/static/js/capa/genex/genex.nocache.js
+++ b/common/static/js/capa/genex/genex.nocache.js
@@ -1,4 +1,4 @@
-function genex(){var P='',xb='" for "gwt:onLoadErrorFn"',vb='" for "gwt:onPropertyErrorFn"',ib='"><\/script>',Z='#',Xb='.cache.html',_='/',lb='//',Qb='026A6180B5959B8660E084245FEE5E9E',Rb='1F433010E1134C95BF6CB43F552F3019',Sb='2DDA730EDABB80B88A6B0DFA3AFEACA2',Tb='4EEB1DCF4B30D366C27968D1B5C0BD04',Ub='5033ABB047340FB9346B622E2CC7107D',Wb=':',pb='::',dc='
+ {% load compressed %}
+ {# static files #}
+ {% for url in suite.static_files %}
+
+ {% endfor %}
+
+ {% compressed_js 'js-test-source' %}
+
{# source files #}
{% for url in suite.js_files %}
{% endfor %}
- {% load compressed %}
- {# static files #}
- {% compressed_js 'js-test-source' %}
{# spec files #}
{% compressed_js 'spec' %}
diff --git a/common/test/data/conditional_and_poll/README b/common/test/data/conditional_and_poll/README
new file mode 100644
index 0000000000..fc95a7c0c9
--- /dev/null
+++ b/common/test/data/conditional_and_poll/README
@@ -0,0 +1,50 @@
+Any place that says "YEAR_SEMESTER" needs to be replaced with something
+in the form "2013_Spring". Take note of this name exactly, you'll need to
+use it everywhere, precisely - capitalization is very important.
+
+See https://github.com/MITx/mitx/blob/master/doc/xml-format.md for more on all this.
+-----------------------
+
+about/: Files that live here will be visible OUTSIDE OF COURSEWARE.
+ YEAR_SEMESTER/
+ end_date.html: Specifies in plain-text the end date of the course
+ overview.html: Text of the overview of the course
+ short_description.html: 10-15 words about the course
+ prerequisites.html: Any prerequisites for the course, or None if there are none.
+
+course/
+ YEAR_SEMESTER.xml: This is your top-level xml page that points at chapters.
+ Can just be for now.
+
+course.xml: This top level file points at a file in roots/. See creating_course.xml.
+
+creating_course.xml: Explains how to create course.xml
+
+info/: Files that live here will be visible on the COURSE LANDING PAGE
+ (Course Info) WITHIN THE COURSEWARE.
+ YEAR_SEMESTER/
+ handouts.html: A list of handouts, or an empty file if there are none
+ (if this file doesn't exist, it displays an error)
+ updates.html: Course updates.
+
+policies/
+ YEAR_SEMESTER/
+ policy.json: See https://github.com/MITx/mitx/blob/master/doc/xml-format.md
+ for more on the fields specified by this file.
+ grading_policy.json: Optional -- you don't need it to get a course off the
+ ground but will eventually. For more info see
+ https://github.com/MITx/mitx/blob/master/doc/course_grading.md
+
+roots/
+ YEAR_SEMESTER.xml: Looks something like
+
+ where ORG in {"MITx", "HarvardX", "BerkeleyX"}
+
+static/
+ See README.
+
+ images/
+ course_image.jpg: You MUST have an image named this to be the background
+ banner image on edx.org
+
+-----------------------
\ No newline at end of file
diff --git a/common/test/data/conditional_and_poll/README.md b/common/test/data/conditional_and_poll/README.md
new file mode 100644
index 0000000000..7dbfa46a26
--- /dev/null
+++ b/common/test/data/conditional_and_poll/README.md
@@ -0,0 +1,2 @@
+content-harvard-justicex
+========================
\ No newline at end of file
diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/overview.html b/common/test/data/conditional_and_poll/about/2013_Spring/overview.html
new file mode 100644
index 0000000000..9c49899948
--- /dev/null
+++ b/common/test/data/conditional_and_poll/about/2013_Spring/overview.html
@@ -0,0 +1,79 @@
+
+
+
+
About ER22x
+
+
Justice is a critical analysis of classical and contemporary theories of justice, including discussion of present-day applications. Topics include affirmative action, income distribution, same-sex marriage, the role of markets, debates about rights (human rights and property rights), arguments for and against equality, dilemmas of loyalty in public and private life. The course invites students to subject their own views on these controversies to critical examination.
+
+
The principle readings for the course are texts by Aristotle, John Locke, Immanuel Kant, John Stuart Mill, and John Rawls. Other assigned readings include writings by contemporary philosophers, court cases, and articles about political controversies that raise philosophical questions.
+
+
+
+
+
+
+
+
Course instructor
+
+
+
+
+
Michael J. Sandel
+
Michael J. Sandel is the Anne T. and Robert M. Bass Professor of Government at Harvard University, where he teaches political philosophy. His course "Justice" has enrolled more than 15,000 Harvard students. Sandel's writings have been published in 21 languages. His books include What Money Can't Buy: The Moral Limits of Markets (2012); Justice: What's the Right Thing to Do? (2009); The Case against Perfection: Ethics in the Age of Genetic Engineering (2007); Public Philosophy: Essays on Morality in Politics (2005); Democracy's Discontent (1996); and Liberalism and the Limits of Justice(1982; 2nd ed., 1998).
+
+
+
+
+
+
+
Frequently Asked Questions
+
+
How much does it cost to take the course?
+
Nothing! The course is free.
+
+
+
+
Does the course have any prerequisites?
+
No. Only an interest in thinking through some of the big ethical and civic questions we face in our everyday lives.
+
+
+
+
Do I need any other materials to take the course?
+
No. As long as you’ve got a computer to access the website, you are ready to take the course.
+
+
+
+
Is there a textbook for the course?
+
All of the course readings that are in the public domain are freely available online, at links provided on the course website. The course can be taken using these free resources alone. For those who wish to purchase a printed version of the assigned readings, an edited volume entitled, Justice: A Reader (ed., Michael Sandel) is available in paperback from Oxford University Press (in bookstores and from online booksellers). Those who would like supplementary readings on the themes of the lectures can find them in Michael Sandel's book Justice: What's the Right Thing to Do?, which is available in various languages throughout the world. This book is not required, and the course can be taken using the free online resources alone.
+
+
+
+
Do I need to watch the lectures at a specific time?
+
No. You can watch the lectures at your leisure.
+
+
+
+
Will I be able to participate in class discussions?
+
Yes, in several ways:
+
+
+
Each lecture invites you to respond to a poll question related to the themes of the lecture. If you respond to the question, you will be presented with a challenge to the opinion you have expressed, and invited to reply to the challenge. You can also, if you wish, comment on the opinions and responses posted by other students in the course, continuing the discussion.
+
+
In addition to the poll question, each class contains a discussion prompt that invites you to offer your view on a controversial question related to the lecture. If you wish, you can respond to this question, and then see what other students have to say about the argument you present. You can also comment on the opinions posted by other students. One aim of the course is to promote reasoned public dialogue about hard moral and political questions.
+
+
Each week, there will be an optional live dialogue enabling students to interact with instructors and participants from around the world.
+
+
+
+
+
Will certificates be awarded?
+
Yes. Online learners who achieve a passing grade in a course can earn a certificate of mastery. These certificates will indicate you have successfully completed the course, but will not include a specific grade. Certificates will be issued by edX under the name of HarvardX, designating the institution from which the course originated.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/prerequisites.html b/common/test/data/conditional_and_poll/about/2013_Spring/prerequisites.html
new file mode 100644
index 0000000000..b0047fa49f
--- /dev/null
+++ b/common/test/data/conditional_and_poll/about/2013_Spring/prerequisites.html
@@ -0,0 +1 @@
+None
diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/short_description.html b/common/test/data/conditional_and_poll/about/2013_Spring/short_description.html
new file mode 100644
index 0000000000..208880c842
--- /dev/null
+++ b/common/test/data/conditional_and_poll/about/2013_Spring/short_description.html
@@ -0,0 +1 @@
+JusticeX is an introduction to moral and political philosophy, including discussion of contemporary dilemmas and controversies.
\ No newline at end of file
diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/video.html b/common/test/data/conditional_and_poll/about/2013_Spring/video.html
new file mode 100644
index 0000000000..0cf427b16c
--- /dev/null
+++ b/common/test/data/conditional_and_poll/about/2013_Spring/video.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/common/test/data/conditional_and_poll/chapter/Staff.xml b/common/test/data/conditional_and_poll/chapter/Staff.xml
new file mode 100644
index 0000000000..e1d5216f6d
--- /dev/null
+++ b/common/test/data/conditional_and_poll/chapter/Staff.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/common/test/data/conditional_and_poll/conditional/condone.xml b/common/test/data/conditional_and_poll/conditional/condone.xml
new file mode 100644
index 0000000000..80b061e244
--- /dev/null
+++ b/common/test/data/conditional_and_poll/conditional/condone.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/common/test/data/conditional_and_poll/course.xml b/common/test/data/conditional_and_poll/course.xml
new file mode 120000
index 0000000000..f4f5c17b87
--- /dev/null
+++ b/common/test/data/conditional_and_poll/course.xml
@@ -0,0 +1 @@
+roots/2013_Spring.xml
\ No newline at end of file
diff --git a/common/test/data/conditional_and_poll/course/2013_Spring.xml b/common/test/data/conditional_and_poll/course/2013_Spring.xml
new file mode 100644
index 0000000000..2eea422a2f
--- /dev/null
+++ b/common/test/data/conditional_and_poll/course/2013_Spring.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/common/test/data/conditional_and_poll/creating_course.xml b/common/test/data/conditional_and_poll/creating_course.xml
new file mode 100644
index 0000000000..4c90f1c2ec
--- /dev/null
+++ b/common/test/data/conditional_and_poll/creating_course.xml
@@ -0,0 +1,8 @@
+
diff --git a/common/test/data/conditional_and_poll/html/secret_page.xml b/common/test/data/conditional_and_poll/html/secret_page.xml
new file mode 100644
index 0000000000..63be3cfa8d
--- /dev/null
+++ b/common/test/data/conditional_and_poll/html/secret_page.xml
@@ -0,0 +1,4 @@
+
+
Consider a hypothetical magnetic field pointing out of your computer screen. Now imagine an electron traveling from right to left in the plane of your screen. A diagram of this situation is show below…
+
+
+
a. The magnitude of the force experienced by the electron is proportional the product of which of the following? (Select all that apply.)
+
+
+
+
+Magnetic field strength…
+Electric field strength…
+Electric charge of the electron…
+Radius of the electron…
+Mass of the electron…
+Velocity of the electron…
+
+
+
+
+
diff --git a/common/test/data/conditional_and_poll/roots/2013_Spring.xml b/common/test/data/conditional_and_poll/roots/2013_Spring.xml
new file mode 100644
index 0000000000..1b97a5a714
--- /dev/null
+++ b/common/test/data/conditional_and_poll/roots/2013_Spring.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/common/test/data/conditional_and_poll/sequential/Problem_Demos.xml b/common/test/data/conditional_and_poll/sequential/Problem_Demos.xml
new file mode 100644
index 0000000000..e10298336d
--- /dev/null
+++ b/common/test/data/conditional_and_poll/sequential/Problem_Demos.xml
@@ -0,0 +1,31 @@
+
+
+
+
What's the Right Thing to Do?
+
Suppose four shipwrecked sailors are stranded at sea in a lifeboat, without
+ food or water. Would it be wrong for three of them to kill and eat the cabin
+ boy, in order to save their own lives?
+ Yes
+ No
+ Don't know
+
+
+
What's the Right Thing to Do?
+
Suppose four shipwrecked sailors are stranded at sea in a lifeboat, without
+ food or water. Would it be wrong for three of them to kill and eat the cabin
+ boy, in order to save their own lives?
+ Yes
+ No
+ Don't know
+
+
+
+
+
+ Condition: first_poll - Yes
+
+ In first condition.
+
+
+
+
diff --git a/common/test/data/conditional_and_poll/static/README b/common/test/data/conditional_and_poll/static/README
new file mode 100644
index 0000000000..e22f378b5e
--- /dev/null
+++ b/common/test/data/conditional_and_poll/static/README
@@ -0,0 +1,5 @@
+Images, handouts, and other statically-served content should go ONLY
+in this directory.
+
+Images for the front page should go in static/images. The frontpage
+banner MUST be named course_image.jpg
\ No newline at end of file
diff --git a/common/test/data/conditional_and_poll/static/images/course_image.jpg b/common/test/data/conditional_and_poll/static/images/course_image.jpg
new file mode 100644
index 0000000000..b6a64b9396
Binary files /dev/null and b/common/test/data/conditional_and_poll/static/images/course_image.jpg differ
diff --git a/common/test/data/conditional_and_poll/static/images/professor-sandel.jpg b/common/test/data/conditional_and_poll/static/images/professor-sandel.jpg
new file mode 100644
index 0000000000..41bde60165
Binary files /dev/null and b/common/test/data/conditional_and_poll/static/images/professor-sandel.jpg differ
diff --git a/doc/development.md b/doc/development.md
index 16c689ff05..184767a139 100644
--- a/doc/development.md
+++ b/doc/development.md
@@ -9,9 +9,8 @@ This will read the `Gemfile` and install all of the gems specified there.
### Python
-In order, run the following:
+Run the following::
- pip install -r pre-requirements.txt
pip install -r requirements.txt
pip install -r test-requirements.txt
diff --git a/jenkins/base.sh b/jenkins/base.sh
new file mode 100644
index 0000000000..c7175e6e52
--- /dev/null
+++ b/jenkins/base.sh
@@ -0,0 +1,12 @@
+
+function github_status {
+ gcli status create mitx mitx $GIT_COMMIT \
+ --params=$1 \
+ target_url:$BUILD_URL \
+ description:"Build #$BUILD_NUMBER is running" \
+ -f csv
+}
+
+function github_mark_failed_on_exit {
+ trap '[ $? == "0" ] || github_status state:failed' EXIT
+}
\ No newline at end of file
diff --git a/jenkins/test.sh b/jenkins/test.sh
index f8ffab29fc..1f5ce70b1b 100755
--- a/jenkins/test.sh
+++ b/jenkins/test.sh
@@ -39,8 +39,8 @@ pip install -q -r pre-requirements.txt
yes w | pip install -q -r test-requirements.txt -r requirements.txt
rake clobber
-rake pep8
-rake pylint
+rake pep8 > pep8.log || cat pep8.log
+rake pylint > pylint.log || cat pylint.log
TESTS_FAILED=0
rake test_cms[false] || TESTS_FAILED=1
diff --git a/lms/djangoapps/circuit/views.py b/lms/djangoapps/circuit/views.py
index 9711e0648c..40a31a2e3a 100644
--- a/lms/djangoapps/circuit/views.py
+++ b/lms/djangoapps/circuit/views.py
@@ -9,7 +9,7 @@ from django.http import HttpResponse
from django.shortcuts import redirect
from mitxmako.shortcuts import render_to_response, render_to_string
-from models import ServerCircuit
+from .models import ServerCircuit
def circuit_line(circuit):
diff --git a/lms/djangoapps/course_wiki/views.py b/lms/djangoapps/course_wiki/views.py
index 6e9f2e38de..6ab106ed70 100644
--- a/lms/djangoapps/course_wiki/views.py
+++ b/lms/djangoapps/course_wiki/views.py
@@ -95,7 +95,7 @@ def course_wiki_redirect(request, course_id):
root,
course_slug,
title=course_slug,
- content="This is the wiki for **{0}**'s _{1}_.".format(course.org, course.title),
+ content="This is the wiki for **{0}**'s _{1}_.".format(course.org, course.display_name_with_default),
user_message="Course page automatically created.",
user=None,
ip_address=None,
diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py
index eaf06d79dc..08bf49ac98 100644
--- a/lms/djangoapps/courseware/access.py
+++ b/lms/djangoapps/courseware/access.py
@@ -164,7 +164,7 @@ def _has_access_course_desc(user, course, action):
if settings.MITX_FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'):
# if this feature is on, only allow courses that have ispublic set to be
# seen by non-staff
- if course.metadata.get('ispublic'):
+ if course.lms.ispublic:
debug("Allow: ACCESS_REQUIRE_STAFF_FOR_COURSE and ispublic")
return True
return _has_staff_access_to_descriptor(user, course)
@@ -240,7 +240,7 @@ def _has_access_descriptor(user, descriptor, action, course_context=None):
return True
# Check start date
- if descriptor.start is not None:
+ if descriptor.lms.start is not None:
now = time.gmtime()
effective_start = _adjust_start_date_for_beta_testers(user, descriptor)
if now > effective_start:
@@ -495,9 +495,9 @@ def _adjust_start_date_for_beta_testers(user, descriptor):
NOTE: If testing manually, make sure MITX_FEATURES['DISABLE_START_DATES'] = False
in envs/dev.py!
"""
- if descriptor.days_early_for_beta is None:
+ if descriptor.lms.days_early_for_beta is None:
# bail early if no beta testing is set up
- return descriptor.start
+ return descriptor.lms.start
user_groups = [g.name for g in user.groups.all()]
@@ -508,13 +508,13 @@ def _adjust_start_date_for_beta_testers(user, descriptor):
# subtract, convert back.
# (fun fact: datetime(*a_time_struct[:6]) is the beautiful syntax for
# converting time_structs into datetimes)
- start_as_datetime = datetime(*descriptor.start[:6])
- delta = timedelta(descriptor.days_early_for_beta)
+ start_as_datetime = datetime(*descriptor.lms.start[:6])
+ delta = timedelta(descriptor.lms.days_early_for_beta)
effective = start_as_datetime - delta
# ...and back to time_struct
return effective.timetuple()
- return descriptor.start
+ return descriptor.lms.start
def _has_instructor_access_to_location(user, location, course_context=None):
diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py
index 52346d7583..3e1162bc03 100644
--- a/lms/djangoapps/courseware/courses.py
+++ b/lms/djangoapps/courseware/courses.py
@@ -11,7 +11,7 @@ from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import Http404
-from module_render import get_module
+from .module_render import get_module
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
@@ -19,10 +19,10 @@ from xmodule.contentstore.content import StaticContent
from xmodule.modulestore.xml import XMLModuleStore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.x_module import XModule
+from courseware.model_data import ModelDataCache
from static_replace import replace_static_urls
from courseware.access import has_access
import branding
-from courseware.models import StudentModuleCache
from xmodule.modulestore.exceptions import ItemNotFoundError
log = logging.getLogger(__name__)
@@ -89,7 +89,7 @@ def course_image_url(course):
"""Try to look up the image url for the course. If it's not found,
log an error and return the dead link"""
if isinstance(modulestore(), XMLModuleStore):
- return '/static/' + course.metadata['data_dir'] + "/images/course_image.jpg"
+ return '/static/' + course.data_dir + "/images/course_image.jpg"
else:
loc = course.location._replace(tag='c4x', category='asset', name='images_course_image.jpg')
path = StaticContent.get_url_path_from_location(loc)
@@ -153,12 +153,23 @@ def get_course_about_section(course, section_key):
request = get_request_for_thread()
loc = course.location._replace(category='about', name=section_key)
- course_module = get_module(request.user, request, loc, None, course.id, not_found_ok=True, wrap_xmodule_display=False)
+
+ # Use an empty cache
+ model_data_cache = ModelDataCache([], course.id, request.user)
+ about_module = get_module(
+ request.user,
+ request,
+ loc,
+ model_data_cache,
+ course.id,
+ not_found_ok=True,
+ wrap_xmodule_display=False
+ )
html = ''
- if course_module is not None:
- html = course_module.get_html()
+ if about_module is not None:
+ html = about_module.get_html()
return html
@@ -167,7 +178,7 @@ def get_course_about_section(course, section_key):
key=section_key, url=course.location.url()))
return None
elif section_key == "title":
- return course.metadata.get('display_name', course.url_name)
+ return course.display_name_with_default
elif section_key == "university":
return course.location.org
elif section_key == "number":
@@ -177,7 +188,7 @@ def get_course_about_section(course, section_key):
-def get_course_info_section(request, cache, course, section_key):
+def get_course_info_section(request, course, section_key):
"""
This returns the snippet of html to be rendered on the course info page,
given the key for the section.
@@ -191,11 +202,22 @@ def get_course_info_section(request, cache, course, section_key):
loc = Location(course.location.tag, course.location.org, course.location.course, 'course_info', section_key)
- course_module = get_module(request.user, request, loc, cache, course.id, wrap_xmodule_display=False)
+
+ # Use an empty cache
+ model_data_cache = ModelDataCache([], course.id, request.user)
+ info_module = get_module(
+ request.user,
+ request,
+ loc,
+ model_data_cache,
+ course.id,
+ wrap_xmodule_display=False
+ )
+
html = ''
- if course_module is not None:
- html = course_module.get_html()
+ if info_module is not None:
+ html = info_module.get_html()
return html
@@ -226,7 +248,7 @@ def get_course_syllabus_section(course, section_key):
with fs.open(filepath) as htmlFile:
return replace_static_urls(
htmlFile.read().decode('utf-8'),
- course.metadata['data_dir'],
+ getattr(course, 'data_dir', None),
course_namespace=course.location
)
except ResourceNotFoundError:
diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py
index 2e19696ad4..8fb2843656 100644
--- a/lms/djangoapps/courseware/features/common.py
+++ b/lms/djangoapps/courseware/features/common.py
@@ -5,6 +5,10 @@ from lettuce.django import django_url
from django.conf import settings
from django.contrib.auth.models import User
from student.models import CourseEnrollment
+from terrain.factories import CourseFactory, ItemFactory
+from xmodule.modulestore import Location
+from xmodule.modulestore.django import _MODULESTORES, modulestore
+from xmodule.templates import update_templates
import time
from logging import getLogger
@@ -81,14 +85,57 @@ def i_am_not_logged_in(step):
world.browser.cookies.delete()
-@step(u'I am registered for a course$')
-def i_am_registered_for_a_course(step):
+TEST_COURSE_ORG = 'edx'
+TEST_COURSE_NAME = 'Test Course'
+TEST_SECTION_NAME = "Problem"
+
+
+@step(u'The course "([^"]*)" exists$')
+def create_course(step, course):
+
+ # First clear the modulestore so we don't try to recreate
+ # the same course twice
+ # This also ensures that the necessary templates are loaded
+ flush_xmodule_store()
+
+ # Create the course
+ # We always use the same org and display name,
+ # but vary the course identifier (e.g. 600x or 191x)
+ course = CourseFactory.create(org=TEST_COURSE_ORG,
+ number=course,
+ display_name=TEST_COURSE_NAME)
+
+ # Add a section to the course to contain problems
+ section = ItemFactory.create(parent_location=course.location,
+ display_name=TEST_SECTION_NAME)
+
+ problem_section = ItemFactory.create(parent_location=section.location,
+ template='i4x://edx/templates/sequential/Empty',
+ display_name=TEST_SECTION_NAME)
+
+
+@step(u'I am registered for the course "([^"]*)"$')
+def i_am_registered_for_the_course(step, course):
+ # Create the course
+ create_course(step, course)
+
+ # Create the user
world.create_user('robot')
u = User.objects.get(username='robot')
- CourseEnrollment.objects.create(user=u, course_id='MITx/6.002x/2012_Fall')
+
+ # If the user is not already enrolled, enroll the user.
+ CourseEnrollment.objects.get_or_create(user=u, course_id=course_id(course))
+
world.log_in('robot@edx.org', 'test')
+@step(u'The course "([^"]*)" has extra tab "([^"]*)"$')
+def add_tab_to_course(step, course, extra_tab_name):
+ section_item = ItemFactory.create(parent_location=course_location(course),
+ template="i4x://edx/templates/static_tab/Empty",
+ display_name=str(extra_tab_name))
+
+
@step(u'I am an edX user$')
def i_am_an_edx_user(step):
world.create_user('robot')
@@ -97,3 +144,37 @@ def i_am_an_edx_user(step):
@step(u'User "([^"]*)" is an edX user$')
def registered_edx_user(step, uname):
world.create_user(uname)
+
+
+def flush_xmodule_store():
+ # Flush and initialize the module store
+ # It needs the templates because it creates new records
+ # by cloning from the template.
+ # Note that if your test module gets in some weird state
+ # (though it shouldn't), do this manually
+ # from the bash shell to drop it:
+ # $ mongo test_xmodule --eval "db.dropDatabase()"
+ _MODULESTORES = {}
+ modulestore().collection.drop()
+ update_templates()
+
+
+def course_id(course_num):
+ return "%s/%s/%s" % (TEST_COURSE_ORG, course_num,
+ TEST_COURSE_NAME.replace(" ", "_"))
+
+
+def course_location(course_num):
+ return Location(loc_or_tag="i4x",
+ org=TEST_COURSE_ORG,
+ course=course_num,
+ category='course',
+ name=TEST_COURSE_NAME.replace(" ", "_"))
+
+
+def section_location(course_num):
+ return Location(loc_or_tag="i4x",
+ org=TEST_COURSE_ORG,
+ course=course_num,
+ category='sequential',
+ name=TEST_SECTION_NAME.replace(" ", "_"))
diff --git a/lms/djangoapps/courseware/features/courses.py b/lms/djangoapps/courseware/features/courses.py
index ba0bcd359b..4fbbfd24f2 100644
--- a/lms/djangoapps/courseware/features/courses.py
+++ b/lms/djangoapps/courseware/features/courses.py
@@ -9,6 +9,7 @@ logger = getLogger(__name__)
## support functions
+
def get_courses():
'''
Returns dict of lists of courses available, keyed by course.org (ie university).
@@ -26,8 +27,8 @@ def get_courseware_with_tabs(course_id):
top three levels of navigation. Same as get_courseware() except include
the tabs on the right hand main navigation page.
- This hides the appropriate courseware as defined by the XML flag test:
- chapter.metadata.get('hide_from_toc','false').lower() == 'true'
+ This hides the appropriate courseware as defined by the hide_from_toc field:
+ chapter.lms.hide_from_toc
Example:
@@ -80,14 +81,14 @@ def get_courseware_with_tabs(course_id):
"""
course = get_course_by_id(course_id)
- chapters = [chapter for chapter in course.get_children() if chapter.metadata.get('hide_from_toc', 'false').lower() != 'true']
- courseware = [{'chapter_name': c.display_name,
- 'sections': [{'section_name': s.display_name,
+ chapters = [chapter for chapter in course.get_children() if not chapter.lms.hide_from_toc]
+ courseware = [{'chapter_name': c.display_name_with_default,
+ 'sections': [{'section_name': s.display_name_with_default,
'clickable_tab_count': len(s.get_children()) if (type(s) == seq_module.SequenceDescriptor) else 0,
'tabs': [{'children_count': len(t.get_children()) if (type(t) == vertical_module.VerticalDescriptor) else 0,
'class': t.__class__.__name__}
for t in s.get_children()]}
- for s in c.get_children() if s.metadata.get('hide_from_toc', 'false').lower() != 'true']}
+ for s in c.get_children() if not s.lms.hide_from_toc]}
for c in chapters]
return courseware
diff --git a/lms/djangoapps/courseware/features/courseware.feature b/lms/djangoapps/courseware/features/courseware.feature
deleted file mode 100644
index 279e5732c9..0000000000
--- a/lms/djangoapps/courseware/features/courseware.feature
+++ /dev/null
@@ -1,11 +0,0 @@
-Feature: View the Courseware Tab
- As a student in an edX course
- In order to work on the course
- I want to view the info on the courseware tab
-
- Scenario: I can get to the courseware tab when logged in
- Given I am registered for a course
- And I log in
- And I click on View Courseware
- When I click on the "Courseware" tab
- Then the "Courseware" tab is active
diff --git a/lms/djangoapps/courseware/features/high-level-tabs.feature b/lms/djangoapps/courseware/features/high-level-tabs.feature
index 2e9c4f1886..931281a455 100644
--- a/lms/djangoapps/courseware/features/high-level-tabs.feature
+++ b/lms/djangoapps/courseware/features/high-level-tabs.feature
@@ -3,21 +3,18 @@ Feature: All the high level tabs should work
As a student
I want to navigate through the high level tabs
-# Note this didn't work as a scenario outline because
-# before each scenario was not flushing the database
-# TODO: break this apart so that if one fails the others
-# will still run
- Scenario: A student can see all tabs of the course
- Given I am registered for a course
- And I log in
- And I click on View Courseware
- When I click on the "Courseware" tab
- Then the page title should be "6.002x Courseware"
- When I click on the "Course Info" tab
- Then the page title should be "6.002x Course Info"
- When I click on the "Textbook" tab
- Then the page title should be "6.002x Textbook"
- When I click on the "Wiki" tab
- Then the page title should be "6.002x | edX Wiki"
- When I click on the "Progress" tab
- Then the page title should be "6.002x Progress"
+Scenario: I can navigate to all high -level tabs in a course
+ Given: I am registered for the course "6.002x"
+ And The course "6.002x" has extra tab "Custom Tab"
+ And I log in
+ And I click on View Courseware
+ When I click on the "" tab
+ Then the page title should contain ""
+
+ Examples:
+ | TabName | PageTitle |
+ | Courseware | 6.002x Courseware |
+ | Course Info | 6.002x Course Info |
+ | Custom Tab | 6.002x Custom Tab |
+ | Wiki | edX Wiki |
+ | Progress | 6.002x Progress |
diff --git a/lms/djangoapps/courseware/features/homepage.feature b/lms/djangoapps/courseware/features/homepage.feature
index 06a45c4bfa..c0c1c32f02 100644
--- a/lms/djangoapps/courseware/features/homepage.feature
+++ b/lms/djangoapps/courseware/features/homepage.feature
@@ -39,9 +39,9 @@ Feature: Homepage for web users
| MITx |
| HarvardX |
| BerkeleyX |
- | UTx |
+ | UTx |
| WellesleyX |
- | GeorgetownX |
+ | GeorgetownX |
# # TODO: Add scenario that tests the courses available
# # using a policy or a configuration file
diff --git a/lms/djangoapps/courseware/features/login.py b/lms/djangoapps/courseware/features/login.py
index ca7d710c61..094db078ca 100644
--- a/lms/djangoapps/courseware/features/login.py
+++ b/lms/djangoapps/courseware/features/login.py
@@ -34,6 +34,7 @@ def click_the_dropdown(step):
#### helper functions
+
def user_is_an_unactivated_user(uname):
u = User.objects.get(username=uname)
u.is_active = False
diff --git a/lms/djangoapps/courseware/features/openended.feature b/lms/djangoapps/courseware/features/openended.feature
index cc9f6e1c5f..1ab496144f 100644
--- a/lms/djangoapps/courseware/features/openended.feature
+++ b/lms/djangoapps/courseware/features/openended.feature
@@ -3,10 +3,10 @@ Feature: Open ended grading
In order to complete the courseware questions
I want the machine learning grading to be functional
- # Commenting these all out right now until we can
+ # Commenting these all out right now until we can
# make a reference implementation for a course with
# an open ended grading problem that is always available
- #
+ #
# Scenario: An answer that is too short is rejected
# Given I navigate to an openended question
# And I enter the answer "z"
diff --git a/lms/djangoapps/courseware/features/problems.feature b/lms/djangoapps/courseware/features/problems.feature
new file mode 100644
index 0000000000..a7fbac49c7
--- /dev/null
+++ b/lms/djangoapps/courseware/features/problems.feature
@@ -0,0 +1,73 @@
+Feature: Answer choice problems
+ As a student in an edX course
+ In order to test my understanding of the material
+ I want to answer choice based problems
+
+ Scenario: I can answer a problem correctly
+ Given I am viewing a "" problem
+ When I answer a "" problem "correctly"
+ Then My "" answer is marked "correct"
+
+ Examples:
+ | ProblemType |
+ | drop down |
+ | multiple choice |
+ | checkbox |
+ | string |
+ | numerical |
+ | formula |
+ | script |
+
+ Scenario: I can answer a problem incorrectly
+ Given I am viewing a "" problem
+ When I answer a "" problem "incorrectly"
+ Then My "" answer is marked "incorrect"
+
+ Examples:
+ | ProblemType |
+ | drop down |
+ | multiple choice |
+ | checkbox |
+ | string |
+ | numerical |
+ | formula |
+ | script |
+
+ Scenario: I can submit a blank answer
+ Given I am viewing a "" problem
+ When I check a problem
+ Then My "" answer is marked "incorrect"
+
+ Examples:
+ | ProblemType |
+ | drop down |
+ | multiple choice |
+ | checkbox |
+ | string |
+ | numerical |
+ | formula |
+ | script |
+
+
+ Scenario: I can reset a problem
+ Given I am viewing a "" problem
+ And I answer a "" problem "ly"
+ When I reset the problem
+ Then My "" answer is marked "unanswered"
+
+ Examples:
+ | ProblemType | Correctness |
+ | drop down | correct |
+ | drop down | incorrect |
+ | multiple choice | correct |
+ | multiple choice | incorrect |
+ | checkbox | correct |
+ | checkbox | incorrect |
+ | string | correct |
+ | string | incorrect |
+ | numerical | correct |
+ | numerical | incorrect |
+ | formula | correct |
+ | formula | incorrect |
+ | script | correct |
+ | script | incorrect |
diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py
new file mode 100644
index 0000000000..a6575c3d22
--- /dev/null
+++ b/lms/djangoapps/courseware/features/problems.py
@@ -0,0 +1,271 @@
+from lettuce import world, step
+from lettuce.django import django_url
+from selenium.webdriver.support.ui import Select
+import random
+import textwrap
+from common import i_am_registered_for_the_course, TEST_SECTION_NAME, section_location
+from terrain.factories import ItemFactory
+from capa.tests.response_xml_factory import OptionResponseXMLFactory, \
+ ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \
+ StringResponseXMLFactory, NumericalResponseXMLFactory, \
+ FormulaResponseXMLFactory, CustomResponseXMLFactory
+
+# Factories from capa.tests.response_xml_factory that we will use
+# to generate the problem XML, with the keyword args used to configure
+# the output.
+PROBLEM_FACTORY_DICT = {
+ 'drop down': {
+ 'factory': OptionResponseXMLFactory(),
+ 'kwargs': {
+ 'question_text': 'The correct answer is Option 2',
+ 'options': ['Option 1', 'Option 2', 'Option 3', 'Option 4'],
+ 'correct_option': 'Option 2'}},
+
+ 'multiple choice': {
+ 'factory': MultipleChoiceResponseXMLFactory(),
+ 'kwargs': {
+ 'question_text': 'The correct answer is Choice 3',
+ 'choices': [False, False, True, False],
+ 'choice_names': ['choice_1', 'choice_2', 'choice_3', 'choice_4']}},
+
+ 'checkbox': {
+ 'factory': ChoiceResponseXMLFactory(),
+ 'kwargs': {
+ 'question_text': 'The correct answer is Choices 1 and 3',
+ 'choice_type': 'checkbox',
+ 'choices': [True, False, True, False, False],
+ 'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']}},
+
+ 'string': {
+ 'factory': StringResponseXMLFactory(),
+ 'kwargs': {
+ 'question_text': 'The answer is "correct string"',
+ 'case_sensitive': False,
+ 'answer': 'correct string'}},
+
+ 'numerical': {
+ 'factory': NumericalResponseXMLFactory(),
+ 'kwargs': {
+ 'question_text': 'The answer is pi + 1',
+ 'answer': '4.14159',
+ 'tolerance': '0.00001',
+ 'math_display': True}},
+
+ 'formula': {
+ 'factory': FormulaResponseXMLFactory(),
+ 'kwargs': {
+ 'question_text': 'The solution is [mathjax]x^2+2x+y[/mathjax]',
+ 'sample_dict': {'x': (-100, 100), 'y': (-100, 100)},
+ 'num_samples': 10,
+ 'tolerance': 0.00001,
+ 'math_display': True,
+ 'answer': 'x^2+2*x+y'}},
+
+ 'script': {
+ 'factory': CustomResponseXMLFactory(),
+ 'kwargs': {
+ 'question_text': 'Enter two integers that sum to 10.',
+ 'cfn': 'test_add_to_ten',
+ 'expect': '10',
+ 'num_inputs': 2,
+ 'script': textwrap.dedent("""
+ def test_add_to_ten(expect,ans):
+ try:
+ a1=int(ans[0])
+ a2=int(ans[1])
+ except ValueError:
+ a1=0
+ a2=0
+ return (a1+a2)==int(expect)
+ """)}},
+ }
+
+
+def add_problem_to_course(course, problem_type):
+
+ assert(problem_type in PROBLEM_FACTORY_DICT)
+
+ # Generate the problem XML using capa.tests.response_xml_factory
+ factory_dict = PROBLEM_FACTORY_DICT[problem_type]
+ problem_xml = factory_dict['factory'].build_xml(**factory_dict['kwargs'])
+
+ # Create a problem item using our generated XML
+ # We set rerandomize=always in the metadata so that the "Reset" button
+ # will appear.
+ problem_item = ItemFactory.create(parent_location=section_location(course),
+ template="i4x://edx/templates/problem/Blank_Common_Problem",
+ display_name=str(problem_type),
+ data=problem_xml,
+ metadata={'rerandomize': 'always'})
+
+
+@step(u'I am viewing a "([^"]*)" problem')
+def view_problem(step, problem_type):
+ i_am_registered_for_the_course(step, 'model_course')
+
+ # Ensure that the course has this problem type
+ add_problem_to_course('model_course', problem_type)
+
+ # Go to the one section in the factory-created course
+ # which should be loaded with the correct problem
+ chapter_name = TEST_SECTION_NAME.replace(" ", "_")
+ section_name = chapter_name
+ url = django_url('/courses/edx/model_course/Test_Course/courseware/%s/%s' %
+ (chapter_name, section_name))
+
+ world.browser.visit(url)
+
+
+@step(u'I answer a "([^"]*)" problem "([^"]*)ly"')
+def answer_problem(step, problem_type, correctness):
+ """ Mark a given problem type correct or incorrect, then submit it.
+
+ *problem_type* is a string representing the type of problem (e.g. 'drop down')
+ *correctness* is in ['correct', 'incorrect']
+ """
+
+ assert(correctness in ['correct', 'incorrect'])
+
+ if problem_type == "drop down":
+ select_name = "input_i4x-edx-model_course-problem-drop_down_2_1"
+ option_text = 'Option 2' if correctness == 'correct' else 'Option 3'
+ world.browser.select(select_name, option_text)
+
+ elif problem_type == "multiple choice":
+ if correctness == 'correct':
+ inputfield('multiple choice', choice='choice_3').check()
+ else:
+ inputfield('multiple choice', choice='choice_2').check()
+
+ elif problem_type == "checkbox":
+ if correctness == 'correct':
+ inputfield('checkbox', choice='choice_0').check()
+ inputfield('checkbox', choice='choice_2').check()
+ else:
+ inputfield('checkbox', choice='choice_3').check()
+
+ elif problem_type == 'string':
+ textvalue = 'correct string' if correctness == 'correct' else 'incorrect'
+ inputfield('string').fill(textvalue)
+
+ elif problem_type == 'numerical':
+ textvalue = "pi + 1" if correctness == 'correct' else str(random.randint(-2, 2))
+ inputfield('numerical').fill(textvalue)
+
+ elif problem_type == 'formula':
+ textvalue = "x^2+2*x+y" if correctness == 'correct' else 'x^2'
+ inputfield('formula').fill(textvalue)
+
+ elif problem_type == 'script':
+ # Correct answer is any two integers that sum to 10
+ first_addend = random.randint(-100, 100)
+ second_addend = 10 - first_addend
+
+ # If we want an incorrect answer, then change
+ # the second addend so they no longer sum to 10
+ if correctness == 'incorrect':
+ second_addend += random.randint(1, 10)
+
+ inputfield('script', input_num=1).fill(str(first_addend))
+ inputfield('script', input_num=2).fill(str(second_addend))
+
+ # Submit the problem
+ check_problem(step)
+
+
+@step(u'I check a problem')
+def check_problem(step):
+ world.browser.find_by_css("input.check").click()
+
+
+@step(u'I reset the problem')
+def reset_problem(step):
+ world.browser.find_by_css('input.reset').click()
+
+
+@step(u'My "([^"]*)" answer is marked "([^"]*)"')
+def assert_answer_mark(step, problem_type, correctness):
+ """ Assert that the expected answer mark is visible for a given problem type.
+
+ *problem_type* is a string identifying the type of problem (e.g. 'drop down')
+ *correctness* is in ['correct', 'incorrect', 'unanswered']
+
+ Asserting that a problem is marked 'unanswered' means that
+ the problem is NOT marked correct and NOT marked incorrect.
+ This can occur, for example, if the user has reset the problem. """
+
+ # Dictionaries that map problem types to the css selectors
+ # for correct/incorrect marks.
+ # The elements are lists of selectors because a particular problem type
+ # might be marked in multiple ways.
+ # For example, multiple choice is marked incorrect differently
+ # depending on whether the user selects an incorrect
+ # item or submits without selecting any item)
+ correct_selectors = {'drop down': ['span.correct'],
+ 'multiple choice': ['label.choicegroup_correct'],
+ 'checkbox': ['span.correct'],
+ 'string': ['div.correct'],
+ 'numerical': ['div.correct'],
+ 'formula': ['div.correct'],
+ 'script': ['div.correct'], }
+
+ incorrect_selectors = {'drop down': ['span.incorrect'],
+ 'multiple choice': ['label.choicegroup_incorrect',
+ 'span.incorrect'],
+ 'checkbox': ['span.incorrect'],
+ 'string': ['div.incorrect'],
+ 'numerical': ['div.incorrect'],
+ 'formula': ['div.incorrect'],
+ 'script': ['div.incorrect']}
+
+ assert(correctness in ['correct', 'incorrect', 'unanswered'])
+ assert(problem_type in correct_selectors and problem_type in incorrect_selectors)
+
+ # Assert that the question has the expected mark
+ # (either correct or incorrect)
+ if correctness in ["correct", "incorrect"]:
+
+ selector_dict = correct_selectors if correctness == "correct" else incorrect_selectors
+
+ # At least one of the correct selectors should be present
+ for sel in selector_dict[problem_type]:
+ has_expected_mark = world.browser.is_element_present_by_css(sel, wait_time=4)
+
+ # As soon as we find the selector, break out of the loop
+ if has_expected_mark:
+ break
+
+ # Expect that we found the right mark (correct or incorrect)
+ assert(has_expected_mark)
+
+ # Assert that the question has neither correct nor incorrect
+ # because it is unanswered (possibly reset)
+ else:
+ # Get all the correct/incorrect selectors for this problem type
+ selector_list = correct_selectors[problem_type] + incorrect_selectors[problem_type]
+
+ # Assert that none of the correct/incorrect selectors are present
+ for sel in selector_list:
+ assert(world.browser.is_element_not_present_by_css(sel, wait_time=4))
+
+
+def inputfield(problem_type, choice=None, input_num=1):
+ """ Return the element for *problem_type*.
+ For example, if problem_type is 'string', return
+ the text field for the string problem in the test course.
+
+ *choice* is the name of the checkbox input in a group
+ of checkboxes. """
+
+ sel = ("input#input_i4x-edx-model_course-problem-%s_2_%s" %
+ (problem_type.replace(" ", "_"), str(input_num)))
+
+ if choice is not None:
+ base = "_choice_" if problem_type == "multiple choice" else "_"
+ sel = sel + base + str(choice)
+
+ # If the input element doesn't exist, fail immediately
+ assert(world.browser.is_element_present_by_css(sel, wait_time=4))
+
+ # Retrieve the input element
+ return world.browser.find_by_css(sel)
diff --git a/lms/djangoapps/courseware/features/registration.feature b/lms/djangoapps/courseware/features/registration.feature
index d9b588534b..5933f860bb 100644
--- a/lms/djangoapps/courseware/features/registration.feature
+++ b/lms/djangoapps/courseware/features/registration.feature
@@ -4,13 +4,14 @@ Feature: Register for a course
I want to register for a class on the edX website
Scenario: I can register for a course
- Given I am logged in
+ Given The course "6.002x" exists
+ And I am logged in
And I visit the courses page
- When I register for the course numbered "6.002x"
+ When I register for the course "6.002x"
Then I should see the course numbered "6.002x" in my dashboard
Scenario: I can unregister for a course
- Given I am registered for a course
+ Given I am registered for the course "6.002x"
And I visit the dashboard
When I click the link with the text "Unregister"
And I press the "Unregister" button in the Unenroll dialog
diff --git a/lms/djangoapps/courseware/features/registration.py b/lms/djangoapps/courseware/features/registration.py
index f585136412..94b9b50f6c 100644
--- a/lms/djangoapps/courseware/features/registration.py
+++ b/lms/djangoapps/courseware/features/registration.py
@@ -1,12 +1,13 @@
from lettuce import world, step
+from lettuce.django import django_url
+from common import TEST_COURSE_ORG, TEST_COURSE_NAME
-@step('I register for the course numbered "([^"]*)"$')
+@step('I register for the course "([^"]*)"$')
def i_register_for_the_course(step, course):
- courses_section = world.browser.find_by_css('section.courses')
- course_link_css = 'article[id*="%s"] > div' % course
- course_link = courses_section.find_by_css(course_link_css).first
- course_link.click()
+ cleaned_name = TEST_COURSE_NAME.replace(' ', '_')
+ url = django_url('courses/%s/%s/%s/about' % (TEST_COURSE_ORG, course, cleaned_name))
+ world.browser.visit(url)
intro_section = world.browser.find_by_css('section.intro')
register_link = intro_section.find_by_css('a.register')
diff --git a/lms/djangoapps/courseware/features/smart-accordion.feature b/lms/djangoapps/courseware/features/smart-accordion.feature
index ccf1d45601..fc51eca25d 100644
--- a/lms/djangoapps/courseware/features/smart-accordion.feature
+++ b/lms/djangoapps/courseware/features/smart-accordion.feature
@@ -60,4 +60,4 @@ Feature: There are courses on the homepage
# Scenario: Navigate through course BerkeleyX/CS184.1x/2012_Fall
# Given I am registered for course "BerkeleyX/CS184.1x/2012_Fall"
# And I log in
- # Then I verify all the content of each course
\ No newline at end of file
+ # Then I verify all the content of each course
diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py
index 30cf91f61d..e7f389696c 100644
--- a/lms/djangoapps/courseware/grades.py
+++ b/lms/djangoapps/courseware/grades.py
@@ -8,13 +8,14 @@ from collections import defaultdict
from django.conf import settings
from django.contrib.auth.models import User
-from models import StudentModuleCache
-from module_render import get_module, get_instance_module
+from .model_data import ModelDataCache, LmsKeyValueStore
+from xblock.core import Scope
+from .module_render import get_module
from xmodule import graders
from xmodule.capa_module import CapaModule
from xmodule.course_module import CourseDescriptor
from xmodule.graders import Score
-from models import StudentModule
+from .models import StudentModule
log = logging.getLogger("mitx.courseware")
@@ -58,37 +59,39 @@ def yield_problems(request, course, student):
the list, but there may be others as well).
"""
grading_context = course.grading_context
- student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors'])
+ descriptor_locations = (descriptor.location.url() for descriptor in grading_context['all_descriptors'])
+ existing_student_modules = set(StudentModule.objects.filter(
+ module_state_key__in=descriptor_locations
+ ).values_list('module_state_key', flat=True))
+
+ sections_to_list = []
for section_format, sections in grading_context['graded_sections'].iteritems():
for section in sections:
section_descriptor = section['section_descriptor']
# If the student hasn't seen a single problem in the section, skip it.
- skip = True
for moduledescriptor in section['xmoduledescriptors']:
- if student_module_cache.lookup(
- course.id, moduledescriptor.category, moduledescriptor.location.url()):
- skip = False
+ if moduledescriptor.location.url() in existing_student_modules:
+ sections_to_list.append(section_descriptor)
break
- if skip:
- continue
+ model_data_cache = ModelDataCache(sections_to_list, course.id, student)
+ for section_descriptor in sections_to_list:
+ section_module = get_module(student, request,
+ section_descriptor.location, model_data_cache,
+ course.id)
+ if section_module is None:
+ # student doesn't have access to this module, or something else
+ # went wrong.
+ # log.debug("couldn't get module for student {0} for section location {1}"
+ # .format(student.username, section_descriptor.location))
+ continue
- section_module = get_module(student, request,
- section_descriptor.location, student_module_cache,
- course.id)
- if section_module is None:
- # student doesn't have access to this module, or something else
- # went wrong.
- # log.debug("couldn't get module for student {0} for section location {1}"
- # .format(student.username, section_descriptor.location))
- continue
-
- for problem in yield_module_descendents(section_module):
- if isinstance(problem, CapaModule):
- yield problem
+ for problem in yield_module_descendents(section_module):
+ if isinstance(problem, CapaModule):
+ yield problem
def answer_distributions(request, course):
@@ -112,13 +115,13 @@ def answer_distributions(request, course):
for problem_id in capa_module.lcp.student_answers:
# Answer can be a list or some other unhashable element. Convert to string.
answer = str(capa_module.lcp.student_answers[problem_id])
- key = (capa_module.url_name, capa_module.display_name, problem_id)
+ key = (capa_module.url_name, capa_module.display_name_with_default, problem_id)
counts[key][answer] += 1
return counts
-def grade(student, request, course, student_module_cache=None, keep_raw_scores=False):
+def grade(student, request, course, model_data_cache=None, keep_raw_scores=False):
"""
This grades a student as quickly as possible. It retuns the
output from the course grader, augmented with the final letter
@@ -140,8 +143,8 @@ def grade(student, request, course, student_module_cache=None, keep_raw_scores=F
grading_context = course.grading_context
raw_scores = []
- if student_module_cache is None:
- student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors'])
+ if model_data_cache is None:
+ model_data_cache = ModelDataCache(grading_context['all_descriptors'], course.id, student)
totaled_scores = {}
# This next complicated loop is just to collect the totaled_scores, which is
@@ -150,13 +153,20 @@ def grade(student, request, course, student_module_cache=None, keep_raw_scores=F
format_scores = []
for section in sections:
section_descriptor = section['section_descriptor']
- section_name = section_descriptor.metadata.get('display_name')
+ section_name = section_descriptor.display_name_with_default
should_grade_section = False
# If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0%
for moduledescriptor in section['xmoduledescriptors']:
- if student_module_cache.lookup(
- course.id, moduledescriptor.category, moduledescriptor.location.url()):
+ # Create a fake key to pull out a StudentModule object from the ModelDataCache
+
+ key = LmsKeyValueStore.Key(
+ Scope.student_state,
+ student.id,
+ moduledescriptor.location,
+ None
+ )
+ if model_data_cache.find(key):
should_grade_section = True
break
@@ -167,11 +177,11 @@ def grade(student, request, course, student_module_cache=None, keep_raw_scores=F
# TODO: We need the request to pass into here. If we could forgo that, our arguments
# would be simpler
return get_module(student, request, descriptor.location,
- student_module_cache, course.id)
+ model_data_cache, course.id)
for module_descriptor in yield_dynamic_descriptor_descendents(section_descriptor, create_module):
- (correct, total) = get_score(course.id, student, module_descriptor, create_module, student_module_cache)
+ (correct, total) = get_score(course.id, student, module_descriptor, create_module, model_data_cache)
if correct is None and total is None:
continue
@@ -181,12 +191,12 @@ def grade(student, request, course, student_module_cache=None, keep_raw_scores=F
else:
correct = total
- graded = module_descriptor.metadata.get("graded", False)
+ graded = module_descriptor.lms.graded
if not total > 0:
#We simply cannot grade a problem that is 12/0, because we might need it as a percentage
graded = False
- scores.append(Score(correct, total, graded, module_descriptor.metadata.get('display_name')))
+ scores.append(Score(correct, total, graded, module_descriptor.display_name_with_default))
section_total, graded_total = graders.aggregate_scores(scores, section_name)
if keep_raw_scores:
@@ -243,7 +253,7 @@ def grade_for_percentage(grade_cutoffs, percentage):
# TODO: This method is not very good. It was written in the old course style and
# then converted over and performance is not good. Once the progress page is redesigned
# to not have the progress summary this method should be deleted (so it won't be copied).
-def progress_summary(student, request, course, student_module_cache):
+def progress_summary(student, request, course, model_data_cache):
"""
This pulls a summary of all problems in the course.
@@ -257,7 +267,7 @@ def progress_summary(student, request, course, student_module_cache):
Arguments:
student: A User object for the student to grade
course: A Descriptor containing the course to grade
- student_module_cache: A StudentModuleCache initialized with all
+ model_data_cache: A ModelDataCache initialized with all
instance_modules for the student
If the student does not have access to load the course module, this function
@@ -269,7 +279,7 @@ def progress_summary(student, request, course, student_module_cache):
# TODO: We need the request to pass into here. If we could forgo that, our arguments
# would be simpler
course_module = get_module(student, request,
- course.location, student_module_cache,
+ course.location, model_data_cache,
course.id, depth=None)
if not course_module:
# This student must not have access to the course.
@@ -279,19 +289,17 @@ def progress_summary(student, request, course, student_module_cache):
# Don't include chapters that aren't displayable (e.g. due to error)
for chapter_module in course_module.get_display_items():
# Skip if the chapter is hidden
- hidden = chapter_module.metadata.get('hide_from_toc', 'false')
- if hidden.lower() == 'true':
+ if chapter_module.lms.hide_from_toc:
continue
sections = []
for section_module in chapter_module.get_display_items():
# Skip if the section is hidden
- hidden = section_module.metadata.get('hide_from_toc', 'false')
- if hidden.lower() == 'true':
+ if section_module.lms.hide_from_toc:
continue
# Same for sections
- graded = section_module.metadata.get('graded', False)
+ graded = section_module.lms.graded
scores = []
module_creator = section_module.system.get_module
@@ -299,37 +307,37 @@ def progress_summary(student, request, course, student_module_cache):
for module_descriptor in yield_dynamic_descriptor_descendents(section_module.descriptor, module_creator):
course_id = course.id
- (correct, total) = get_score(course_id, student, module_descriptor, module_creator, student_module_cache)
+ (correct, total) = get_score(course_id, student, module_descriptor, module_creator, model_data_cache)
if correct is None and total is None:
continue
scores.append(Score(correct, total, graded,
- module_descriptor.metadata.get('display_name')))
+ module_descriptor.display_name_with_default))
scores.reverse()
section_total, graded_total = graders.aggregate_scores(
- scores, section_module.metadata.get('display_name'))
+ scores, section_module.display_name_with_default)
- format = section_module.metadata.get('format', "")
+ format = section_module.lms.format if section_module.lms.format is not None else ''
sections.append({
- 'display_name': section_module.display_name,
+ 'display_name': section_module.display_name_with_default,
'url_name': section_module.url_name,
'scores': scores,
'section_total': section_total,
'format': format,
- 'due': section_module.metadata.get("due", ""),
+ 'due': section_module.lms.due,
'graded': graded,
})
- chapters.append({'course': course.display_name,
- 'display_name': chapter_module.display_name,
+ chapters.append({'course': course.display_name_with_default,
+ 'display_name': chapter_module.display_name_with_default,
'url_name': chapter_module.url_name,
'sections': sections})
return chapters
-def get_score(course_id, user, problem_descriptor, module_creator, student_module_cache):
+def get_score(course_id, user, problem_descriptor, module_creator, model_data_cache):
"""
Return the score for a user on a problem, as a tuple (correct, total).
e.g. (5,7) if you got 5 out of 7 points.
@@ -341,8 +349,11 @@ def get_score(course_id, user, problem_descriptor, module_creator, student_modul
problem_descriptor: an XModuleDescriptor
module_creator: a function that takes a descriptor, and returns the corresponding XModule for this user.
Can return None if user doesn't have access, or if something else went wrong.
- cache: A StudentModuleCache
+ cache: A ModelDataCache
"""
+ if not user.is_authenticated():
+ return (None, None)
+
if problem_descriptor.always_recalculate_grades:
problem = module_creator(problem_descriptor)
d = problem.get_score()
@@ -355,34 +366,42 @@ def get_score(course_id, user, problem_descriptor, module_creator, student_modul
# These are not problems, and do not have a score
return (None, None)
- correct = 0.0
+ # Create a fake KeyValueStore key to pull out the StudentModule
+ key = LmsKeyValueStore.Key(
+ Scope.student_state,
+ user.id,
+ problem_descriptor.location,
+ None
+ )
- instance_module = student_module_cache.lookup(
- course_id, problem_descriptor.category, problem_descriptor.location.url())
+ student_module = model_data_cache.find(key)
- if not instance_module:
- # If the problem was not in the cache, we need to instantiate the problem.
- # Otherwise, the max score (cached in instance_module) won't be available
+ if student_module is not None and student_module.max_grade is not None:
+ correct = student_module.grade if student_module.grade is not None else 0
+ total = student_module.max_grade
+ else:
+ # If the problem was not in the cache, or hasn't been graded yet,
+ # we need to instantiate the problem.
+ # Otherwise, the max score (cached in student_module) won't be available
problem = module_creator(problem_descriptor)
if problem is None:
return (None, None)
- instance_module = get_instance_module(course_id, user, problem, student_module_cache)
- # If this problem is ungraded/ungradable, bail
- if not instance_module or instance_module.max_grade is None:
- return (None, None)
+ correct = 0.0
+ total = problem.max_score()
- correct = instance_module.grade if instance_module.grade is not None else 0
- total = instance_module.max_grade
+ # Problem may be an error module (if something in the problem builder failed)
+ # In which case total might be None
+ if total is None:
+ return (None, None)
- if correct is not None and total is not None:
- #Now we re-weight the problem, if specified
- weight = getattr(problem_descriptor, 'weight', None)
- if weight is not None:
- if total == 0:
- log.exception("Cannot reweight a problem with zero total points. Problem: " + str(instance_module))
- return (correct, total)
- correct = correct * weight / total
- total = weight
+ #Now we re-weight the problem, if specified
+ weight = problem_descriptor.weight
+ if weight is not None:
+ if total == 0:
+ log.exception("Cannot reweight a problem with zero total points. Problem: " + str(student_module))
+ return (correct, total)
+ correct = correct * weight / total
+ total = weight
return (correct, total)
diff --git a/lms/djangoapps/courseware/management/commands/check_course.py b/lms/djangoapps/courseware/management/commands/check_course.py
index adb8bff709..58f8933cd8 100644
--- a/lms/djangoapps/courseware/management/commands/check_course.py
+++ b/lms/djangoapps/courseware/management/commands/check_course.py
@@ -13,7 +13,7 @@ import xmodule
import mitxmako.middleware as middleware
middleware.MakoMiddleware()
from xmodule.modulestore.django import modulestore
-from courseware.models import StudentModuleCache
+from courseware.model_data import ModelDataCache
from courseware.module_render import get_module
@@ -83,7 +83,7 @@ class Command(BaseCommand):
# TODO (cpennington): Get coursename in a legitimate way
course_location = 'i4x://edx/6002xs12/course/6.002_Spring_2012'
- student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
+ student_module_cache = ModelDataCache.cache_for_descriptor_descendents(
course_id,
sample_user, modulestore().get_item(course_location))
course = get_module(sample_user, None, course_location, student_module_cache)
diff --git a/lms/djangoapps/courseware/management/commands/metadata_to_json.py b/lms/djangoapps/courseware/management/commands/metadata_to_json.py
index b80736f693..58d087c316 100644
--- a/lms/djangoapps/courseware/management/commands/metadata_to_json.py
+++ b/lms/djangoapps/courseware/management/commands/metadata_to_json.py
@@ -53,7 +53,7 @@ def node_metadata(node):
'start', 'due', 'graded', 'hide_from_toc',
'ispublic', 'xqa_key')
- orig = node.own_metadata
+ orig = own_metadata(node)
d = {k: orig[k] for k in to_export if k in orig}
return d
diff --git a/lms/djangoapps/courseware/migrations/0008_add_xmodule_storage.py b/lms/djangoapps/courseware/migrations/0008_add_xmodule_storage.py
new file mode 100644
index 0000000000..0d89471fff
--- /dev/null
+++ b/lms/djangoapps/courseware/migrations/0008_add_xmodule_storage.py
@@ -0,0 +1,185 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+ # Adding model 'XModuleStudentInfoField'
+ db.create_table('courseware_xmodulestudentinfofield', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('field_name', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)),
+ ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+ ('student', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
+ ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)),
+ ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)),
+ ))
+ db.send_create_signal('courseware', ['XModuleStudentInfoField'])
+
+ # Adding unique constraint on 'XModuleStudentInfoField', fields ['student', 'field_name']
+ db.create_unique('courseware_xmodulestudentinfofield', ['student_id', 'field_name'])
+
+ # Adding model 'XModuleContentField'
+ db.create_table('courseware_xmodulecontentfield', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('field_name', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)),
+ ('definition_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
+ ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+ ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)),
+ ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)),
+ ))
+ db.send_create_signal('courseware', ['XModuleContentField'])
+
+ # Adding unique constraint on 'XModuleContentField', fields ['definition_id', 'field_name']
+ db.create_unique('courseware_xmodulecontentfield', ['definition_id', 'field_name'])
+
+ # Adding model 'XModuleSettingsField'
+ db.create_table('courseware_xmodulesettingsfield', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('field_name', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)),
+ ('usage_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
+ ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+ ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)),
+ ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)),
+ ))
+ db.send_create_signal('courseware', ['XModuleSettingsField'])
+
+ # Adding unique constraint on 'XModuleSettingsField', fields ['usage_id', 'field_name']
+ db.create_unique('courseware_xmodulesettingsfield', ['usage_id', 'field_name'])
+
+ # Adding model 'XModuleStudentPrefsField'
+ db.create_table('courseware_xmodulestudentprefsfield', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('field_name', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)),
+ ('module_type', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)),
+ ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
+ ('student', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
+ ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)),
+ ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)),
+ ))
+ db.send_create_signal('courseware', ['XModuleStudentPrefsField'])
+
+ # Adding unique constraint on 'XModuleStudentPrefsField', fields ['student', 'module_type', 'field_name']
+ db.create_unique('courseware_xmodulestudentprefsfield', ['student_id', 'module_type', 'field_name'])
+
+
+ def backwards(self, orm):
+ # Removing unique constraint on 'XModuleStudentPrefsField', fields ['student', 'module_type', 'field_name']
+ db.delete_unique('courseware_xmodulestudentprefsfield', ['student_id', 'module_type', 'field_name'])
+
+ # Removing unique constraint on 'XModuleSettingsField', fields ['usage_id', 'field_name']
+ db.delete_unique('courseware_xmodulesettingsfield', ['usage_id', 'field_name'])
+
+ # Removing unique constraint on 'XModuleContentField', fields ['definition_id', 'field_name']
+ db.delete_unique('courseware_xmodulecontentfield', ['definition_id', 'field_name'])
+
+ # Removing unique constraint on 'XModuleStudentInfoField', fields ['student', 'field_name']
+ db.delete_unique('courseware_xmodulestudentinfofield', ['student_id', 'field_name'])
+
+ # Deleting model 'XModuleStudentInfoField'
+ db.delete_table('courseware_xmodulestudentinfofield')
+
+ # Deleting model 'XModuleContentField'
+ db.delete_table('courseware_xmodulecontentfield')
+
+ # Deleting model 'XModuleSettingsField'
+ db.delete_table('courseware_xmodulesettingsfield')
+
+ # Deleting model 'XModuleStudentPrefsField'
+ db.delete_table('courseware_xmodulestudentprefsfield')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'courseware.studentmodule': {
+ 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'},
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}),
+ 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
+ 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}),
+ 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}),
+ 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+ },
+ 'courseware.xmodulecontentfield': {
+ 'Meta': {'unique_together': "(('definition_id', 'field_name'),)", 'object_name': 'XModuleContentField'},
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'definition_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+ },
+ 'courseware.xmodulesettingsfield': {
+ 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleSettingsField'},
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'usage_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+ },
+ 'courseware.xmodulestudentinfofield': {
+ 'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'},
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+ 'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+ },
+ 'courseware.xmodulestudentprefsfield': {
+ 'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'},
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'module_type': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
+ 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+ 'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'})
+ }
+ }
+
+ complete_apps = ['courseware']
\ No newline at end of file
diff --git a/lms/djangoapps/courseware/migrations/0009_add_field_default.py b/lms/djangoapps/courseware/migrations/0009_add_field_default.py
new file mode 100644
index 0000000000..cd885ee7a6
--- /dev/null
+++ b/lms/djangoapps/courseware/migrations/0009_add_field_default.py
@@ -0,0 +1,128 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+
+ # Changing field 'XModuleContentField.value'
+ db.alter_column('courseware_xmodulecontentfield', 'value', self.gf('django.db.models.fields.TextField')())
+
+ # Changing field 'XModuleStudentInfoField.value'
+ db.alter_column('courseware_xmodulestudentinfofield', 'value', self.gf('django.db.models.fields.TextField')())
+
+ # Changing field 'XModuleSettingsField.value'
+ db.alter_column('courseware_xmodulesettingsfield', 'value', self.gf('django.db.models.fields.TextField')())
+
+ # Changing field 'XModuleStudentPrefsField.value'
+ db.alter_column('courseware_xmodulestudentprefsfield', 'value', self.gf('django.db.models.fields.TextField')())
+
+ def backwards(self, orm):
+
+ # Changing field 'XModuleContentField.value'
+ db.alter_column('courseware_xmodulecontentfield', 'value', self.gf('django.db.models.fields.TextField')(null=True))
+
+ # Changing field 'XModuleStudentInfoField.value'
+ db.alter_column('courseware_xmodulestudentinfofield', 'value', self.gf('django.db.models.fields.TextField')(null=True))
+
+ # Changing field 'XModuleSettingsField.value'
+ db.alter_column('courseware_xmodulesettingsfield', 'value', self.gf('django.db.models.fields.TextField')(null=True))
+
+ # Changing field 'XModuleStudentPrefsField.value'
+ db.alter_column('courseware_xmodulestudentprefsfield', 'value', self.gf('django.db.models.fields.TextField')(null=True))
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'courseware.studentmodule': {
+ 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'},
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}),
+ 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
+ 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}),
+ 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}),
+ 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+ },
+ 'courseware.xmodulecontentfield': {
+ 'Meta': {'unique_together': "(('definition_id', 'field_name'),)", 'object_name': 'XModuleContentField'},
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'definition_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
+ },
+ 'courseware.xmodulesettingsfield': {
+ 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleSettingsField'},
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'usage_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
+ },
+ 'courseware.xmodulestudentinfofield': {
+ 'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'},
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+ 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
+ },
+ 'courseware.xmodulestudentprefsfield': {
+ 'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'},
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'module_type': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
+ 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+ 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
+ }
+ }
+
+ complete_apps = ['courseware']
\ No newline at end of file
diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py
new file mode 100644
index 0000000000..35deda5d6b
--- /dev/null
+++ b/lms/djangoapps/courseware/model_data.py
@@ -0,0 +1,360 @@
+import json
+from collections import namedtuple, defaultdict
+from itertools import chain
+from .models import (
+ StudentModule,
+ XModuleContentField,
+ XModuleSettingsField,
+ XModuleStudentPrefsField,
+ XModuleStudentInfoField
+)
+
+from xblock.runtime import KeyValueStore, InvalidScopeError
+from xblock.core import Scope
+
+
+class InvalidWriteError(Exception):
+ pass
+
+
+def chunks(items, chunk_size):
+ items = list(items)
+ return (items[i:i + chunk_size] for i in xrange(0, len(items), chunk_size))
+
+
+class ModelDataCache(object):
+ """
+ A cache of django model objects needed to supply the data
+ for a module and its decendants
+ """
+ def __init__(self, descriptors, course_id, user, select_for_update=False):
+ '''
+ Find any courseware.models objects that are needed by any descriptor
+ in descriptors. Attempts to minimize the number of queries to the database.
+ Note: Only modules that have store_state = True or have shared
+ state will have a StudentModule.
+
+ Arguments
+ descriptors: A list of XModuleDescriptors.
+ course_id: The id of the current course
+ user: The user for which to cache data
+ select_for_update: True if rows should be locked until end of transaction
+ '''
+ self.cache = {}
+ self.descriptors = descriptors
+ self.select_for_update = select_for_update
+ self.course_id = course_id
+ self.user = user
+
+ if user.is_authenticated():
+ for scope, fields in self._fields_to_cache().items():
+ for field_object in self._retrieve_fields(scope, fields):
+ self.cache[self._cache_key_from_field_object(scope, field_object)] = field_object
+
+ @classmethod
+ def cache_for_descriptor_descendents(cls, course_id, user, descriptor, depth=None,
+ descriptor_filter=lambda descriptor: True,
+ select_for_update=False):
+ """
+ course_id: the course in the context of which we want StudentModules.
+ user: the django user for whom to load modules.
+ descriptor: An XModuleDescriptor
+ depth is the number of levels of descendent modules to load StudentModules for, in addition to
+ the supplied descriptor. If depth is None, load all descendent StudentModules
+ descriptor_filter is a function that accepts a descriptor and return wether the StudentModule
+ should be cached
+ select_for_update: Flag indicating whether the rows should be locked until end of transaction
+ """
+
+ def get_child_descriptors(descriptor, depth, descriptor_filter):
+ if descriptor_filter(descriptor):
+ descriptors = [descriptor]
+ else:
+ descriptors = []
+
+ if depth is None or depth > 0:
+ new_depth = depth - 1 if depth is not None else depth
+
+ for child in descriptor.get_children() + descriptor.get_required_module_descriptors():
+ descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter))
+
+ return descriptors
+
+ descriptors = get_child_descriptors(descriptor, depth, descriptor_filter)
+
+ return ModelDataCache(descriptors, course_id, user, select_for_update)
+
+ def _query(self, model_class, **kwargs):
+ """
+ Queries model_class with **kwargs, optionally adding select_for_update if
+ self.select_for_update is set
+ """
+ query = model_class.objects
+ if self.select_for_update:
+ query = query.select_for_update()
+ query = query.filter(**kwargs)
+ return query
+
+ def _chunked_query(self, model_class, chunk_field, items, chunk_size=500, **kwargs):
+ """
+ Queries model_class with `chunk_field` set to chunks of size `chunk_size`,
+ and all other parameters from `**kwargs`
+
+ This works around a limitation in sqlite3 on the number of parameters
+ that can be put into a single query
+ """
+ res = chain.from_iterable(
+ self._query(model_class, **dict([(chunk_field, chunk)] + kwargs.items()))
+ for chunk in chunks(items, chunk_size)
+ )
+ return res
+
+ def _retrieve_fields(self, scope, fields):
+ """
+ Queries the database for all of the fields in the specified scope
+ """
+ if scope in (Scope.children, Scope.parent):
+ return []
+ elif scope == Scope.student_state:
+ return self._chunked_query(
+ StudentModule,
+ 'module_state_key__in',
+ (descriptor.location.url() for descriptor in self.descriptors),
+ course_id=self.course_id,
+ student=self.user,
+ )
+ elif scope == Scope.content:
+ return self._chunked_query(
+ XModuleContentField,
+ 'definition_id__in',
+ (descriptor.location.url() for descriptor in self.descriptors),
+ field_name__in=set(field.name for field in fields),
+ )
+ elif scope == Scope.settings:
+ return self._chunked_query(
+ XModuleSettingsField,
+ 'usage_id__in',
+ (
+ '%s-%s' % (self.course_id, descriptor.location.url())
+ for descriptor in self.descriptors
+ ),
+ field_name__in=set(field.name for field in fields),
+ )
+ elif scope == Scope.student_preferences:
+ return self._chunked_query(
+ XModuleStudentPrefsField,
+ 'module_type__in',
+ set(descriptor.location.category for descriptor in self.descriptors),
+ student=self.user,
+ field_name__in=set(field.name for field in fields),
+ )
+ elif scope == Scope.student_info:
+ return self._query(
+ XModuleStudentInfoField,
+ student=self.user,
+ field_name__in=set(field.name for field in fields),
+ )
+ else:
+ raise InvalidScopeError(scope)
+
+ def _fields_to_cache(self):
+ """
+ Returns a map of scopes to fields in that scope that should be cached
+ """
+ scope_map = defaultdict(set)
+ for descriptor in self.descriptors:
+ for field in (descriptor.module_class.fields + descriptor.module_class.lms.fields):
+ scope_map[field.scope].add(field)
+ return scope_map
+
+ def _cache_key_from_kvs_key(self, key):
+ if key.scope == Scope.student_state:
+ return (key.scope, key.block_scope_id.url())
+ elif key.scope == Scope.content:
+ return (key.scope, key.block_scope_id.url(), key.field_name)
+ elif key.scope == Scope.settings:
+ return (key.scope, '%s-%s' % (self.course_id, key.block_scope_id.url()), key.field_name)
+ elif key.scope == Scope.student_preferences:
+ return (key.scope, key.block_scope_id, key.field_name)
+ elif key.scope == Scope.student_info:
+ return (key.scope, key.field_name)
+
+ def _cache_key_from_field_object(self, scope, field_object):
+ if scope == Scope.student_state:
+ return (scope, field_object.module_state_key)
+ elif scope == Scope.content:
+ return (scope, field_object.definition_id, field_object.field_name)
+ elif scope == Scope.settings:
+ return (scope, field_object.usage_id, field_object.field_name)
+ elif scope == Scope.student_preferences:
+ return (scope, field_object.module_type, field_object.field_name)
+ elif scope == Scope.student_info:
+ return (scope, field_object.field_name)
+
+ def find(self, key):
+ '''
+ Look for a model data object using an LmsKeyValueStore.Key object
+
+ key: An `LmsKeyValueStore.Key` object selecting the object to find
+
+ returns the found object, or None if the object doesn't exist
+ '''
+ return self.cache.get(self._cache_key_from_kvs_key(key))
+
+ def find_or_create(self, key):
+ '''
+ Find a model data object in this cache, or create it if it doesn't
+ exist
+ '''
+ field_object = self.find(key)
+
+ if field_object is not None:
+ return field_object
+
+ if key.scope == Scope.student_state:
+ field_object, _ = StudentModule.objects.get_or_create(
+ course_id=self.course_id,
+ student=self.user,
+ module_type=key.block_scope_id.category,
+ module_state_key=key.block_scope_id.url(),
+ defaults={'state': json.dumps({})},
+ )
+ elif key.scope == Scope.content:
+ field_object, _ = XModuleContentField.objects.get_or_create(
+ field_name=key.field_name,
+ definition_id=key.block_scope_id.url()
+ )
+ elif key.scope == Scope.settings:
+ field_object, _ = XModuleSettingsField.objects.get_or_create(
+ field_name=key.field_name,
+ usage_id='%s-%s' % (self.course_id, key.block_scope_id.url()),
+ )
+ elif key.scope == Scope.student_preferences:
+ field_object, _= XModuleStudentPrefsField.objects.get_or_create(
+ field_name=key.field_name,
+ module_type=key.block_scope_id,
+ student=self.user,
+ )
+ elif key.scope == Scope.student_info:
+ field_object, _ = XModuleStudentInfoField.objects.get_or_create(
+ field_name=key.field_name,
+ student=self.user,
+ )
+
+ cache_key = self._cache_key_from_kvs_key(key)
+ self.cache[cache_key] = field_object
+ return field_object
+
+
+class LmsKeyValueStore(KeyValueStore):
+ """
+ This KeyValueStore will read data from descriptor_model_data if it exists,
+ but will not overwrite any keys set in descriptor_model_data. Attempts to do so will
+ raise an InvalidWriteError.
+
+ If the scope to write to is not one of the 5 named scopes:
+ Scope.content
+ Scope.settings
+ Scope.student_state
+ Scope.student_preferences
+ Scope.student_info
+ then an InvalidScopeError will be raised.
+
+ Data for Scope.student_state is stored as StudentModule objects via the django orm.
+
+ Data for the other scopes is stored in individual objects that are named for the
+ scope involved and have the field name as a key
+
+ If the key isn't found in the expected table during a read or a delete, then a KeyError will be raised
+ """
+
+ _allowed_scopes = (
+ Scope.content,
+ Scope.settings,
+ Scope.student_state,
+ Scope.student_preferences,
+ Scope.student_info,
+ Scope.children,
+ )
+ def __init__(self, descriptor_model_data, model_data_cache):
+ self._descriptor_model_data = descriptor_model_data
+ self._model_data_cache = model_data_cache
+
+ def get(self, key):
+ if key.field_name in self._descriptor_model_data:
+ return self._descriptor_model_data[key.field_name]
+
+ if key.scope == Scope.parent:
+ return None
+
+ if key.scope not in self._allowed_scopes:
+ raise InvalidScopeError(key.scope)
+
+ field_object = self._model_data_cache.find(key)
+ if field_object is None:
+ raise KeyError(key.field_name)
+
+ if key.scope == Scope.student_state:
+ return json.loads(field_object.state)[key.field_name]
+ else:
+ return json.loads(field_object.value)
+
+ def set(self, key, value):
+ if key.field_name in self._descriptor_model_data:
+ raise InvalidWriteError("Not allowed to overwrite descriptor model data", key.field_name)
+
+ field_object = self._model_data_cache.find_or_create(key)
+
+ if key.scope not in self._allowed_scopes:
+ raise InvalidScopeError(key.scope)
+
+ if key.scope == Scope.student_state:
+ state = json.loads(field_object.state)
+ state[key.field_name] = value
+ field_object.state = json.dumps(state)
+ else:
+ field_object.value = json.dumps(value)
+
+ field_object.save()
+
+ def delete(self, key):
+ if key.field_name in self._descriptor_model_data:
+ raise InvalidWriteError("Not allowed to deleted descriptor model data", key.field_name)
+
+ if key.scope not in self._allowed_scopes:
+ raise InvalidScopeError(key.scope)
+
+ field_object = self._model_data_cache.find(key)
+ if field_object is None:
+ raise KeyError(key.field_name)
+
+ if key.scope == Scope.student_state:
+ state = json.loads(field_object.state)
+ del state[key.field_name]
+ field_object.state = json.dumps(state)
+ field_object.save()
+ else:
+ field_object.delete()
+
+ def has(self, key):
+ if key.field_name in self._descriptor_model_data:
+ return key.field_name in self._descriptor_model_data
+
+ if key.scope == Scope.parent:
+ return True
+
+ if key.scope not in self._allowed_scopes:
+ raise InvalidScopeError(key.scope)
+
+ field_object = self._model_data_cache.find(key)
+ if field_object is None:
+ return False
+
+ if key.scope == Scope.student_state:
+ return key.field_name in json.loads(field_object.state)
+ else:
+ return True
+
+
+LmsUsage = namedtuple('LmsUsage', 'id, def_id')
+
diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py
index 3ad850f066..448757a2f8 100644
--- a/lms/djangoapps/courseware/models.py
+++ b/lms/djangoapps/courseware/models.py
@@ -57,9 +57,17 @@ class StudentModule(models.Model):
created = models.DateTimeField(auto_now_add=True, db_index=True)
modified = models.DateTimeField(auto_now=True, db_index=True)
+ def __repr__(self):
+ return 'StudentModule<%r>' % ({
+ 'course_id': self.course_id,
+ 'module_type': self.module_type,
+ 'student': self.student.username,
+ 'module_state_key': self.module_state_key,
+ 'state': str(self.state)[:20],
+ },)
+
def __unicode__(self):
- return '/'.join([self.course_id, self.module_type,
- self.student.username, self.module_state_key, str(self.state)[:20]])
+ return unicode(repr(self))
class StudentModuleHistory(models.Model):
@@ -93,121 +101,130 @@ class StudentModuleHistory(models.Model):
history_entry.save()
-# TODO (cpennington): Remove these once the LMS switches to using XModuleDescriptors
-
-
-class StudentModuleCache(object):
+class XModuleContentField(models.Model):
"""
- A cache of StudentModules for a specific student
+ Stores data set in the Scope.content scope by an xmodule field
"""
- def __init__(self, course_id, user, descriptors, select_for_update=False):
- '''
- Find any StudentModule objects that are needed by any descriptor
- in descriptors. Avoids making multiple queries to the database.
- Note: Only modules that have store_state = True or have shared
- state will have a StudentModule.
- Arguments
- user: The user for which to fetch maching StudentModules
- descriptors: An array of XModuleDescriptors.
- select_for_update: Flag indicating whether the rows should be locked until end of transaction
- '''
- if user.is_authenticated():
- module_ids = self._get_module_state_keys(descriptors)
+ class Meta:
+ unique_together = (('definition_id', 'field_name'),)
- # This works around a limitation in sqlite3 on the number of parameters
- # that can be put into a single query
- self.cache = []
- chunk_size = 500
- for id_chunk in [module_ids[i:i + chunk_size] for i in xrange(0, len(module_ids), chunk_size)]:
- if select_for_update:
- self.cache.extend(StudentModule.objects.select_for_update().filter(
- course_id=course_id,
- student=user,
- module_state_key__in=id_chunk)
- )
- else:
- self.cache.extend(StudentModule.objects.filter(
- course_id=course_id,
- student=user,
- module_state_key__in=id_chunk)
- )
+ # The name of the field
+ field_name = models.CharField(max_length=64, db_index=True)
- else:
- self.cache = []
+ # The definition id for the module
+ definition_id = models.CharField(max_length=255, db_index=True)
- @classmethod
- def cache_for_descriptor_descendents(cls, course_id, user, descriptor, depth=None,
- descriptor_filter=lambda descriptor: True,
- select_for_update=False):
- """
- obtain and return cache for descriptor descendents (ie children) AND modules required by the descriptor,
- but which are not children of the module
+ # The value of the field. Defaults to None dumped as json
+ value = models.TextField(default='null')
- course_id: the course in the context of which we want StudentModules.
- user: the django user for whom to load modules.
- descriptor: An XModuleDescriptor
- depth is the number of levels of descendent modules to load StudentModules for, in addition to
- the supplied descriptor. If depth is None, load all descendent StudentModules
- descriptor_filter is a function that accepts a descriptor and return wether the StudentModule
- should be cached
- select_for_update: Flag indicating whether the rows should be locked until end of transaction
- """
+ created = models.DateTimeField(auto_now_add=True, db_index=True)
+ modified = models.DateTimeField(auto_now=True, db_index=True)
- def get_child_descriptors(descriptor, depth, descriptor_filter):
- if descriptor_filter(descriptor):
- descriptors = [descriptor]
- else:
- descriptors = []
+ def __repr__(self):
+ return 'XModuleContentField<%r>' % ({
+ 'field_name': self.field_name,
+ 'definition_id': self.definition_id,
+ 'value': self.value,
+ },)
- if depth is None or depth > 0:
- new_depth = depth - 1 if depth is not None else depth
+ def __unicode__(self):
+ return unicode(repr(self))
- for child in descriptor.get_children() + descriptor.get_required_module_descriptors():
- descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter))
- return descriptors
+class XModuleSettingsField(models.Model):
+ """
+ Stores data set in the Scope.settings scope by an xmodule field
+ """
- descriptors = get_child_descriptors(descriptor, depth, descriptor_filter)
+ class Meta:
+ unique_together = (('usage_id', 'field_name'),)
- return StudentModuleCache(course_id, user, descriptors, select_for_update)
+ # The name of the field
+ field_name = models.CharField(max_length=64, db_index=True)
- def _get_module_state_keys(self, descriptors):
- '''
- Get a list of the state_keys needed for StudentModules
- required for this module descriptor
+ # The usage id for the module
+ usage_id = models.CharField(max_length=255, db_index=True)
- descriptor_filter is a function that accepts a descriptor and return wether the StudentModule
- should be cached
- '''
- keys = []
- for descriptor in descriptors:
- if descriptor.stores_state:
- keys.append(descriptor.location.url())
+ # The value of the field. Defaults to None, dumped as json
+ value = models.TextField(default='null')
- shared_state_key = getattr(descriptor, 'shared_state_key', None)
- if shared_state_key is not None:
- keys.append(shared_state_key)
+ created = models.DateTimeField(auto_now_add=True, db_index=True)
+ modified = models.DateTimeField(auto_now=True, db_index=True)
- return keys
+ def __repr__(self):
+ return 'XModuleSettingsField<%r>' % ({
+ 'field_name': self.field_name,
+ 'usage_id': self.usage_id,
+ 'value': self.value,
+ },)
- def lookup(self, course_id, module_type, module_state_key):
- '''
- Look for a student module with the given course_id, type, and id in the cache.
+ def __unicode__(self):
+ return unicode(repr(self))
- cache -- list of student modules
- returns first found object, or None
- '''
- for o in self.cache:
- if (o.course_id == course_id and
- o.module_type == module_type and
- o.module_state_key == module_state_key):
- return o
- return None
+class XModuleStudentPrefsField(models.Model):
+ """
+ Stores data set in the Scope.student_preferences scope by an xmodule field
+ """
- def append(self, student_module):
- self.cache.append(student_module)
+ class Meta:
+ unique_together = (('student', 'module_type', 'field_name'),)
+
+ # The name of the field
+ field_name = models.CharField(max_length=64, db_index=True)
+
+ # The type of the module for these preferences
+ module_type = models.CharField(max_length=64, db_index=True)
+
+ # The value of the field. Defaults to None dumped as json
+ value = models.TextField(default='null')
+
+ student = models.ForeignKey(User, db_index=True)
+
+ created = models.DateTimeField(auto_now_add=True, db_index=True)
+ modified = models.DateTimeField(auto_now=True, db_index=True)
+
+ def __repr__(self):
+ return 'XModuleStudentPrefsField<%r>' % ({
+ 'field_name': self.field_name,
+ 'module_type': self.module_type,
+ 'student': self.student.username,
+ 'value': self.value,
+ },)
+
+ def __unicode__(self):
+ return unicode(repr(self))
+
+
+class XModuleStudentInfoField(models.Model):
+ """
+ Stores data set in the Scope.student_preferences scope by an xmodule field
+ """
+
+ class Meta:
+ unique_together = (('student', 'field_name'),)
+
+ # The name of the field
+ field_name = models.CharField(max_length=64, db_index=True)
+
+ # The value of the field. Defaults to None dumped as json
+ value = models.TextField(default='null')
+
+ student = models.ForeignKey(User, db_index=True)
+
+ created = models.DateTimeField(auto_now_add=True, db_index=True)
+ modified = models.DateTimeField(auto_now=True, db_index=True)
+
+ def __repr__(self):
+ return 'XModuleStudentInfoField<%r>' % ({
+ 'field_name': self.field_name,
+ 'student': self.student.username,
+ 'value': self.value,
+ },)
+
+ def __unicode__(self):
+ return unicode(repr(self))
class OfflineComputedGrade(models.Model):
diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index ec2178f642..08df7bfb8c 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -18,7 +18,7 @@ from requests.auth import HTTPBasicAuth
from capa.xqueue_interface import XQueueInterface
from courseware.access import has_access
from mitxmako.shortcuts import render_to_string
-from models import StudentModule, StudentModuleCache
+from .models import StudentModule
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
from student.models import unique_id_for_user
from xmodule.errortracker import exc_info_to_str
@@ -27,7 +27,9 @@ from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.x_module import ModuleSystem
from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor
+from xblock.runtime import DbModel
from xmodule_modifiers import replace_course_urls, replace_static_urls, add_histogram, wrap_xmodule
+from .model_data import LmsKeyValueStore, LmsUsage, ModelDataCache
from xmodule.modulestore.exceptions import ItemNotFoundError
from statsd import statsd
@@ -59,7 +61,7 @@ def make_track_function(request):
return f
-def toc_for_course(user, request, course, active_chapter, active_section):
+def toc_for_course(user, request, course, active_chapter, active_section, model_data_cache):
'''
Create a table of contents from the module store
@@ -79,19 +81,17 @@ def toc_for_course(user, request, course, active_chapter, active_section):
NOTE: assumes that if we got this far, user has access to course. Returns
None if this is not the case.
+
+ model_data_cache must include data from the course module and 2 levels of its descendents
'''
- student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
- course.id, user, course, depth=2)
- course_module = get_module_for_descriptor(user, request, course,
- student_module_cache, course.id)
+ course_module = get_module_for_descriptor(user, request, course, model_data_cache, course.id)
if course_module is None:
return None
chapters = list()
for chapter in course_module.get_display_items():
- hide_from_toc = chapter.metadata.get('hide_from_toc', 'false').lower() == 'true'
- if hide_from_toc:
+ if chapter.lms.hide_from_toc:
continue
sections = list()
@@ -99,27 +99,26 @@ def toc_for_course(user, request, course, active_chapter, active_section):
active = (chapter.url_name == active_chapter and
section.url_name == active_section)
- hide_from_toc = section.metadata.get('hide_from_toc', 'false').lower() == 'true'
- if not hide_from_toc:
- sections.append({'display_name': section.display_name,
+ if not section.lms.hide_from_toc:
+ sections.append({'display_name': section.display_name_with_default,
'url_name': section.url_name,
- 'format': section.metadata.get('format', ''),
- 'due': section.metadata.get('due', ''),
+ 'format': section.lms.format if section.lms.format is not None else '',
+ 'due': section.lms.due,
'active': active,
- 'graded': section.metadata.get('graded', False),
+ 'graded': section.lms.graded,
})
- chapters.append({'display_name': chapter.display_name,
+ chapters.append({'display_name': chapter.display_name_with_default,
'url_name': chapter.url_name,
'sections': sections,
'active': chapter.url_name == active_chapter})
return chapters
-def get_module(user, request, location, student_module_cache, course_id,
- position=None, not_found_ok=False, wrap_xmodule_display=True,
- depth=0):
+def get_module(user, request, location, model_data_cache, course_id,
+ position=None, not_found_ok = False, wrap_xmodule_display=True,
+ grade_bucket_type=None, depth=0):
"""
Get an instance of the xmodule class identified by location,
setting the state based on an existing StudentModule, or creating one if none
@@ -130,7 +129,7 @@ def get_module(user, request, location, student_module_cache, course_id,
- request : current django HTTPrequest. Note: request.user isn't used for anything--all auth
and such works based on user.
- location : A Location-like object identifying the module to load
- - student_module_cache : a StudentModuleCache
+ - model_data_cache : a ModelDataCache
- course_id : the course_id in the context of which to load module
- position : extra information from URL for user-specified
position within module
@@ -144,9 +143,10 @@ def get_module(user, request, location, student_module_cache, course_id,
try:
location = Location(location)
descriptor = modulestore().get_instance(course_id, location, depth=depth)
- return get_module_for_descriptor(user, request, descriptor, student_module_cache, course_id,
- position=position, not_found_ok=not_found_ok,
- wrap_xmodule_display=wrap_xmodule_display)
+ return get_module_for_descriptor(user, request, descriptor, model_data_cache, course_id,
+ position=position,
+ wrap_xmodule_display=wrap_xmodule_display,
+ grade_bucket_type=grade_bucket_type)
except ItemNotFoundError:
if not not_found_ok:
log.exception("Error in get_module")
@@ -157,17 +157,8 @@ def get_module(user, request, location, student_module_cache, course_id,
return None
-def get_module_for_descriptor(user, request, descriptor, student_module_cache, course_id,
- position=None, not_found_ok=False, wrap_xmodule_display=True):
- """
- Actually implement get_module. See docstring there for details.
- """
- return _get_module(user, request, descriptor, student_module_cache, course_id,
- position=position, wrap_xmodule_display=wrap_xmodule_display)
-
-
-def _get_module(user, request, descriptor, student_module_cache, course_id,
- position=None, wrap_xmodule_display=True):
+def get_module_for_descriptor(user, request, descriptor, model_data_cache, course_id,
+ position=None, wrap_xmodule_display=True, grade_bucket_type=None):
"""
Actually implement get_module. See docstring there for details.
"""
@@ -176,23 +167,6 @@ def _get_module(user, request, descriptor, student_module_cache, course_id,
if not has_access(user, descriptor, 'load', course_id):
return None
- # Only check the cache if this module can possibly have state
- instance_module = None
- shared_module = None
- if user.is_authenticated():
- if descriptor.stores_state:
- instance_module = student_module_cache.lookup(
- course_id, descriptor.category, descriptor.location.url())
-
- shared_state_key = getattr(descriptor, 'shared_state_key', None)
- if shared_state_key is not None:
- shared_module = student_module_cache.lookup(course_id,
- descriptor.category,
- shared_state_key)
-
- instance_state = instance_module.state if instance_module is not None else None
- shared_state = shared_module.state if shared_module is not None else None
-
# Setup system context for module instance
ajax_url = reverse('modx_dispatch',
kwargs=dict(course_id=course_id,
@@ -254,7 +228,44 @@ def _get_module(user, request, descriptor, student_module_cache, course_id,
Delegate to get_module. It does an access check, so may return None
"""
return get_module_for_descriptor(user, request, descriptor,
- student_module_cache, course_id, position)
+ model_data_cache, course_id, position)
+
+ def xblock_model_data(descriptor):
+ return DbModel(
+ LmsKeyValueStore(descriptor._model_data, model_data_cache),
+ descriptor.module_class,
+ user.id,
+ LmsUsage(descriptor.location, descriptor.location)
+ )
+
+ def publish(event):
+ if event.get('event_name') != 'grade':
+ return
+
+ student_module, created = StudentModule.objects.get_or_create(
+ course_id=course_id,
+ student=user,
+ module_type=descriptor.location.category,
+ module_state_key=descriptor.location.url(),
+ defaults={'state': '{}'},
+ )
+ student_module.grade = event.get('value')
+ student_module.max_grade = event.get('max_value')
+ student_module.save()
+
+ #Bin score into range and increment stats
+ score_bucket = get_score_bucket(student_module.grade, student_module.max_grade)
+ org, course_num, run = course_id.split("/")
+
+ tags = ["org:{0}".format(org),
+ "course:{0}".format(course_num),
+ "run:{0}".format(run),
+ "score_bucket:{0}".format(score_bucket)]
+
+ if grade_bucket_type is not None:
+ tags.append('type:%s' % grade_bucket_type)
+
+ statsd.increment("lms.courseware.question_answered", tags=tags)
# TODO (cpennington): When modules are shared between courses, the static
# prefix is going to have to be specific to the module, not the directory
@@ -272,10 +283,12 @@ def _get_module(user, request, descriptor, student_module_cache, course_id,
# by the replace_static_urls code below
replace_urls=partial(
static_replace.replace_static_urls,
- data_directory=descriptor.metadata.get('data_dir', ''),
+ data_directory=getattr(descriptor, 'data_dir', None),
course_namespace=descriptor.location._replace(category=None, name=None),
),
node_path=settings.NODE_PATH,
+ xblock_model_data=xblock_model_data,
+ publish=publish,
anonymous_student_id=unique_id_for_user(user),
course_id=course_id,
open_ended_grading_interface=open_ended_grading_interface,
@@ -284,26 +297,29 @@ def _get_module(user, request, descriptor, student_module_cache, course_id,
# pass position specified in URL to module through ModuleSystem
system.set('position', position)
system.set('DEBUG', settings.DEBUG)
- if settings.MITX_FEATURES.get('ENABLE_PSYCHOMETRICS') and instance_module is not None:
+ if settings.MITX_FEATURES.get('ENABLE_PSYCHOMETRICS'):
system.set('psychometrics_handler', # set callback for updating PsychometricsData
- make_psychometrics_data_update_handler(instance_module))
+ make_psychometrics_data_update_handler(course_id, user, descriptor.location.url()))
try:
- module = descriptor.xmodule_constructor(system)(instance_state, shared_state)
+ module = descriptor.xmodule(system)
except:
log.exception("Error creating module from descriptor {0}".format(descriptor))
# make an ErrorDescriptor -- assuming that the descriptor's system is ok
- import_system = descriptor.system
if has_access(user, descriptor.location, 'staff', course_id):
- err_descriptor = ErrorDescriptor.from_xml(str(descriptor), import_system,
- error_msg=exc_info_to_str(sys.exc_info()))
+ err_descriptor_class = ErrorDescriptor
else:
- err_descriptor = NonStaffErrorDescriptor.from_xml(str(descriptor), import_system,
- error_msg=exc_info_to_str(sys.exc_info()))
+ err_descriptor_class = NonStaffErrorDescriptor
+
+ err_descriptor = err_descriptor_class.from_xml(
+ str(descriptor), descriptor.system,
+ org=descriptor.location.org, course=descriptor.location.course,
+ error_msg=exc_info_to_str(sys.exc_info())
+ )
# Make an error module
- return err_descriptor.xmodule_constructor(system)(None, None)
+ return err_descriptor.xmodule(system)
system.set('user_is_staff', has_access(user, descriptor.location, 'staff', course_id))
_get_html = module.get_html
@@ -313,7 +329,7 @@ def _get_module(user, request, descriptor, student_module_cache, course_id,
module.get_html = replace_static_urls(
_get_html,
- module.metadata.get('data_dir', ''),
+ getattr(descriptor, 'data_dir', None),
course_namespace=module.location._replace(category=None, name=None))
# Allow URLs of the form '/course/' refer to the root of multicourse directory
@@ -326,69 +342,6 @@ def _get_module(user, request, descriptor, student_module_cache, course_id,
return module
-# TODO (vshnayder): Rename this? It's very confusing.
-
-
-def get_instance_module(course_id, user, module, student_module_cache):
- """
- Returns the StudentModule specific to this module for this student,
- or None if this is an anonymous user
- """
- if user.is_authenticated():
- if not module.descriptor.stores_state:
- log.exception("Attempted to get the instance_module for a module "
- + str(module.id) + " which does not store state.")
- return None
-
- instance_module = student_module_cache.lookup(
- course_id, module.category, module.location.url())
-
- if not instance_module:
- instance_module = StudentModule(
- course_id=course_id,
- student=user,
- module_type=module.category,
- module_state_key=module.id,
- state=module.get_instance_state(),
- max_grade=module.max_score())
- instance_module.save()
- student_module_cache.append(instance_module)
-
- return instance_module
- else:
- return None
-
-
-def get_shared_instance_module(course_id, user, module, student_module_cache):
- """
- Return shared_module is a StudentModule specific to all modules with the same
- 'shared_state_key' attribute, or None if the module does not elect to
- share state
- """
- if user.is_authenticated():
- # To get the shared_state_key, we need to descriptor
- descriptor = modulestore().get_instance(course_id, module.location)
-
- shared_state_key = getattr(module, 'shared_state_key', None)
- if shared_state_key is not None:
- shared_module = student_module_cache.lookup(module.category,
- shared_state_key)
- if not shared_module:
- shared_module = StudentModule(
- course_id=course_id,
- student=user,
- module_type=descriptor.category,
- module_state_key=shared_state_key,
- state=module.get_shared_state())
- shared_module.save()
- student_module_cache.append(shared_module)
- else:
- shared_module = None
-
- return shared_module
- else:
- return None
-
@csrf_exempt
def xqueue_callback(request, course_id, userid, id, dispatch):
@@ -409,22 +362,13 @@ def xqueue_callback(request, course_id, userid, id, dispatch):
# Retrieve target StudentModule
user = User.objects.get(id=userid)
- student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id,
+ model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id,
user, modulestore().get_instance(course_id, id), depth=0, select_for_update=True)
- instance = get_module(user, request, id, student_module_cache, course_id)
+ instance = get_module(user, request, id, model_data_cache, course_id, grade_bucket_type='xqueue')
if instance is None:
log.debug("No module {0} for user {1}--access denied?".format(id, user))
raise Http404
- instance_module = get_instance_module(course_id, user, instance, student_module_cache)
-
- if instance_module is None:
- log.debug("Couldn't find instance of module '%s' for user '%s'", id, user)
- raise Http404
-
- oldgrade = instance_module.grade
- old_instance_state = instance_module.state
-
# Transfer 'queuekey' from xqueue response header to 'get'. This is required to
# use the interface defined by 'handle_ajax'
get.update({'queuekey': header['lms_key']})
@@ -438,22 +382,6 @@ def xqueue_callback(request, course_id, userid, id, dispatch):
log.exception("error processing ajax call")
raise
- # Save state back to database
- instance_module.state = instance.get_instance_state()
- if instance.get_score():
- instance_module.grade = instance.get_score()['score']
- if instance_module.grade != oldgrade or instance_module.state != old_instance_state:
- instance_module.save()
-
- #Bin score into range and increment stats
- score_bucket = get_score_bucket(instance_module.grade, instance_module.max_grade)
- org, course_num, run = course_id.split("/")
- statsd.increment("lms.courseware.question_answered",
- tags=["org:{0}".format(org),
- "course:{0}".format(course_num),
- "run:{0}".format(run),
- "score_bucket:{0}".format(score_bucket),
- "type:xqueue"])
return HttpResponse("")
@@ -493,29 +421,16 @@ def modx_dispatch(request, dispatch, location, course_id):
return HttpResponse(json.dumps({'success': file_too_big_msg}))
p[fileinput_id] = inputfiles
- student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id,
+ model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id,
request.user, modulestore().get_instance(course_id, location))
- instance = get_module(request.user, request, location, student_module_cache, course_id)
+ instance = get_module(request.user, request, location, model_data_cache, course_id, grade_bucket_type='ajax')
if instance is None:
# Either permissions just changed, or someone is trying to be clever
# and load something they shouldn't have access to.
log.debug("No module {0} for user {1}--access denied?".format(location, request.user))
raise Http404
- instance_module = get_instance_module(course_id, request.user, instance, student_module_cache)
- shared_module = get_shared_instance_module(course_id, request.user, instance, student_module_cache)
-
- # Don't track state for anonymous users (who don't have student modules)
- if instance_module is not None:
- oldgrade = instance_module.grade
- # The max grade shouldn't change under normal circumstances, but
- # sometimes the problem changes with the same name but a new max grade.
- # This updates the module if that happens.
- old_instance_max_grade = instance_module.max_grade
- old_instance_state = instance_module.state
- old_shared_state = shared_module.state if shared_module is not None else None
-
# Let the module handle the AJAX
try:
ajax_return = instance.handle_ajax(dispatch, p)
@@ -526,34 +441,6 @@ def modx_dispatch(request, dispatch, location, course_id):
log.exception("error processing ajax call")
raise
- # Save the state back to the database
- # Don't track state for anonymous users (who don't have student modules)
- if instance_module is not None:
- instance_module.state = instance.get_instance_state()
- instance_module.max_grade = instance.max_score()
- if instance.get_score():
- instance_module.grade = instance.get_score()['score']
- if (instance_module.grade != oldgrade or
- instance_module.state != old_instance_state or
- instance_module.max_grade != old_instance_max_grade):
- instance_module.save()
-
- #Bin score into range and increment stats
- score_bucket = get_score_bucket(instance_module.grade, instance_module.max_grade)
- org, course_num, run = course_id.split("/")
- statsd.increment("lms.courseware.question_answered",
- tags=["org:{0}".format(org),
- "course:{0}".format(course_num),
- "run:{0}".format(run),
- "score_bucket:{0}".format(score_bucket),
- "type:ajax"])
-
-
- if shared_module is not None:
- shared_module.state = instance.get_shared_state()
- if shared_module.state != old_shared_state:
- shared_module.save()
-
# Return whatever the module wanted to return to the client/caller
return HttpResponse(ajax_return)
diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py
index 8e3b5874f0..7b3f76c101 100644
--- a/lms/djangoapps/courseware/tabs.py
+++ b/lms/djangoapps/courseware/tabs.py
@@ -21,13 +21,14 @@ from fs.errors import ResourceNotFoundError
from courseware.access import has_access
from lxml.html import rewrite_links
-from module_render import get_module
+from .module_render import get_module
from courseware.access import has_access
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.xml import XMLModuleStore
from xmodule.x_module import XModule
from student.models import unique_id_for_user
+from courseware.model_data import ModelDataCache
from open_ended_grading import open_ended_notifications
@@ -352,10 +353,12 @@ def get_static_tab_by_slug(course, tab_slug):
return None
-def get_static_tab_contents(request, cache, course, tab):
+def get_static_tab_contents(request, course, tab):
loc = Location(course.location.tag, course.location.org, course.location.course, 'static_tab', tab['url_slug'])
- tab_module = get_module(request.user, request, loc, cache, course.id)
+ model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course.id,
+ request.user, modulestore().get_instance(course.id, loc), depth=0)
+ tab_module = get_module(request.user, request, loc, model_data_cache, course.id)
logging.debug('course_module = {0}'.format(tab_module))
diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py
index acb05d5d78..c1bb9f203e 100644
--- a/lms/djangoapps/courseware/tests/test_access.py
+++ b/lms/djangoapps/courseware/tests/test_access.py
@@ -12,7 +12,7 @@ from xmodule.modulestore import Location
from xmodule.timeparse import parse_time
from xmodule.x_module import XModule, XModuleDescriptor
import courseware.access as access
-from factories import CourseEnrollmentAllowedFactory
+from .factories import CourseEnrollmentAllowedFactory
class AccessTestCase(TestCase):
@@ -88,7 +88,6 @@ class AccessTestCase(TestCase):
yesterday = time.gmtime(time.time() - 86400)
tomorrow = time.gmtime(time.time() + 86400)
c = Mock(enrollment_start=yesterday, enrollment_end=tomorrow)
- c.metadata.get = 'is_public'
# User can enroll if it is between the start and end dates
self.assertTrue(access._has_access_course_desc(u, c, 'enroll'))
@@ -99,7 +98,6 @@ class AccessTestCase(TestCase):
u.is_authenticated.return_value = True
c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/2012_Fall')
- c.metadata.get = 'is_public'
allowed = CourseEnrollmentAllowedFactory(email=u.email, course_id=c.id)
@@ -110,7 +108,6 @@ class AccessTestCase(TestCase):
u.is_authenticated.return_value = True
c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/Whenever')
- c.metadata.get = 'is_public'
self.assertTrue(access._has_access_course_desc(u, c, 'enroll'))
# TODO:
diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py
new file mode 100644
index 0000000000..7f4727cf15
--- /dev/null
+++ b/lms/djangoapps/courseware/tests/test_model_data.py
@@ -0,0 +1,298 @@
+import factory
+import json
+from mock import Mock
+from django.contrib.auth.models import User
+
+from functools import partial
+
+from courseware.model_data import LmsKeyValueStore, InvalidWriteError, InvalidScopeError, ModelDataCache
+from courseware.models import StudentModule, XModuleContentField, XModuleSettingsField, XModuleStudentInfoField, XModuleStudentPrefsField
+from xblock.core import Scope, BlockScope
+from xmodule.modulestore import Location
+
+from django.test import TestCase
+
+
+def mock_field(scope, name):
+ field = Mock()
+ field.scope = scope
+ field.name = name
+ return field
+
+def mock_descriptor(fields=[], lms_fields=[]):
+ descriptor = Mock()
+ descriptor.stores_state = True
+ descriptor.location = location('def_id')
+ descriptor.module_class.fields = fields
+ descriptor.module_class.lms.fields = lms_fields
+ return descriptor
+
+location = partial(Location, 'i4x', 'edX', 'test_course', 'problem')
+course_id = 'edX/test_course/test'
+
+content_key = partial(LmsKeyValueStore.Key, Scope.content, None, location('def_id'))
+settings_key = partial(LmsKeyValueStore.Key, Scope.settings, None, location('def_id'))
+student_state_key = partial(LmsKeyValueStore.Key, Scope.student_state, 'user', location('def_id'))
+student_prefs_key = partial(LmsKeyValueStore.Key, Scope.student_preferences, 'user', 'problem')
+student_info_key = partial(LmsKeyValueStore.Key, Scope.student_info, 'user', None)
+
+
+class UserFactory(factory.Factory):
+ FACTORY_FOR = User
+
+ username = 'user'
+
+
+class StudentModuleFactory(factory.Factory):
+ FACTORY_FOR = StudentModule
+
+ module_type = 'problem'
+ module_state_key = location('def_id').url()
+ student = factory.SubFactory(UserFactory)
+ course_id = course_id
+ state = None
+
+
+class ContentFactory(factory.Factory):
+ FACTORY_FOR = XModuleContentField
+
+ field_name = 'existing_field'
+ value = json.dumps('old_value')
+ definition_id = location('def_id').url()
+
+
+class SettingsFactory(factory.Factory):
+ FACTORY_FOR = XModuleSettingsField
+
+ field_name = 'existing_field'
+ value = json.dumps('old_value')
+ usage_id = '%s-%s' % (course_id, location('def_id').url())
+
+
+class StudentPrefsFactory(factory.Factory):
+ FACTORY_FOR = XModuleStudentPrefsField
+
+ field_name = 'existing_field'
+ value = json.dumps('old_value')
+ student = factory.SubFactory(UserFactory)
+ module_type = 'problem'
+
+
+class StudentInfoFactory(factory.Factory):
+ FACTORY_FOR = XModuleStudentInfoField
+
+ field_name = 'existing_field'
+ value = json.dumps('old_value')
+ student = factory.SubFactory(UserFactory)
+
+
+class TestDescriptorFallback(TestCase):
+
+ def setUp(self):
+ self.desc_md = {
+ 'field_a': 'content',
+ 'field_b': 'settings',
+ }
+ self.kvs = LmsKeyValueStore(self.desc_md, None)
+
+ def test_get_from_descriptor(self):
+ self.assertEquals('content', self.kvs.get(content_key('field_a')))
+ self.assertEquals('settings', self.kvs.get(settings_key('field_b')))
+
+ def test_write_to_descriptor(self):
+ self.assertRaises(InvalidWriteError, self.kvs.set, content_key('field_a'), 'foo')
+ self.assertEquals('content', self.desc_md['field_a'])
+ self.assertRaises(InvalidWriteError, self.kvs.set, settings_key('field_b'), 'foo')
+ self.assertEquals('settings', self.desc_md['field_b'])
+
+ self.assertRaises(InvalidWriteError, self.kvs.delete, content_key('field_a'))
+ self.assertEquals('content', self.desc_md['field_a'])
+ self.assertRaises(InvalidWriteError, self.kvs.delete, settings_key('field_b'))
+ self.assertEquals('settings', self.desc_md['field_b'])
+
+
+class TestInvalidScopes(TestCase):
+ def setUp(self):
+ self.desc_md = {}
+ self.user = UserFactory.create()
+ self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.student_state, 'a_field')])], course_id, self.user)
+ self.kvs = LmsKeyValueStore(self.desc_md, self.mdc)
+
+ def test_invalid_scopes(self):
+ for scope in (Scope(student=True, block=BlockScope.DEFINITION),
+ Scope(student=False, block=BlockScope.TYPE),
+ Scope(student=False, block=BlockScope.ALL)):
+ self.assertRaises(InvalidScopeError, self.kvs.get, LmsKeyValueStore.Key(scope, None, None, 'field'))
+ self.assertRaises(InvalidScopeError, self.kvs.set, LmsKeyValueStore.Key(scope, None, None, 'field'), 'value')
+ self.assertRaises(InvalidScopeError, self.kvs.delete, LmsKeyValueStore.Key(scope, None, None, 'field'))
+ self.assertRaises(InvalidScopeError, self.kvs.has, LmsKeyValueStore.Key(scope, None, None, 'field'))
+
+
+class TestStudentModuleStorage(TestCase):
+
+ def setUp(self):
+ self.desc_md = {}
+ student_module = StudentModuleFactory(state=json.dumps({'a_field': 'a_value'}))
+ self.user = student_module.student
+ self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.student_state, 'a_field')])], course_id, self.user)
+ self.kvs = LmsKeyValueStore(self.desc_md, self.mdc)
+
+ def test_get_existing_field(self):
+ "Test that getting an existing field in an existing StudentModule works"
+ self.assertEquals('a_value', self.kvs.get(student_state_key('a_field')))
+
+ def test_get_missing_field(self):
+ "Test that getting a missing field from an existing StudentModule raises a KeyError"
+ self.assertRaises(KeyError, self.kvs.get, student_state_key('not_a_field'))
+
+ def test_set_existing_field(self):
+ "Test that setting an existing student_state field changes the value"
+ self.kvs.set(student_state_key('a_field'), 'new_value')
+ self.assertEquals(1, StudentModule.objects.all().count())
+ self.assertEquals({'a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state))
+
+ def test_set_missing_field(self):
+ "Test that setting a new student_state field changes the value"
+ self.kvs.set(student_state_key('not_a_field'), 'new_value')
+ self.assertEquals(1, StudentModule.objects.all().count())
+ self.assertEquals({'a_field': 'a_value', 'not_a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state))
+
+ def test_delete_existing_field(self):
+ "Test that deleting an existing field removes it from the StudentModule"
+ self.kvs.delete(student_state_key('a_field'))
+ self.assertEquals(1, StudentModule.objects.all().count())
+ self.assertRaises(KeyError, self.kvs.get, student_state_key('not_a_field'))
+
+ def test_delete_missing_field(self):
+ "Test that deleting a missing field from an existing StudentModule raises a KeyError"
+ self.assertRaises(KeyError, self.kvs.delete, student_state_key('not_a_field'))
+ self.assertEquals(1, StudentModule.objects.all().count())
+ self.assertEquals({'a_field': 'a_value'}, json.loads(StudentModule.objects.all()[0].state))
+
+ def test_has_existing_field(self):
+ "Test that `has` returns True for existing fields in StudentModules"
+ self.assertTrue(self.kvs.has(student_state_key('a_field')))
+
+ def test_has_missing_field(self):
+ "Test that `has` returns False for missing fields in StudentModule"
+ self.assertFalse(self.kvs.has(student_state_key('not_a_field')))
+
+
+class TestMissingStudentModule(TestCase):
+ def setUp(self):
+ self.user = UserFactory.create()
+ self.desc_md = {}
+ self.mdc = ModelDataCache([mock_descriptor()], course_id, self.user)
+ self.kvs = LmsKeyValueStore(self.desc_md, self.mdc)
+
+ def test_get_field_from_missing_student_module(self):
+ "Test that getting a field from a missing StudentModule raises a KeyError"
+ self.assertRaises(KeyError, self.kvs.get, student_state_key('a_field'))
+
+ def test_set_field_in_missing_student_module(self):
+ "Test that setting a field in a missing StudentModule creates the student module"
+ self.assertEquals(0, len(self.mdc.cache))
+ self.assertEquals(0, StudentModule.objects.all().count())
+
+ self.kvs.set(student_state_key('a_field'), 'a_value')
+
+ self.assertEquals(1, len(self.mdc.cache))
+ self.assertEquals(1, StudentModule.objects.all().count())
+
+ student_module = StudentModule.objects.all()[0]
+ self.assertEquals({'a_field': 'a_value'}, json.loads(student_module.state))
+ self.assertEquals(self.user, student_module.student)
+ self.assertEquals(location('def_id').url(), student_module.module_state_key)
+ self.assertEquals(course_id, student_module.course_id)
+
+ def test_delete_field_from_missing_student_module(self):
+ "Test that deleting a field from a missing StudentModule raises a KeyError"
+ self.assertRaises(KeyError, self.kvs.delete, student_state_key('a_field'))
+
+ def test_has_field_for_missing_student_module(self):
+ "Test that `has` returns False for missing StudentModules"
+ self.assertFalse(self.kvs.has(student_state_key('a_field')))
+
+
+class StorageTestBase(object):
+ factory = None
+ scope = None
+ key_factory = None
+ storage_class = None
+
+ def setUp(self):
+ field_storage = self.factory.create()
+ if hasattr(field_storage, 'student'):
+ self.user = field_storage.student
+ else:
+ self.user = UserFactory.create()
+ self.desc_md = {}
+ self.mdc = ModelDataCache([mock_descriptor([mock_field(self.scope, 'existing_field')])], course_id, self.user)
+ self.kvs = LmsKeyValueStore(self.desc_md, self.mdc)
+
+ def test_get_existing_field(self):
+ "Test that getting an existing field in an existing Storage Field works"
+ self.assertEquals('old_value', self.kvs.get(self.key_factory('existing_field')))
+
+ def test_get_missing_field(self):
+ "Test that getting a missing field from an existing Storage Field raises a KeyError"
+ self.assertRaises(KeyError, self.kvs.get, self.key_factory('missing_field'))
+
+ def test_set_existing_field(self):
+ "Test that setting an existing field changes the value"
+ self.kvs.set(self.key_factory('existing_field'), 'new_value')
+ self.assertEquals(1, self.storage_class.objects.all().count())
+ self.assertEquals('new_value', json.loads(self.storage_class.objects.all()[0].value))
+
+ def test_set_missing_field(self):
+ "Test that setting a new field changes the value"
+ self.kvs.set(self.key_factory('missing_field'), 'new_value')
+ self.assertEquals(2, self.storage_class.objects.all().count())
+ self.assertEquals('old_value', json.loads(self.storage_class.objects.get(field_name='existing_field').value))
+ self.assertEquals('new_value', json.loads(self.storage_class.objects.get(field_name='missing_field').value))
+
+ def test_delete_existing_field(self):
+ "Test that deleting an existing field removes it"
+ self.kvs.delete(self.key_factory('existing_field'))
+ self.assertEquals(0, self.storage_class.objects.all().count())
+
+ def test_delete_missing_field(self):
+ "Test that deleting a missing field from an existing Storage Field raises a KeyError"
+ self.assertRaises(KeyError, self.kvs.delete, self.key_factory('missing_field'))
+ self.assertEquals(1, self.storage_class.objects.all().count())
+
+ def test_has_existing_field(self):
+ "Test that `has` returns True for an existing Storage Field"
+ self.assertTrue(self.kvs.has(self.key_factory('existing_field')))
+
+ def test_has_missing_field(self):
+ "Test that `has` return False for an existing Storage Field"
+ self.assertFalse(self.kvs.has(self.key_factory('missing_field')))
+
+
+class TestSettingsStorage(StorageTestBase, TestCase):
+ factory = SettingsFactory
+ scope = Scope.settings
+ key_factory = settings_key
+ storage_class = XModuleSettingsField
+
+
+class TestContentStorage(StorageTestBase, TestCase):
+ factory = ContentFactory
+ scope = Scope.content
+ key_factory = content_key
+ storage_class = XModuleContentField
+
+
+class TestStudentPrefsStorage(StorageTestBase, TestCase):
+ factory = StudentPrefsFactory
+ scope = Scope.student_preferences
+ key_factory = student_prefs_key
+ storage_class = XModuleStudentPrefsField
+
+
+class TestStudentInfoStorage(StorageTestBase, TestCase):
+ factory = StudentInfoFactory
+ scope = Scope.student_info
+ key_factory = student_info_key
+ storage_class = XModuleStudentInfoField
diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py
index 81f95a85e4..3a3a7ac5ea 100644
--- a/lms/djangoapps/courseware/tests/test_module_render.py
+++ b/lms/djangoapps/courseware/tests/test_module_render.py
@@ -15,7 +15,6 @@ from django.test.client import RequestFactory
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
-from courseware.models import StudentModule, StudentModuleCache
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.exceptions import NotFoundError
from xmodule.modulestore import Location
@@ -24,8 +23,9 @@ from xmodule.modulestore.django import modulestore, _MODULESTORES
from xmodule.seq_module import SequenceModule
from courseware.tests.tests import PageLoader
from student.models import Registration
+from courseware.model_data import ModelDataCache
-from factories import UserFactory
+from .factories import UserFactory
class Stub:
@@ -60,18 +60,6 @@ class ModuleRenderTestCase(PageLoader):
self.assertIsNone(render.get_module('dummyuser', None,
'invalid location', None, None))
- def test_get_instance_module(self):
- mock_user = MagicMock()
- mock_user.is_authenticated.return_value = False
- self.assertIsNone(render.get_instance_module('dummy', mock_user, 'dummy',
- 'dummy'))
- mock_user_2 = MagicMock()
- mock_user_2.is_authenticated.return_value = True
- mock_module = MagicMock()
- mock_module.descriptor.stores_state = False
- self.assertIsNone(render.get_instance_module('dummy', mock_user_2,
- mock_module, 'dummy'))
-
def test_modx_dispatch(self):
self.assertRaises(Http404, render.modx_dispatch, 'dummy', 'dummy',
'invalid Location', 'dummy')
@@ -133,6 +121,8 @@ class TestTOC(TestCase):
chapter_url = '%s/%s/%s' % ('/courses', self.course_name, chapter)
factory = RequestFactory()
request = factory.get(chapter_url)
+ model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
+ self.toy_course.id, self.portal_user, self.toy_course, depth=2)
expected = ([{'active': True, 'sections':
[{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True,
@@ -149,7 +139,7 @@ class TestTOC(TestCase):
'format': '', 'due': '', 'active': False}],
'url_name': 'secret:magic', 'display_name': 'secret:magic'}])
- actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, None)
+ actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, None, model_data_cache)
self.assertEqual(expected, actual)
def test_toc_toy_from_section(self):
@@ -158,6 +148,8 @@ class TestTOC(TestCase):
section = 'Welcome'
factory = RequestFactory()
request = factory.get(chapter_url)
+ model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
+ self.toy_course.id, self.portal_user, self.toy_course, depth=2)
expected = ([{'active': True, 'sections':
[{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True,
@@ -174,5 +166,5 @@ class TestTOC(TestCase):
'format': '', 'due': '', 'active': False}],
'url_name': 'secret:magic', 'display_name': 'secret:magic'}])
- actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, section)
+ actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, section, model_data_cache)
self.assertEqual(expected, actual)
diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py
index eeac999813..979f347d1f 100644
--- a/lms/djangoapps/courseware/tests/test_views.py
+++ b/lms/djangoapps/courseware/tests/test_views.py
@@ -19,7 +19,7 @@ from xmodule.modulestore.exceptions import InvalidLocationError,\
import courseware.views as views
from xmodule.modulestore import Location
-from factories import UserFactory
+from .factories import UserFactory
class Stub():
@@ -107,7 +107,7 @@ class ViewsTestCase(TestCase):
mock_module.position = 3
mock_module.get_display_items.return_value = []
self.assertRaises(Http404, views.redirect_to_course_position,
- mock_module, True)
+ mock_module)
def test_registered_for_course(self):
self.assertFalse(views.registered_for_course('Basketweaving', None))
diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py
index 7e00baf61f..cd845b1e44 100644
--- a/lms/djangoapps/courseware/tests/tests.py
+++ b/lms/djangoapps/courseware/tests/tests.py
@@ -19,9 +19,9 @@ from xmodule.modulestore.mongo import MongoModuleStore
# Need access to internal func to put users in the right group
from courseware import grades
+from courseware.model_data import ModelDataCache
from courseware.access import (has_access, _course_staff_group_name,
course_beta_test_group_name)
-from courseware.models import StudentModuleCache
from student.models import Registration
from xmodule.error_module import ErrorDescriptor
@@ -325,7 +325,7 @@ class PageLoader(ActivateLoginTestCase):
num_bad += 1
elif isinstance(descriptor, ErrorDescriptor):
msg = "ERROR error descriptor loaded: "
- msg = msg + descriptor.definition['data']['error_msg']
+ msg = msg + descriptor.error_msg
all_ok = False
num_bad += 1
@@ -560,8 +560,8 @@ class TestViewAuth(PageLoader):
# Make courses start in the future
tomorrow = time.time() + 24 * 3600
- self.toy.metadata['start'] = stringify_time(time.gmtime(tomorrow))
- self.full.metadata['start'] = stringify_time(time.gmtime(tomorrow))
+ self.toy.lms.start = time.gmtime(tomorrow)
+ self.full.lms.start = time.gmtime(tomorrow)
self.assertFalse(self.toy.has_started())
self.assertFalse(self.full.has_started())
@@ -730,11 +730,11 @@ class TestViewAuth(PageLoader):
yesterday = time.time() - 24 * 3600
# toy course's hasn't started
- self.toy.metadata['start'] = stringify_time(time.gmtime(tomorrow))
+ self.toy.lms.start = time.gmtime(tomorrow)
self.assertFalse(self.toy.has_started())
# but should be accessible for beta testers
- self.toy.metadata['days_early_for_beta'] = '2'
+ self.toy.lms.days_early_for_beta = 2
# student user shouldn't see it
student_user = user(self.student)
@@ -778,27 +778,27 @@ class TestCourseGrader(PageLoader):
self.factory = RequestFactory()
def get_grade_summary(self):
- student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
+ model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
self.graded_course.id, self.student_user, self.graded_course)
fake_request = self.factory.get(reverse('progress',
kwargs={'course_id': self.graded_course.id}))
return grades.grade(self.student_user, fake_request,
- self.graded_course, student_module_cache)
+ self.graded_course, model_data_cache)
def get_homework_scores(self):
return self.get_grade_summary()['totaled_scores']['Homework']
def get_progress_summary(self):
- student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
+ model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
self.graded_course.id, self.student_user, self.graded_course)
fake_request = self.factory.get(reverse('progress',
kwargs={'course_id': self.graded_course.id}))
progress_summary = grades.progress_summary(self.student_user, fake_request,
- self.graded_course, student_module_cache)
+ self.graded_course, model_data_cache)
return progress_summary
def check_grade_percent(self, percent):
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index ceb559d59e..e75ef8e8cf 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -21,8 +21,9 @@ from courseware.access import has_access
from courseware.courses import (get_courses, get_course_with_access,
get_courses_by_university, sort_by_announcement)
import courseware.tabs as tabs
-from courseware.models import StudentModule, StudentModuleCache, StudentModuleHistory
-from module_render import toc_for_course, get_module, get_instance_module, get_module_for_descriptor
+from courseware.model_data import ModelDataCache
+from .module_render import toc_for_course, get_module_for_descriptor, get_module
+from courseware.models import StudentModule, StudentModuleHistory
from django_comment_client.utils import get_discussion_title
@@ -40,7 +41,6 @@ log = logging.getLogger("mitx.courseware")
template_imports = {'urllib': urllib}
-
def user_groups(user):
"""
TODO (vshnayder): This is not used. When we have a new plan for groups, adjust appropriately.
@@ -76,7 +76,7 @@ def courses(request):
return render_to_response("courseware/courses.html", {'courses': courses})
-def render_accordion(request, course, chapter, section):
+def render_accordion(request, course, chapter, section, model_data_cache):
''' Draws navigation bar. Takes current position in accordion as
parameter.
@@ -88,7 +88,7 @@ def render_accordion(request, course, chapter, section):
# grab the table of contents
user = User.objects.prefetch_related("groups").get(id=request.user.id)
- toc = toc_for_course(user, request, course, chapter, section)
+ toc = toc_for_course(user, request, course, chapter, section, model_data_cache)
context = dict([('toc', toc),
('course_id', course.id),
@@ -99,16 +99,21 @@ def render_accordion(request, course, chapter, section):
def get_current_child(xmodule):
"""
Get the xmodule.position's display item of an xmodule that has a position and
- children. Returns None if the xmodule doesn't have a position, or if there
- are no children. Otherwise, if position is out of bounds, returns the first child.
+ children. If xmodule has no position or is out of bounds, return the first child.
+ Returns None only if there are no children at all.
"""
if not hasattr(xmodule, 'position'):
return None
+ if xmodule.position is None:
+ pos = 0
+ else:
+ # position is 1-indexed.
+ pos = xmodule.position - 1
+
children = xmodule.get_display_items()
- # position is 1-indexed.
- if 0 <= xmodule.position - 1 < len(children):
- child = children[xmodule.position - 1]
+ if 0 <= pos < len(children):
+ child = children[pos]
elif len(children) > 0:
# Something is wrong. Default to first child
child = children[0]
@@ -117,114 +122,114 @@ def get_current_child(xmodule):
return child
-def redirect_to_course_position(course_module, first_time):
+def redirect_to_course_position(course_module):
"""
- Load the course state for the user, and return a redirect to the
- appropriate place in the course: either the first element if there
- is no state, or their previous place if there is.
+ Return a redirect to the user's current place in the course.
+
+ If this is the user's first time, redirects to COURSE/CHAPTER/SECTION.
+ If this isn't the users's first time, redirects to COURSE/CHAPTER,
+ and the view will find the current section and display a message
+ about reusing the stored position.
+
+ If there is no current position in the course or chapter, then selects
+ the first child.
- If this is the user's first time, send them to the first section instead.
"""
- course_id = course_module.descriptor.id
+ urlargs = {'course_id': course_module.descriptor.id}
chapter = get_current_child(course_module)
if chapter is None:
# oops. Something bad has happened.
- raise Http404
- if not first_time:
- return redirect(reverse('courseware_chapter', kwargs={'course_id': course_id,
- 'chapter': chapter.url_name}))
+ raise Http404("No chapter found when loading current position in course")
+
+ urlargs['chapter'] = chapter.url_name
+ if course_module.position is not None:
+ return redirect(reverse('courseware_chapter', kwargs=urlargs))
+
# Relying on default of returning first child
section = get_current_child(chapter)
- return redirect(reverse('courseware_section', kwargs={'course_id': course_id,
- 'chapter': chapter.url_name,
- 'section': section.url_name}))
+ if section is None:
+ raise Http404("No section found when loading current position in course")
+
+ urlargs['section'] = section.url_name
+ return redirect(reverse('courseware_section', kwargs=urlargs))
-def save_child_position(seq_module, child_name, instance_module):
+def save_child_position(seq_module, child_name):
"""
child_name: url_name of the child
- instance_module: the StudentModule object for the seq_module
"""
- for i, c in enumerate(seq_module.get_display_items()):
+ for position, c in enumerate(seq_module.get_display_items(), start=1):
if c.url_name == child_name:
- # Position is 1-indexed
- position = i + 1
# Only save if position changed
if position != seq_module.position:
seq_module.position = position
- instance_module.state = seq_module.get_instance_state()
- instance_module.save()
+
def check_for_active_timelimit_module(request, course_id, course):
'''
Looks for a timing module for the given user and course that is currently active.
If found, returns a context dict with timer-related values to enable display of time remaining.
- '''
+ '''
context = {}
+
+ # TODO (cpennington): Once we can query the course structure, replace this with such a query
timelimit_student_modules = StudentModule.objects.filter(student=request.user, course_id=course_id, module_type='timelimit')
if timelimit_student_modules:
- for timelimit_student_module in timelimit_student_modules:
+ for timelimit_student_module in timelimit_student_modules:
# get the corresponding section_descriptor for the given StudentModel entry:
module_state_key = timelimit_student_module.module_state_key
timelimit_descriptor = modulestore().get_instance(course_id, Location(module_state_key))
- timelimit_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course.id, request.user,
+ timelimit_module_cache = ModelDataCache.cache_for_descriptor_descendents(course.id, request.user,
timelimit_descriptor, depth=None)
- timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor,
- timelimit_module_cache, course.id, position=None)
+ timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor,
+ timelimit_module_cache, course.id, position=None)
if timelimit_module is not None and timelimit_module.category == 'timelimit' and \
timelimit_module.has_begun and not timelimit_module.has_ended:
location = timelimit_module.location
# determine where to go when the timer expires:
- if 'time_expired_redirect_url' not in timelimit_descriptor.metadata:
- raise Http404("No {0} metadata at this location: {1} ".format('time_expired_redirect_url', location))
- time_expired_redirect_url = timelimit_descriptor.metadata.get('time_expired_redirect_url')
- context['time_expired_redirect_url'] = time_expired_redirect_url
+ if timelimit_descriptor.time_expired_redirect_url is None:
+ raise Http404("no time_expired_redirect_url specified at this location: {} ".format(timelimit_module.location))
+ context['time_expired_redirect_url'] = timelimit_descriptor.time_expired_redirect_url
# Fetch the remaining time relative to the end time as stored in the module when it was started.
# This value should be in milliseconds.
remaining_time = timelimit_module.get_remaining_time_in_ms()
context['timer_expiration_duration'] = remaining_time
- if 'suppress_toplevel_navigation' in timelimit_descriptor.metadata:
- context['suppress_toplevel_navigation'] = timelimit_descriptor.metadata['suppress_toplevel_navigation']
- return_url = reverse('jump_to', kwargs={'course_id':course_id, 'location':location})
+ context['suppress_toplevel_navigation'] = timelimit_descriptor.suppress_toplevel_navigation
+ return_url = reverse('jump_to', kwargs={'course_id': course_id, 'location': location})
context['timer_navigation_return_url'] = return_url
return context
-def update_timelimit_module(user, course_id, student_module_cache, timelimit_descriptor, timelimit_module):
+
+def update_timelimit_module(user, course_id, model_data_cache, timelimit_descriptor, timelimit_module):
'''
Updates the state of the provided timing module, starting it if it hasn't begun.
Returns dict with timer-related values to enable display of time remaining.
Returns 'timer_expiration_duration' in dict if timer is still active, and not if timer has expired.
- '''
+ '''
context = {}
# determine where to go when the exam ends:
- if 'time_expired_redirect_url' not in timelimit_descriptor.metadata:
- raise Http404("No {0} metadata at this location: {1} ".format('time_expired_redirect_url', timelimit_module.location))
- time_expired_redirect_url = timelimit_descriptor.metadata.get('time_expired_redirect_url')
- context['time_expired_redirect_url'] = time_expired_redirect_url
+ if timelimit_descriptor.time_expired_redirect_url is None:
+ raise Http404("No time_expired_redirect_url specified at this location: {} ".format(timelimit_module.location))
+ context['time_expired_redirect_url'] = timelimit_descriptor.time_expired_redirect_url
if not timelimit_module.has_ended:
if not timelimit_module.has_begun:
# user has not started the exam, so start it now.
- if 'duration' not in timelimit_descriptor.metadata:
- raise Http404("No {0} metadata at this location: {1} ".format('duration', timelimit_module.location))
+ if timelimit_descriptor.duration is None:
+ raise Http404("No duration specified at this location: {} ".format(timelimit_module.location))
# The user may have an accommodation that has been granted to them.
# This accommodation information should already be stored in the module's state.
- duration = int(timelimit_descriptor.metadata.get('duration'))
- timelimit_module.begin(duration)
- # we have changed state, so we need to persist the change:
- instance_module = get_instance_module(course_id, user, timelimit_module, student_module_cache)
- instance_module.state = timelimit_module.get_instance_state()
- instance_module.save()
-
+ timelimit_module.begin(timelimit_descriptor.duration)
+
# the exam has been started, either because the student is returning to the
# exam page, or because they have just visited it. Fetch the remaining time relative to the
# end time as stored in the module when it was started.
context['timer_expiration_duration'] = timelimit_module.get_remaining_time_in_ms()
# also use the timed module to determine whether top-level navigation is visible:
- if 'suppress_toplevel_navigation' in timelimit_descriptor.metadata:
- context['suppress_toplevel_navigation'] = timelimit_descriptor.metadata['suppress_toplevel_navigation']
+ context['suppress_toplevel_navigation'] = timelimit_descriptor.suppress_toplevel_navigation
return context
+
@login_required
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@@ -262,26 +267,22 @@ def index(request, course_id, chapter=None, section=None,
return redirect(reverse('about_course', args=[course.id]))
try:
- student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
+ model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
course.id, user, course, depth=2)
- # Has this student been in this course before?
- first_time = student_module_cache.lookup(course_id, 'course', course.location.url()) is None
-
- # Load the module for the course
- course_module = get_module_for_descriptor(user, request, course, student_module_cache, course.id)
+ course_module = get_module_for_descriptor(user, request, course, model_data_cache, course.id)
if course_module is None:
log.warning('If you see this, something went wrong: if we got this'
' far, should have gotten a course module for this user')
return redirect(reverse('about_course', args=[course.id]))
if chapter is None:
- return redirect_to_course_position(course_module, first_time)
+ return redirect_to_course_position(course_module)
context = {
'csrf': csrf(request)['csrf_token'],
- 'accordion': render_accordion(request, course, chapter, section),
- 'COURSE_TITLE': course.title,
+ 'accordion': render_accordion(request, course, chapter, section, model_data_cache),
+ 'COURSE_TITLE': course.display_name_with_default,
'course': course,
'init': '',
'content': '',
@@ -291,8 +292,7 @@ def index(request, course_id, chapter=None, section=None,
chapter_descriptor = course.get_child_by(lambda m: m.url_name == chapter)
if chapter_descriptor is not None:
- instance_module = get_instance_module(course_id, user, course_module, student_module_cache)
- save_child_position(course_module, chapter, instance_module)
+ save_child_position(course_module, chapter)
else:
raise Http404('No chapter descriptor found with name {}'.format(chapter))
@@ -313,23 +313,23 @@ def index(request, course_id, chapter=None, section=None,
# Load all descendants of the section, because we're going to display its
# html, which in general will need all of its children
- section_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
- course.id, user, section_descriptor, depth=None)
+ section_model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
+ course_id, user, section_descriptor, depth=None)
+ section_module = get_module(request.user, request,
+ section_descriptor.location,
+ section_model_data_cache, course_id, position, depth=None)
- section_module = get_module(user, request, section_descriptor.location,
- section_module_cache, course.id, position=position, depth=None)
if section_module is None:
# User may be trying to be clever and access something
# they don't have access to.
raise Http404
# Save where we are in the chapter
- instance_module = get_instance_module(course_id, user, chapter_module, student_module_cache)
- save_child_position(chapter_module, section, instance_module)
+ save_child_position(chapter_module, section)
- # check here if this section *is* a timed module.
+ # check here if this section *is* a timed module.
if section_module.category == 'timelimit':
- timer_context = update_timelimit_module(user, course_id, student_module_cache,
+ timer_context = update_timelimit_module(user, course_id, student_module_cache,
section_descriptor, section_module)
if 'timer_expiration_duration' in timer_context:
context.update(timer_context)
@@ -337,10 +337,10 @@ def index(request, course_id, chapter=None, section=None,
# if there is no expiration defined, then we know the timer has expired:
return HttpResponseRedirect(timer_context['time_expired_redirect_url'])
else:
- # check here if this page is within a course that has an active timed module running. If so, then
+ # check here if this page is within a course that has an active timed module running. If so, then
# add in the appropriate timer information to the rendering context:
context.update(check_for_active_timelimit_module(request, course_id, course))
-
+
context['content'] = section_module.get_html()
else:
# section is none, so display a message
@@ -453,7 +453,11 @@ def static_tab(request, course_id, tab_slug):
if tab is None:
raise Http404
- contents = tabs.get_static_tab_contents(request, None, course, tab)
+ contents = tabs.get_static_tab_contents(
+ request,
+ course,
+ tab
+ )
if contents is None:
raise Http404
@@ -594,12 +598,12 @@ def progress(request, course_id, student_id=None):
# additional DB lookup (this kills the Progress page in particular).
student = User.objects.prefetch_related("groups").get(id=student.id)
- student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
+ model_data_cache = ModelDataCache.cache_for_descriptor_descendents(
course_id, student, course, depth=None)
courseware_summary = grades.progress_summary(student, request, course,
- student_module_cache)
- grade_summary = grades.grade(student, request, course, student_module_cache)
+ model_data_cache)
+ grade_summary = grades.grade(student, request, course, model_data_cache)
if courseware_summary is None:
#This means the student didn't have access to the course (which the instructor requested)
@@ -617,9 +621,9 @@ def progress(request, course_id, student_id=None):
@login_required
def submission_history(request, course_id, student_username, location):
- """Render an HTML fragment (meant for inclusion elsewhere) that renders a
+ """Render an HTML fragment (meant for inclusion elsewhere) that renders a
history of all state changes made by this user for this problem location.
- Right now this only works for problems because that's all
+ Right now this only works for problems because that's all
StudentModuleHistory records.
"""
course = get_course_with_access(request.user, course_id, 'load')
diff --git a/lms/djangoapps/django_comment_client/base/urls.py b/lms/djangoapps/django_comment_client/base/urls.py
index d8fd4927fb..92826a18ae 100644
--- a/lms/djangoapps/django_comment_client/base/urls.py
+++ b/lms/djangoapps/django_comment_client/base/urls.py
@@ -12,6 +12,8 @@ urlpatterns = patterns('django_comment_client.base.views',
url(r'threads/(?P[\w\-]+)/upvote$', 'vote_for_thread', {'value': 'up'}, name='upvote_thread'),
url(r'threads/(?P[\w\-]+)/downvote$', 'vote_for_thread', {'value': 'down'}, name='downvote_thread'),
url(r'threads/(?P[\w\-]+)/unvote$', 'undo_vote_for_thread', name='undo_vote_for_thread'),
+ url(r'threads/(?P[\w\-]+)/pin$', 'pin_thread', name='pin_thread'),
+ url(r'threads/(?P[\w\-]+)/unpin$', 'un_pin_thread', name='un_pin_thread'),
url(r'threads/(?P[\w\-]+)/follow$', 'follow_thread', name='follow_thread'),
url(r'threads/(?P[\w\-]+)/unfollow$', 'unfollow_thread', name='unfollow_thread'),
url(r'threads/(?P[\w\-]+)/close$', 'openclose_thread', name='openclose_thread'),
diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py
index d93ea19f44..69609dcf01 100644
--- a/lms/djangoapps/django_comment_client/base/views.py
+++ b/lms/djangoapps/django_comment_client/base/views.py
@@ -72,12 +72,12 @@ def create_thread(request, course_id, commentable_id):
course = get_course_with_access(request.user, course_id, 'load')
post = request.POST
- if course.metadata.get("allow_anonymous", True):
+ if course.allow_anonymous:
anonymous = post.get('anonymous', 'false').lower() == 'true'
else:
anonymous = False
- if course.metadata.get("allow_anonymous_to_peers", False):
+ if course.allow_anonymous_to_peers:
anonymous_to_peers = post.get('anonymous_to_peers', 'false').lower() == 'true'
else:
anonymous_to_peers = False
@@ -96,7 +96,7 @@ def create_thread(request, course_id, commentable_id):
#kevinchugh because the new requirement is that all groups will be determined
#by the group id in the request this all goes away
#not anymore, only for admins
-
+
# Cohort the thread if the commentable is cohorted.
if is_commentable_cohorted(course_id, commentable_id):
user_group_id = get_cohort_id(user, course_id)
@@ -113,9 +113,13 @@ def create_thread(request, course_id, commentable_id):
if group_id:
thread.update_attributes(group_id=group_id)
-
+
thread.save()
+ #patch for backward compatibility to comments service
+ if not 'pinned' in thread.attributes:
+ thread['pinned'] = False
+
if post.get('auto_subscribe', 'false').lower() == 'true':
user = cc.User.from_django_user(request.user)
user.follow(thread)
@@ -147,12 +151,12 @@ def _create_comment(request, course_id, thread_id=None, parent_id=None):
comment = cc.Comment(**extract(post, ['body']))
course = get_course_with_access(request.user, course_id, 'load')
- if course.metadata.get("allow_anonymous", True):
+ if course.allow_anonymous:
anonymous = post.get('anonymous', 'false').lower() == 'true'
else:
anonymous = False
- if course.metadata.get("allow_anonymous_to_peers", False):
+ if course.allow_anonymous_to_peers:
anonymous_to_peers = post.get('anonymous_to_peers', 'false').lower() == 'true'
else:
anonymous_to_peers = False
@@ -289,6 +293,21 @@ def undo_vote_for_thread(request, course_id, thread_id):
user.unvote(thread)
return JsonResponse(utils.safe_content(thread.to_dict()))
+@require_POST
+@login_required
+@permitted
+def pin_thread(request, course_id, thread_id):
+ user = cc.User.from_django_user(request.user)
+ thread = cc.Thread.find(thread_id)
+ thread.pin(user,thread_id)
+ return JsonResponse(utils.safe_content(thread.to_dict()))
+
+def un_pin_thread(request, course_id, thread_id):
+ user = cc.User.from_django_user(request.user)
+ thread = cc.Thread.find(thread_id)
+ thread.un_pin(user,thread_id)
+ return JsonResponse(utils.safe_content(thread.to_dict()))
+
@require_POST
@login_required
diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py
index a3120c563a..3eee0948da 100644
--- a/lms/djangoapps/django_comment_client/forum/views.py
+++ b/lms/djangoapps/django_comment_client/forum/views.py
@@ -91,12 +91,18 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
#now add the group name if the thread has a group id
for thread in threads:
+
if thread.get('group_id'):
thread['group_name'] = get_cohort_by_id(course_id, thread.get('group_id')).name
thread['group_string'] = "This post visible only to Group %s." % (thread['group_name'])
else:
thread['group_name'] = ""
thread['group_string'] = "This post visible to everyone."
+
+ #patch for backward compatibility to comments service
+ if not 'pinned' in thread:
+ thread['pinned'] = False
+
query_params['page'] = page
query_params['num_pages'] = num_pages
@@ -124,31 +130,31 @@ def inline_discussion(request, course_id, discussion_id):
annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info)
- allow_anonymous = course.metadata.get("allow_anonymous", True)
- allow_anonymous_to_peers = course.metadata.get("allow_anonymous_to_peers", False)
+ allow_anonymous = course.allow_anonymous
+ allow_anonymous_to_peers = course.allow_anonymous_to_peers
#since inline is all one commentable, only show or allow the choice of cohorts
#if the commentable is cohorted, otherwise everything is not cohorted
#and no one has the option of choosing a cohort
is_cohorted = is_course_cohorted(course_id) and is_commentable_cohorted(course_id, discussion_id)
is_moderator = cached_has_permission(request.user, "see_all_cohorts", course_id)
-
+
cohorts_list = list()
-
+
if is_cohorted:
cohorts_list.append({'name':'All Groups','id':None})
-
+
#if you're a mod, send all cohorts and let you pick
-
+
if is_moderator:
cohorts = get_course_cohorts(course_id)
for c in cohorts:
cohorts_list.append({'name':c.name, 'id':c.id})
-
+
else:
#students don't get to choose
cohorts_list = None
-
+
return utils.JsonResponse({
'discussion_data': map(utils.safe_content, threads),
'user_info': user_info,
@@ -210,6 +216,9 @@ def forum_form_discussion(request, course_id):
user_cohort_id = get_cohort_id(request.user, course_id)
+
+
+
context = {
'csrf': csrf(request)['csrf_token'],
'course': course,
@@ -241,6 +250,11 @@ def single_thread(request, course_id, discussion_id, thread_id):
try:
thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id)
+
+ #patch for backward compatibility with comments service
+ if not 'pinned' in thread.attributes:
+ thread['pinned'] = False
+
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
log.error("Error loading single thread.")
raise Http404
@@ -281,6 +295,10 @@ def single_thread(request, course_id, discussion_id, thread_id):
if thread.get('group_id') and not thread.get('group_name'):
thread['group_name'] = get_cohort_by_id(course_id, thread.get('group_id')).name
+ #patch for backward compatibility with comments service
+ if not "pinned" in thread:
+ thread["pinned"] = False
+
threads = [utils.safe_content(thread) for thread in threads]
#recent_active_threads = cc.search_recent_active_threads(
diff --git a/lms/djangoapps/django_comment_client/helpers.py b/lms/djangoapps/django_comment_client/helpers.py
index 733856e2a9..fbe7a2401b 100644
--- a/lms/djangoapps/django_comment_client/helpers.py
+++ b/lms/djangoapps/django_comment_client/helpers.py
@@ -1,11 +1,11 @@
from django.core.urlresolvers import reverse
from django.conf import settings
from mitxmako.shortcuts import render_to_string
-from mustache_helpers import mustache_helpers
+from .mustache_helpers import mustache_helpers
from django.core.urlresolvers import reverse
from functools import partial
-from utils import *
+from .utils import *
import django_comment_client.settings as cc_settings
import pystache_custom as pystache
diff --git a/lms/djangoapps/django_comment_client/permissions.py b/lms/djangoapps/django_comment_client/permissions.py
index dfdcd3e7ba..7d21cc9783 100644
--- a/lms/djangoapps/django_comment_client/permissions.py
+++ b/lms/djangoapps/django_comment_client/permissions.py
@@ -90,6 +90,8 @@ VIEW_PERMISSIONS = {
'undo_vote_for_comment': [['unvote', 'is_open']],
'vote_for_thread' : [['vote', 'is_open']],
'undo_vote_for_thread': [['unvote', 'is_open']],
+ 'pin_thread': ['create_comment'],
+ 'un_pin_thread': ['create_comment'],
'follow_thread' : ['follow_thread'],
'follow_commentable': ['follow_commentable'],
'follow_user' : ['follow_user'],
diff --git a/lms/djangoapps/django_comment_client/tests.py b/lms/djangoapps/django_comment_client/tests.py
index 4b5fe2ba5a..1d925cdb8e 100644
--- a/lms/djangoapps/django_comment_client/tests.py
+++ b/lms/djangoapps/django_comment_client/tests.py
@@ -62,7 +62,7 @@ from courseware.tests.tests import PageLoader, TEST_DATA_XML_MODULESTORE
# #self.assertEqual(resp.status_code, 200)
# #self.assertEqual(my_save.something, "expected", "complaint if not true")
#
-# self.toy.metadata["cohort_config"] = {"cohorted": True}
+# self.toy.cohort_config = {"cohorted": True}
#
# # call the view again ...
#
diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py
index 7cc36c491b..42233b84da 100644
--- a/lms/djangoapps/django_comment_client/utils.py
+++ b/lms/djangoapps/django_comment_client/utils.py
@@ -4,6 +4,10 @@ import time
import urllib
from datetime import datetime
+from courseware.module_render import get_module
+from xmodule.modulestore import Location
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.search import path_to_location
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.db import connection
@@ -167,23 +171,23 @@ def initialize_discussion_info(course):
for module in all_modules:
skip_module = False
- for key in ('id', 'discussion_category', 'for'):
- if key not in module.metadata:
+ for key in ('discussion_id', 'discussion_category', 'discussion_target'):
+ if getattr(module, key) is None:
log.warning("Required key '%s' not in discussion %s, leaving out of category map" % (key, module.location))
skip_module = True
if skip_module:
continue
- id = module.metadata['id']
- category = module.metadata['discussion_category']
- title = module.metadata['for']
- sort_key = module.metadata.get('sort_key', title)
+ id = module.discussion_id
+ category = module.discussion_category
+ title = module.discussion_target
+ sort_key = module.sort_key
category = " / ".join([x.strip() for x in category.split("/")])
last_category = category.split("/")[-1]
discussion_id_map[id] = {"location": module.location, "title": last_category + " / " + title}
unexpanded_category_map[category].append({"title": title, "id": id,
- "sort_key": sort_key, "start_date": module.start})
+ "sort_key": sort_key, "start_date": module.lms.start})
category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)}
for category_path, entries in unexpanded_category_map.items():
@@ -225,9 +229,7 @@ def initialize_discussion_info(course):
# TODO. BUG! : course location is not unique across multiple course runs!
# (I think Kevin already noticed this) Need to send course_id with requests, store it
# in the backend.
- default_topics = {'General': {'id': course.location.html_id()}}
- discussion_topics = course.metadata.get('discussion_topics', default_topics)
- for topic, entry in discussion_topics.items():
+ for topic, entry in course.discussion_topics.items():
category_map['entries'][topic] = {"id": entry["id"],
"sort_key": entry.get("sort_key", topic),
"start_date": time.gmtime()}
@@ -406,7 +408,7 @@ def safe_content(content):
'updated_at', 'depth', 'type', 'commentable_id', 'comments_count',
'at_position_list', 'children', 'highlighted_title', 'highlighted_body',
'courseware_title', 'courseware_url', 'tags', 'unread_comments_count',
- 'read', 'group_id', 'group_name', 'group_string'
+ 'read', 'group_id', 'group_name', 'group_string', 'pinned'
]
if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False):
diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py
index 88a4bdbb2b..671283db9f 100644
--- a/lms/djangoapps/instructor/views.py
+++ b/lms/djangoapps/instructor/views.py
@@ -1,14 +1,16 @@
-# ======== Instructor views =============================================================================
-
+"""
+Instructor Views
+"""
from collections import defaultdict
import csv
-import itertools
import json
import logging
import os
import re
import requests
+from requests.status_codes import codes
import urllib
+from collections import OrderedDict
import json
from StringIO import StringIO
@@ -19,6 +21,7 @@ from django.http import HttpResponse
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
from mitxmako.shortcuts import render_to_response
+import requests
from django.core.urlresolvers import reverse
from courseware import grades
@@ -88,7 +91,17 @@ def instructor_dashboard(request, course_id):
data = [['# Enrolled', CourseEnrollment.objects.filter(course_id=course_id).count()]]
data += compute_course_stats(course).items()
if request.user.is_staff:
- data.append(['metadata', escape(str(course.metadata))])
+ for field in course.fields:
+ if getattr(field.scope, 'student', False):
+ continue
+
+ data.append([field.name, json.dumps(field.read_json(course))])
+ for namespace in course.namespaces:
+ for field in getattr(course, namespace).fields:
+ if getattr(field.scope, 'student', False):
+ continue
+
+ data.append(["{}.{}".format(namespace, field.name), json.dumps(field.read_json(course))])
datatable['data'] = data
def return_csv(fn, datatable, fp=None):
@@ -136,7 +149,7 @@ def instructor_dashboard(request, course_id):
if settings.MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD']:
if 'GIT pull' in action:
- data_dir = course.metadata['data_dir']
+ data_dir = getattr(course, 'data_dir')
log.debug('git pull {0}'.format(data_dir))
gdir = settings.DATA_DIR / data_dir
if not os.path.exists(gdir):
@@ -150,7 +163,7 @@ def instructor_dashboard(request, course_id):
if 'Reload course' in action:
log.debug('reloading {0} ({1})'.format(course_id, course))
try:
- data_dir = course.metadata['data_dir']
+ data_dir = getattr(course, 'data_dir')
modulestore().try_load_course(data_dir)
msg += "
Course reloaded from {0}
".format(data_dir)
track.views.server_track(request, 'reload {0}'.format(data_dir), {}, page='idashboard')
@@ -404,7 +417,7 @@ def instructor_dashboard(request, course_id):
def getdat(u):
p = u.profile
return [u.username, u.email] + [getattr(p,x,'') for x in profkeys]
-
+
datatable['data'] = [getdat(u) for u in enrolled_students]
datatable['title'] = 'Student profile data for course %s' % course_id
return return_csv('profiledata_%s.csv' % course_id, datatable)
@@ -426,7 +439,7 @@ def instructor_dashboard(request, course_id):
msg+="Couldn't find module with that urlname. "
msg += "
%s
" % escape(err)
smdat = []
-
+
if smdat:
datatable = {'header': ['username', 'state']}
datatable['data'] = [ [x.student.username, x.state] for x in smdat ]
@@ -587,6 +600,46 @@ def instructor_dashboard(request, course_id):
if idash_mode == 'Psychometrics':
problems = psychoanalyze.problems_with_psychometric_data(course_id)
+ #----------------------------------------
+ # analytics
+ def get_analytics_result(analytics_name):
+ """Return data for an Analytic piece, or None if it doesn't exist. It
+ logs and swallows errors.
+ """
+ url = settings.ANALYTICS_SERVER_URL + \
+ "get?aname={}&course_id={}&apikey={}".format(analytics_name,
+ course_id,
+ settings.ANALYTICS_API_KEY)
+ try:
+ res = requests.get(url)
+ except Exception:
+ log.exception("Error trying to access analytics at %s", url)
+ return None
+
+ if res.status_code == codes.OK:
+ # WARNING: do not use req.json because the preloaded json doesn't
+ # preserve the order of the original record (hence OrderedDict).
+ return json.loads(res.content, object_pairs_hook=OrderedDict)
+ else:
+ log.error("Error fetching %s, code: %s, msg: %s",
+ url, res.status_code, res.content)
+ return None
+
+ analytics_results = {}
+
+ if idash_mode == 'Analytics':
+ DASHBOARD_ANALYTICS = [
+ # "StudentsAttemptedProblems", # num students who tried given problem
+ "StudentsDailyActivity", # active students by day
+ "StudentsDropoffPerDay", # active students dropoff by day
+ # "OverallGradeDistribution", # overall point distribution for course
+ "StudentsActive", # num students active in time period (default = 1wk)
+ "StudentsEnrolled", # num students enrolled
+ # "StudentsPerProblemCorrect", # foreach problem, num students correct
+ "ProblemGradeDistribution", # foreach problem, grade distribution
+ ]
+ for analytic_name in DASHBOARD_ANALYTICS:
+ analytics_results[analytic_name] = get_analytics_result(analytic_name)
#----------------------------------------
# offline grades?
@@ -608,11 +661,14 @@ def instructor_dashboard(request, course_id):
'problems': problems, # psychometrics
'plots': plots, # psychometrics
'course_errors': modulestore().get_item_errors(course.location),
+
'djangopid': os.getpid(),
'mitx_version': getattr(settings, 'MITX_VERSION_STRING', ''),
'offline_grade_log': offline_grades_available(course_id),
'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}),
- }
+
+ 'analytics_results': analytics_results,
+ }
return render_to_response('courseware/instructor_dashboard.html', context)
@@ -621,7 +677,7 @@ def _do_remote_gradebook(user, course, action, args=None, files=None):
'''
Perform remote gradebook action. Returns msg, datatable.
'''
- rg = course.metadata.get('remote_gradebook', '')
+ rg = course.remote_gradebook
if not rg:
msg = "No remote gradebook defined in course metadata"
return msg, {}
diff --git a/lms/djangoapps/licenses/tests.py b/lms/djangoapps/licenses/tests.py
index 9f4e0e3e4f..232c853b62 100644
--- a/lms/djangoapps/licenses/tests.py
+++ b/lms/djangoapps/licenses/tests.py
@@ -6,7 +6,7 @@ from tempfile import NamedTemporaryFile
from django.test import TestCase
from django.core.management import call_command
-from models import CourseSoftware, UserLicense
+from .models import CourseSoftware, UserLicense
COURSE_1 = 'edX/toy/2012_Fall'
diff --git a/lms/djangoapps/licenses/views.py b/lms/djangoapps/licenses/views.py
index 7d804fbd3d..20966427ba 100644
--- a/lms/djangoapps/licenses/views.py
+++ b/lms/djangoapps/licenses/views.py
@@ -11,8 +11,8 @@ from django.contrib.auth.models import User
from django.http import HttpResponse, Http404
from django.views.decorators.csrf import requires_csrf_token, csrf_protect
-from models import CourseSoftware
-from models import get_courses_licenses, get_or_create_license, get_license
+from .models import CourseSoftware
+from .models import get_courses_licenses, get_or_create_license, get_license
log = logging.getLogger("mitx.licenses")
diff --git a/lms/djangoapps/lms_migration/migrate.py b/lms/djangoapps/lms_migration/migrate.py
index 9cdc783bb9..569129f469 100644
--- a/lms/djangoapps/lms_migration/migrate.py
+++ b/lms/djangoapps/lms_migration/migrate.py
@@ -128,7 +128,7 @@ def manage_modulestores(request, reload_dir=None, commit_id=None):
for cdir, course in def_ms.courses.items():
html += ''
- html += '
Course: %s (%s)
' % (course.display_name, cdir)
+ html += '
Course: %s (%s)
' % (course.display_name_with_default, cdir)
html += '
commit_id=%s
' % get_commit_id(course)
diff --git a/lms/djangoapps/open_ended_grading/open_ended_notifications.py b/lms/djangoapps/open_ended_grading/open_ended_notifications.py
index d67e2816d5..6d5f2a3eb4 100644
--- a/lms/djangoapps/open_ended_grading/open_ended_notifications.py
+++ b/lms/djangoapps/open_ended_grading/open_ended_notifications.py
@@ -1,6 +1,6 @@
from django.conf import settings
from xmodule.open_ended_grading_classes import peer_grading_service
-from staff_grading_service import StaffGradingService
+from .staff_grading_service import StaffGradingService
from xmodule.open_ended_grading_classes.controller_query_service import ControllerQueryService
import json
from student.models import unique_id_for_user
@@ -61,7 +61,14 @@ def staff_grading_notifications(course, user):
def peer_grading_notifications(course, user):
- system = ModuleSystem(None, None, None, render_to_string, None)
+ system = ModuleSystem(
+ ajax_url=None,
+ track_function=None,
+ get_module = None,
+ render_template=render_to_string,
+ replace_urls=None,
+ xblock_model_data= {}
+ )
peer_gs = peer_grading_service.PeerGradingService(settings.OPEN_ENDED_GRADING_INTERFACE, system)
pending_grading = False
img_path = ""
@@ -97,7 +104,14 @@ def peer_grading_notifications(course, user):
def combined_notifications(course, user):
- system = ModuleSystem(None, None, None, render_to_string, None)
+ system = ModuleSystem(
+ ajax_url=None,
+ track_function=None,
+ get_module = None,
+ render_template=render_to_string,
+ replace_urls=None,
+ xblock_model_data= {}
+ )
controller_qs = ControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, system)
student_id = unique_id_for_user(user)
user_is_staff = has_access(user, course, 'staff')
diff --git a/lms/djangoapps/open_ended_grading/staff_grading_service.py b/lms/djangoapps/open_ended_grading/staff_grading_service.py
index 91138bf685..57bfd7df42 100644
--- a/lms/djangoapps/open_ended_grading/staff_grading_service.py
+++ b/lms/djangoapps/open_ended_grading/staff_grading_service.py
@@ -66,7 +66,14 @@ class StaffGradingService(GradingService):
"""
def __init__(self, config):
- config['system'] = ModuleSystem(None, None, None, render_to_string, None)
+ config['system'] = ModuleSystem(
+ ajax_url=None,
+ track_function=None,
+ get_module = None,
+ render_template=render_to_string,
+ replace_urls=None,
+ xblock_model_data= {}
+ )
super(StaffGradingService, self).__init__(config)
self.url = config['url'] + config['staff_grading']
self.login_url = self.url + '/login/'
diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py
index 64123605ce..1fd871d0cd 100644
--- a/lms/djangoapps/open_ended_grading/tests.py
+++ b/lms/djangoapps/open_ended_grading/tests.py
@@ -149,15 +149,21 @@ class TestPeerGradingService(ct.PageLoader):
self.course_id = "edX/toy/2012_Fall"
self.toy = modulestore().get_course(self.course_id)
location = "i4x://edX/toy/peergrading/init"
-
+ model_data = {'data': ""}
self.mock_service = peer_grading_service.MockPeerGradingService()
- self.system = ModuleSystem(location, None, None, render_to_string, None,
- s3_interface=test_util_open_ended.S3_INTERFACE,
- open_ended_grading_interface=test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE
+ self.system = ModuleSystem(
+ ajax_url=location,
+ track_function=None,
+ get_module=None,
+ render_template=render_to_string,
+ replace_urls=None,
+ xblock_model_data={},
+ s3_interface=test_util_open_ended.S3_INTERFACE,
+ open_ended_grading_interface=test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE
)
- self.descriptor = peer_grading_module.PeerGradingDescriptor(self.system)
- self.peer_module = peer_grading_module.PeerGradingModule(self.system, location, "",
- self.descriptor)
+ self.descriptor = peer_grading_module.PeerGradingDescriptor(self.system, location, model_data)
+ model_data = {}
+ self.peer_module = peer_grading_module.PeerGradingModule(self.system, location, self.descriptor, model_data)
self.peer_module.peer_gs = self.mock_service
self.logout()
diff --git a/lms/djangoapps/open_ended_grading/views.py b/lms/djangoapps/open_ended_grading/views.py
index 2e7f429429..65cfe22ed0 100644
--- a/lms/djangoapps/open_ended_grading/views.py
+++ b/lms/djangoapps/open_ended_grading/views.py
@@ -29,7 +29,14 @@ log = logging.getLogger(__name__)
template_imports = {'urllib': urllib}
-system = ModuleSystem(None, None, None, render_to_string, None)
+system = ModuleSystem(
+ ajax_url=None,
+ track_function=None,
+ get_module = None,
+ render_template=render_to_string,
+ replace_urls = None,
+ xblock_model_data= {}
+)
controller_qs = ControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, system)
"""
diff --git a/lms/djangoapps/psychometrics/psychoanalyze.py b/lms/djangoapps/psychometrics/psychoanalyze.py
index 28a5c4437c..3e9edcc997 100644
--- a/lms/djangoapps/psychometrics/psychoanalyze.py
+++ b/lms/djangoapps/psychometrics/psychoanalyze.py
@@ -297,12 +297,18 @@ def generate_plots_for_problem(problem):
#-----------------------------------------------------------------------------
-def make_psychometrics_data_update_handler(studentmodule):
+def make_psychometrics_data_update_handler(course_id, user, module_state_key):
"""
Construct and return a procedure which may be called to update
the PsychometricsData instance for the given StudentModule instance.
"""
- sm = studentmodule
+ sm = studentmodule.objects.get_or_create(
+ course_id=course_id,
+ student=user,
+ module_state_key=module_state_key,
+ defaults={'state': '{}', 'module_type': 'problem'},
+ )
+
try:
pmd = PsychometricData.objects.using(db).get(studentmodule=sm)
except PsychometricData.DoesNotExist:
diff --git a/lms/djangoapps/simplewiki/admin.py b/lms/djangoapps/simplewiki/admin.py
index e4cf8c2f56..2ba6405956 100644
--- a/lms/djangoapps/simplewiki/admin.py
+++ b/lms/djangoapps/simplewiki/admin.py
@@ -4,7 +4,7 @@ from django import forms
from django.contrib import admin
from django.utils.translation import ugettext as _
-from models import Article, Revision, Permission, ArticleAttachment
+from .models import Article, Revision, Permission, ArticleAttachment
class RevisionInline(admin.TabularInline):
diff --git a/lms/djangoapps/simplewiki/models.py b/lms/djangoapps/simplewiki/models.py
index 68da6a0d71..75cdb8aa7a 100644
--- a/lms/djangoapps/simplewiki/models.py
+++ b/lms/djangoapps/simplewiki/models.py
@@ -9,7 +9,7 @@ from django.db.models import signals
from django.utils.translation import ugettext_lazy as _
from markdown import markdown
-from wiki_settings import *
+from .wiki_settings import *
from util.cache import cache
diff --git a/lms/djangoapps/simplewiki/views.py b/lms/djangoapps/simplewiki/views.py
index 445b0418fc..a84fac6e7d 100644
--- a/lms/djangoapps/simplewiki/views.py
+++ b/lms/djangoapps/simplewiki/views.py
@@ -14,7 +14,7 @@ from courseware.access import has_access
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.django import modulestore
-from models import Revision, Article, Namespace, CreateArticleForm, RevisionFormWithTitle, RevisionForm
+from .models import Revision, Article, Namespace, CreateArticleForm, RevisionFormWithTitle, RevisionForm
import wiki_settings
diff --git a/lms/djangoapps/staticbook/views.py b/lms/djangoapps/staticbook/views.py
index ec34683997..aa1ba68d72 100644
--- a/lms/djangoapps/staticbook/views.py
+++ b/lms/djangoapps/staticbook/views.py
@@ -50,7 +50,7 @@ def pdf_index(request, course_id, book_index, chapter=None, page=None):
input_url = "'" + original_url + "'"
output_url = replace_static_urls(
input_url,
- course.metadata['data_dir'],
+ getattr(course, 'data_dir', None),
course_namespace=course.location
)
# strip off the quotes again...
@@ -86,7 +86,7 @@ def html_index(request, course_id, book_index, chapter=None, anchor_id=None):
input_url = "'" + original_url + "'"
output_url = replace_static_urls(
input_url,
- course.metadata['data_dir'],
+ getattr(course, 'data_dir', None),
course_namespace=course.location
)
# strip off the quotes again...
diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py
index b6941f4a70..3dac545367 100644
--- a/lms/envs/acceptance.py
+++ b/lms/envs/acceptance.py
@@ -8,16 +8,24 @@ from .test import *
# otherwise the browser will not render the pages correctly
DEBUG = True
-# Show the courses that are in the data directory
-COURSES_ROOT = ENV_ROOT / "data"
-DATA_DIR = COURSES_ROOT
+# Use the mongo store for acceptance tests
+modulestore_options = {
+ 'default_class': 'xmodule.raw_module.RawDescriptor',
+ 'host': 'localhost',
+ 'db': 'test_xmodule',
+ 'collection': 'modulestore',
+ 'fs_root': GITHUB_REPO_ROOT,
+ 'render_template': 'mitxmako.shortcuts.render_to_string',
+}
+
MODULESTORE = {
'default': {
- 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
- 'OPTIONS': {
- 'data_dir': DATA_DIR,
- 'default_class': 'xmodule.hidden_module.HiddenDescriptor',
- }
+ 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
+ 'OPTIONS': modulestore_options
+ },
+ 'direct': {
+ 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
+ 'OPTIONS': modulestore_options
}
}
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index 9089bc92ed..cc9247b876 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -110,3 +110,7 @@ PEARSON = AUTH_TOKENS.get("PEARSON")
# Datadog for events!
DATADOG_API = AUTH_TOKENS.get("DATADOG_API")
+
+# Analytics dashboard server
+ANALYTICS_SERVER_URL = ENV_TOKENS.get("ANALYTICS_SERVER_URL")
+ANALYTICS_API_KEY = AUTH_TOKENS.get("ANALYTICS_API_KEY", "")
diff --git a/lms/envs/common.py b/lms/envs/common.py
index df419f09ee..b1fe9b15c6 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -81,10 +81,13 @@ MITX_FEATURES = {
'AUTH_USE_MIT_CERTIFICATES': False,
'AUTH_USE_OPENID_PROVIDER': False,
+ # analytics experiments
+ 'ENABLE_INSTRUCTOR_ANALYTICS': False,
+
# Flip to True when the YouTube iframe API breaks (again)
'USE_YOUTUBE_OBJECT_API': False,
- # Give a UI to show a student's submission history in a problem by the
+ # Give a UI to show a student's submission history in a problem by the
# Staff Debug tool.
'ENABLE_STUDENT_HISTORY_VIEW': True,
@@ -212,8 +215,8 @@ WIKI_ENABLED = False
###
COURSE_DEFAULT = '6.002x_Fall_2012'
-COURSE_SETTINGS = {'6.002x_Fall_2012': {'number' : '6.002x',
- 'title' : 'Circuits and Electronics',
+COURSE_SETTINGS = {'6.002x_Fall_2012': {'number': '6.002x',
+ 'title': 'Circuits and Electronics',
'xmlpath': '6002x/',
'location': 'i4x://edx/6002xs12/course/6.002x_Fall_2012',
}
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index 6ecbbb0f85..f204dc287b 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -21,6 +21,8 @@ MITX_FEATURES['SUBDOMAIN_BRANDING'] = True
MITX_FEATURES['FORCE_UNIVERSITY_DOMAIN'] = None # show all university courses if in dev (ie don't use HTTP_HOST)
MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True
MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard)
+MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True
+
WIKI_ENABLED = True
@@ -57,6 +59,13 @@ CACHES = {
'KEY_PREFIX': 'general',
'VERSION': 4,
'KEY_FUNCTION': 'util.memcache.safe_key',
+ },
+
+ 'mongo_metadata_inheritance': {
+ 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
+ 'LOCATION': '/var/tmp/mongo_metadata_inheritance',
+ 'TIMEOUT': 300,
+ 'KEY_FUNCTION': 'util.memcache.safe_key',
}
}
@@ -215,3 +224,8 @@ PIPELINE_SASS_ARGUMENTS = '-r {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.for
MITX_FEATURES['ENABLE_PEARSON_HACK_TEST'] = True
PEARSON_TEST_USER = "pearsontest"
PEARSON_TEST_PASSWORD = "12345"
+
+########################## ANALYTICS TESTING ########################
+
+ANALYTICS_SERVER_URL = "http://127.0.0.1:9000/"
+ANALYTICS_API_KEY = ""
diff --git a/lms/envs/test.py b/lms/envs/test.py
index c1863349fb..5eb96c8df0 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -8,7 +8,6 @@ sessions. Assumes structure:
/log # Where we're going to write log files
"""
from .common import *
-from logsettings import get_logger_config
import os
from path import path
@@ -116,7 +115,14 @@ CACHES = {
'KEY_PREFIX': 'general',
'VERSION': 4,
'KEY_FUNCTION': 'util.memcache.safe_key',
- }
+ },
+
+ 'mongo_metadata_inheritance': {
+ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+ 'LOCATION': '/var/tmp/mongo_metadata_inheritance',
+ 'TIMEOUT': 300,
+ 'KEY_FUNCTION': 'util.memcache.safe_key',
+ }
}
# Dummy secret key for dev
@@ -170,4 +176,4 @@ PASSWORD_HASHERS = (
'django.contrib.auth.hashers.SHA1PasswordHasher',
'django.contrib.auth.hashers.MD5PasswordHasher',
# 'django.contrib.auth.hashers.CryptPasswordHasher',
-)
\ No newline at end of file
+)
diff --git a/lms/lib/comment_client/__init__.py b/lms/lib/comment_client/__init__.py
index ecade68b50..6228c32024 100644
--- a/lms/lib/comment_client/__init__.py
+++ b/lms/lib/comment_client/__init__.py
@@ -1,2 +1,2 @@
-from comment_client import *
-from utils import CommentClientError, CommentClientUnknownError
+from .comment_client import *
+from .utils import CommentClientError, CommentClientUnknownError
diff --git a/lms/lib/comment_client/comment.py b/lms/lib/comment_client/comment.py
index 0d81761512..2f93aff6b3 100644
--- a/lms/lib/comment_client/comment.py
+++ b/lms/lib/comment_client/comment.py
@@ -1,6 +1,6 @@
-from utils import *
+from .utils import *
-from thread import Thread
+from .thread import Thread
import models
import settings
diff --git a/lms/lib/comment_client/comment_client.py b/lms/lib/comment_client/comment_client.py
index d7c8f05485..862483a75b 100644
--- a/lms/lib/comment_client/comment_client.py
+++ b/lms/lib/comment_client/comment_client.py
@@ -1,9 +1,9 @@
-from comment import Comment
-from thread import Thread
-from user import User
-from commentable import Commentable
+from .comment import Comment
+from .thread import Thread
+from .user import User
+from .commentable import Commentable
-from utils import *
+from .utils import *
import settings
diff --git a/lms/lib/comment_client/commentable.py b/lms/lib/comment_client/commentable.py
index 85c357ef81..111809f8f0 100644
--- a/lms/lib/comment_client/commentable.py
+++ b/lms/lib/comment_client/commentable.py
@@ -1,4 +1,4 @@
-from utils import *
+from .utils import *
import models
import settings
diff --git a/lms/lib/comment_client/models.py b/lms/lib/comment_client/models.py
index 2a8992554d..bf5f576a44 100644
--- a/lms/lib/comment_client/models.py
+++ b/lms/lib/comment_client/models.py
@@ -1,4 +1,4 @@
-from utils import *
+from .utils import *
class Model(object):
diff --git a/lms/lib/comment_client/thread.py b/lms/lib/comment_client/thread.py
index ca607d3ff3..8911d5a2c6 100644
--- a/lms/lib/comment_client/thread.py
+++ b/lms/lib/comment_client/thread.py
@@ -1,4 +1,4 @@
-from utils import *
+from .utils import *
import models
import settings
@@ -11,12 +11,12 @@ class Thread(models.Model):
'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id',
'created_at', 'updated_at', 'comments_count', 'unread_comments_count',
'at_position_list', 'children', 'type', 'highlighted_title',
- 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name'
+ 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned'
]
updatable_fields = [
'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
- 'closed', 'tags', 'user_id', 'commentable_id', 'group_id', 'group_name'
+ 'closed', 'tags', 'user_id', 'commentable_id', 'group_id', 'group_name', 'pinned'
]
initializable_fields = updatable_fields
@@ -79,3 +79,23 @@ class Thread(models.Model):
response = perform_request('get', url, request_params)
self.update_attributes(**response)
+
+ def pin(self, user, thread_id):
+ url = _url_for_pin_thread(thread_id)
+ params = {'user_id': user.id}
+ request = perform_request('put', url, params)
+ self.update_attributes(request)
+
+ def un_pin(self, user, thread_id):
+ url = _url_for_un_pin_thread(thread_id)
+ params = {'user_id': user.id}
+ request = perform_request('put', url, params)
+ self.update_attributes(request)
+
+
+def _url_for_pin_thread(thread_id):
+ return "{prefix}/threads/{thread_id}/pin".format(prefix=settings.PREFIX, thread_id=thread_id)
+
+def _url_for_un_pin_thread(thread_id):
+ return "{prefix}/threads/{thread_id}/unpin".format(prefix=settings.PREFIX, thread_id=thread_id)
+
\ No newline at end of file
diff --git a/lms/lib/comment_client/user.py b/lms/lib/comment_client/user.py
index c3ba84175e..a9e47fe6aa 100644
--- a/lms/lib/comment_client/user.py
+++ b/lms/lib/comment_client/user.py
@@ -1,4 +1,4 @@
-from utils import *
+from .utils import *
import models
import settings
diff --git a/lms/lib/comment_client/utils.py b/lms/lib/comment_client/utils.py
index e053fea6c0..860035dc06 100644
--- a/lms/lib/comment_client/utils.py
+++ b/lms/lib/comment_client/utils.py
@@ -3,7 +3,7 @@ import logging
import requests
import settings
-log = logging.getLogger('mitx.' + __name__)
+log = logging.getLogger(__name__)
def strip_none(dic):
diff --git a/lms/lib/loncapa/__init__.py b/lms/lib/loncapa/__init__.py
index b734967d0a..32661f2df8 100644
--- a/lms/lib/loncapa/__init__.py
+++ b/lms/lib/loncapa/__init__.py
@@ -1,3 +1,3 @@
#!/usr/bin/python
-from loncapa_check import *
+from .loncapa_check import *
diff --git a/lms/lib/symmath/__init__.py b/lms/lib/symmath/__init__.py
index 6a5632c001..a4233e4dde 100644
--- a/lms/lib/symmath/__init__.py
+++ b/lms/lib/symmath/__init__.py
@@ -1,2 +1,2 @@
-from formula import *
-from symmath_check import *
+from .formula import *
+from .symmath_check import *
diff --git a/lms/lib/symmath/symmath_check.py b/lms/lib/symmath/symmath_check.py
index 0a4ff84f70..151debee71 100644
--- a/lms/lib/symmath/symmath_check.py
+++ b/lms/lib/symmath/symmath_check.py
@@ -13,7 +13,7 @@ import sys
import string
import re
import traceback
-from formula import *
+from .formula import *
import logging
log = logging.getLogger(__name__)
diff --git a/lms/lib/symmath/test_symmath_check.py b/lms/lib/symmath/test_symmath_check.py
index 2d015fcb53..3b8f14b0d2 100644
--- a/lms/lib/symmath/test_symmath_check.py
+++ b/lms/lib/symmath/test_symmath_check.py
@@ -1,5 +1,5 @@
from unittest import TestCase
-from symmath_check import symmath_check
+from .symmath_check import symmath_check
class SymmathCheckTest(TestCase):
def test_symmath_check_integers(self):
diff --git a/lms/one_time_startup.py b/lms/one_time_startup.py
index 93428a3404..6b3c45d60f 100644
--- a/lms/one_time_startup.py
+++ b/lms/one_time_startup.py
@@ -1,5 +1,14 @@
+import logging
from dogapi import dog_http_api, dog_stats_api
from django.conf import settings
+from xmodule.modulestore.django import modulestore
+
+from django.core.cache import get_cache, InvalidCacheBackendError
+
+cache = get_cache('mongo_metadata_inheritance')
+for store_name in settings.MODULESTORE:
+ store = modulestore(store_name)
+ store.metadata_inheritance_cache = cache
if hasattr(settings, 'DATADOG_API'):
dog_http_api.api_key = settings.DATADOG_API
diff --git a/lms/static/coffee/files.json b/lms/static/coffee/files.json
index 5dc03613b9..0efe488dd9 100644
--- a/lms/static/coffee/files.json
+++ b/lms/static/coffee/files.json
@@ -5,8 +5,5 @@
"/static/js/vendor/jquery-ui.min.js",
"/static/js/vendor/jquery.leanModal.min.js",
"/static/js/vendor/flot/jquery.flot.js"
- ],
- "static_files": [
- "js/application.js"
]
}
diff --git a/lms/static/coffee/spec/calculator_spec.coffee b/lms/static/coffee/spec/calculator_spec.coffee
index 072d220a44..8258d8965a 100644
--- a/lms/static/coffee/spec/calculator_spec.coffee
+++ b/lms/static/coffee/spec/calculator_spec.coffee
@@ -4,9 +4,6 @@ describe 'Calculator', ->
@calculator = new Calculator
describe 'bind', ->
- beforeEach ->
- Calculator.bind()
-
it 'bind the calculator button', ->
expect($('.calc')).toHandleWith 'click', @calculator.toggle
@@ -31,12 +28,19 @@ describe 'Calculator', ->
$('form#calculator').submit()
describe 'toggle', ->
- it 'toggle the calculator and focus the input', ->
- spyOn $.fn, 'focus'
- @calculator.toggle(jQuery.Event("click"))
+ it 'focuses the input when toggled', ->
- expect($('li.calc-main')).toHaveClass('open')
- expect($('#calculator_wrapper #calculator_input').focus).toHaveBeenCalled()
+ # Since the focus is called asynchronously, we need to
+ # wait until focus() is called.
+ didFocus = false
+ runs ->
+ spyOn($.fn, 'focus').andCallFake (elementName) -> didFocus = true
+ @calculator.toggle(jQuery.Event("click"))
+
+ waitsFor (-> didFocus), "focus() should have been called on the input", 1000
+
+ runs ->
+ expect($('#calculator_wrapper #calculator_input').focus).toHaveBeenCalled()
it 'toggle the close button on the calculator button', ->
@calculator.toggle(jQuery.Event("click"))
diff --git a/lms/static/coffee/spec/modules/tab_spec.coffee b/lms/static/coffee/spec/modules/tab_spec.coffee
index 909f0d7cda..6fba470974 100644
--- a/lms/static/coffee/spec/modules/tab_spec.coffee
+++ b/lms/static/coffee/spec/modules/tab_spec.coffee
@@ -22,18 +22,23 @@ describe 'Tab', ->
it 'bind the tabs', ->
expect($.fn.tabs).toHaveBeenCalledWith show: @tab.onShow
+ # As of jQuery 1.9, the onShow callback is deprecated
+ # http://jqueryui.com/upgrade-guide/1.9/#deprecated-show-event-renamed-to-activate
+ # The code below tests that onShow does what is expected,
+ # but note that onShow will NOT be called when the user
+ # clicks on the tab if we're using jQuery version >= 1.9
describe 'onShow', ->
beforeEach ->
@tab = new Tab 1, @items
- $('[href="#tab-1-0"]').click()
+ @tab.onShow($('#tab-1-0'), {'index': 1})
it 'replace content in the container', ->
- $('[href="#tab-1-1"]').click()
+ @tab.onShow($('#tab-1-1'), {'index': 1})
expect($('#tab-1-0').html()).toEqual ''
expect($('#tab-1-1').html()).toEqual 'Video 2'
expect($('#tab-1-2').html()).toEqual ''
it 'trigger contentChanged event on the element', ->
spyOnEvent @tab.el, 'contentChanged'
- $('[href="#tab-1-1"]').click()
+ @tab.onShow($('#tab-1-1'), {'index': 1})
expect('contentChanged').toHaveBeenTriggeredOn @tab.el
diff --git a/lms/static/coffee/spec/navigation_spec.coffee b/lms/static/coffee/spec/navigation_spec.coffee
index 1340984e52..b351164b63 100644
--- a/lms/static/coffee/spec/navigation_spec.coffee
+++ b/lms/static/coffee/spec/navigation_spec.coffee
@@ -32,11 +32,9 @@ describe 'Navigation', ->
heightStyle: 'content'
it 'binds the accordionchange event', ->
- Navigation.bind()
expect($('#accordion')).toHandleWith 'accordionchange', @navigation.log
it 'bind the navigation toggle', ->
- Navigation.bind()
expect($('#open_close_accordion a')).toHandleWith 'click', @navigation.toggle
describe 'when the #accordion does not exists', ->
@@ -45,7 +43,6 @@ describe 'Navigation', ->
it 'does not activate the accordion', ->
spyOn $.fn, 'accordion'
- Navigation.bind()
expect($('#accordion').accordion).wasNotCalled()
describe 'toggle', ->
diff --git a/lms/static/images/pinned.png b/lms/static/images/pinned.png
new file mode 100644
index 0000000000..76bb207fff
Binary files /dev/null and b/lms/static/images/pinned.png differ
diff --git a/lms/static/images/press/releases/201x_240x180.jpg b/lms/static/images/press/releases/201x_240x180.jpg
new file mode 100644
index 0000000000..d2cfd8ee45
Binary files /dev/null and b/lms/static/images/press/releases/201x_240x180.jpg differ
diff --git a/lms/static/images/unpinned.png b/lms/static/images/unpinned.png
new file mode 100644
index 0000000000..030198f7e8
Binary files /dev/null and b/lms/static/images/unpinned.png differ
diff --git a/lms/static/sass/_discussion.scss b/lms/static/sass/_discussion.scss
index e5134837fe..2f044ca5a3 100644
--- a/lms/static/sass/_discussion.scss
+++ b/lms/static/sass/_discussion.scss
@@ -2442,4 +2442,39 @@ body.discussion {
color:#000;
font-style: italic;
background-color:#fff;
- }
\ No newline at end of file
+ }
+
+.discussion-pin {
+ font-size: 12px;
+ float:right;
+ padding-right: 5px;
+ font-style: italic;
+ }
+
+.notpinned .icon
+{
+ display: inline-block;
+ width: 10px;
+ height: 14px;
+ padding-right: 3px;
+ background: transparent url('../images/unpinned.png') no-repeat 0 0;
+}
+
+.pinned .icon
+{
+ display: inline-block;
+ width: 10px;
+ height: 14px;
+ padding-right: 3px;
+ background: transparent url('../images/pinned.png') no-repeat 0 0;
+}
+
+.pinned span {
+ color: #B82066;
+ font-style: italic;
+}
+
+.notpinned span {
+ color: #888;
+ font-style: italic;
+}
\ No newline at end of file
diff --git a/lms/static/sass/course/courseware/_courseware.scss b/lms/static/sass/course/courseware/_courseware.scss
index ea987d8b2f..ab285392ca 100644
--- a/lms/static/sass/course/courseware/_courseware.scss
+++ b/lms/static/sass/course/courseware/_courseware.scss
@@ -119,6 +119,10 @@ div.course-wrapper {
}
}
+ section.xmodule_WrapperModule ol.vert-mod > li {
+ border-bottom: none;
+ }
+
section.tutorials {
h2 {
margin-bottom: lh();
@@ -219,7 +223,7 @@ div.course-wrapper {
.xmodule_VideoModule {
margin-bottom: 30px;
-
+
}
textarea.short-form-response {
@@ -237,7 +241,7 @@ section.self-assessment {
margin-top: 5px;
margin-bottom: 5px;
}
-
+
div {
margin-top: 5px;
margin-bottom: 5px;
@@ -261,4 +265,4 @@ section.foldit {
padding-right: 5px;
}
-}
\ No newline at end of file
+}
diff --git a/lms/static/sass/shared/_course_object.scss b/lms/static/sass/shared/_course_object.scss
index 2c638ed158..e99559a49f 100644
--- a/lms/static/sass/shared/_course_object.scss
+++ b/lms/static/sass/shared/_course_object.scss
@@ -205,7 +205,10 @@
position: relative;
width: 100%;
- p {
+ section {
+ color: $base-font-color;
+ font: normal 1em/1.6em $serif;
+ margin: 0px;
height: 100%;
overflow: hidden;
text-overflow: ellipsis;
diff --git a/lms/templates/conditional_ajax.html b/lms/templates/conditional_ajax.html
index 0a5887be04..61f1095259 100644
--- a/lms/templates/conditional_ajax.html
+++ b/lms/templates/conditional_ajax.html
@@ -1 +1,8 @@
-
+
<%
@@ -226,7 +226,7 @@
%>
% if testcenter_exam_info is not None:
- % if registration is None and testcenter_exam_info.is_registering():
+ % if registration is None and testcenter_exam_info.is_registering():
% endif
- % if not registration.is_accepted and not registration.is_rejected:
+ % if not registration.is_accepted and not registration.is_rejected:
Your registration for the Pearson exam is pending. Within a few days, you should see a confirmation number here, which can be used to schedule your exam.