studio - alerts: resolving local master merge conflcits
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,4 +29,5 @@ cover_html/
|
||||
.idea/
|
||||
.redcar/
|
||||
chromedriver.log
|
||||
/nbproject
|
||||
ghostdriver.log
|
||||
|
||||
14
.pylintrc
14
.pylintrc
@@ -33,7 +33,15 @@ load-plugins=
|
||||
# can either give multiple identifier separated by comma (,) or put this option
|
||||
# multiple time (only on the command line, not in the configuration file where
|
||||
# it should appear only once).
|
||||
disable=E1102,W0142
|
||||
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)
|
||||
# R0904: Too many public methods
|
||||
W0141,W0142,R0201,R0901,R0902,R0903,R0904
|
||||
|
||||
|
||||
[REPORTS]
|
||||
@@ -97,7 +105,7 @@ bad-functions=map,filter,apply,input
|
||||
module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
|
||||
|
||||
# Regular expression which should only match correct module level names
|
||||
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|log)$
|
||||
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$
|
||||
|
||||
# Regular expression which should only match correct class names
|
||||
class-rgx=[A-Z_][a-zA-Z0-9]+$
|
||||
@@ -106,7 +114,7 @@ class-rgx=[A-Z_][a-zA-Z0-9]+$
|
||||
function-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||
|
||||
# Regular expression which should only match correct method names
|
||||
method-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||
method-rgx=([a-z_][a-z0-9_]{2,60}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*)$
|
||||
|
||||
# Regular expression which should only match correct instance attribute names
|
||||
attr-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||
|
||||
@@ -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("<ol></ol>")
|
||||
course_html_parsed = etree.fromstring(course_updates.data)
|
||||
except etree.XMLSyntaxError:
|
||||
course_html_parsed = etree.fromstring("<ol></ol>")
|
||||
|
||||
# Confirm that root is <ol>, iterate over <li>, pull out <h2> 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("<ol></ol>")
|
||||
course_html_parsed = etree.fromstring(course_updates.data)
|
||||
except etree.XMLSyntaxError:
|
||||
course_html_parsed = etree.fromstring("<ol></ol>")
|
||||
|
||||
# No try/catch b/c failure generates an error back to client
|
||||
new_html_parsed = html.fromstring('<li><h2>' + update['date'] + '</h2>' + update['content'] + '</li>')
|
||||
@@ -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("<ol></ol>")
|
||||
course_html_parsed = etree.fromstring(course_updates.data)
|
||||
except etree.XMLSyntaxError:
|
||||
course_html_parsed = etree.fromstring("<ol></ol>")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'}),
|
||||
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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in')
|
||||
self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from cms.djangoapps.contentstore import utils
|
||||
from contentstore import utils
|
||||
import mock
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,11 +28,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 +58,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 +114,7 @@ def login_page(request):
|
||||
def howitworks(request):
|
||||
if request.user.is_authenticated():
|
||||
return index(request)
|
||||
else:
|
||||
else:
|
||||
return render_to_response('howitworks.html', {})
|
||||
|
||||
def ux_alerts(request):
|
||||
@@ -138,7 +142,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,
|
||||
@@ -242,8 +246,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()
|
||||
@@ -296,8 +305,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)
|
||||
@@ -318,10 +326,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 = [
|
||||
@@ -365,11 +373,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',
|
||||
@@ -380,11 +383,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,
|
||||
})
|
||||
|
||||
|
||||
@@ -449,9 +452,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)
|
||||
@@ -462,46 +464,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
|
||||
@@ -509,6 +474,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
|
||||
@@ -519,6 +511,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?
|
||||
@@ -529,6 +529,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -541,11 +542,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
|
||||
|
||||
@@ -557,12 +558,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':
|
||||
@@ -580,11 +582,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
|
||||
|
||||
@@ -598,7 +598,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
|
||||
|
||||
@@ -646,15 +646,17 @@ def delete_item(request):
|
||||
modulestore('direct').delete_item(item.location)
|
||||
|
||||
# cdodge: we need to remove our parent's pointer to us so that it is no longer dangling
|
||||
if delete_all_versions:
|
||||
parent_locs = modulestore('direct').get_parent_locations(item_loc, None)
|
||||
|
||||
parent_locs = modulestore('direct').get_parent_locations(item_loc, None)
|
||||
|
||||
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"])
|
||||
for parent_loc in parent_locs:
|
||||
parent = modulestore('direct').get_item(parent_loc)
|
||||
item_url = item_loc.url()
|
||||
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()
|
||||
|
||||
@@ -693,7 +695,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
|
||||
@@ -701,15 +703,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()
|
||||
|
||||
@@ -776,17 +778,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()}))
|
||||
|
||||
@@ -1005,7 +1004,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:
|
||||
@@ -1014,7 +1013,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()
|
||||
|
||||
|
||||
@@ -1232,7 +1231,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)),
|
||||
})
|
||||
|
||||
@@ -1315,7 +1313,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()
|
||||
@@ -1325,7 +1323,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':
|
||||
@@ -1436,13 +1434,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)
|
||||
|
||||
@@ -1461,12 +1456,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
<li class="field-group course-advanced-policy-list-item">
|
||||
<div class="field text key" id="<%= (_.isEmpty(key) ? '__new_advanced_key__' : key) %>">
|
||||
<div class="field is-not-editable text key" id="<%= key %>">
|
||||
<label for="<%= keyUniqueId %>">Policy Key:</label>
|
||||
<input type="text" class="short policy-key" id="<%= keyUniqueId %>" value="<%= key %>" />
|
||||
<span class="tip tip-stacked">Keys are case sensitive and cannot contain spaces or start with a number</span>
|
||||
<input readonly title="This field is disabled: policy keys cannot be edited." type="text" class="short policy-key" id="<%= keyUniqueId %>" value="<%= key %>" />
|
||||
</div>
|
||||
|
||||
<div class="field text value">
|
||||
<label for="<%= valueUniqueId %>">Policy Value:</label>
|
||||
<textarea class="json text" id="<%= valueUniqueId %>"><%= value %></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="#" class="button delete-button standard remove-item remove-grading-data"><span class="delete-icon"></span>Delete</a>
|
||||
</div>
|
||||
</li>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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) {
|
||||
$(".wrapper-alert").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').removeClass('is-hiding').addClass('is-shown');
|
||||
this.buttonsVisible = true;
|
||||
}
|
||||
},
|
||||
|
||||
hideSaveCancelButtons: function() {
|
||||
$('.wrapper-notification').removeClass('is-shown').addClass('is-hiding');
|
||||
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");
|
||||
},
|
||||
|
||||
@@ -472,6 +472,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<title>
|
||||
<%block name="title"></%block> |
|
||||
% if context_course:
|
||||
<% ctx_loc = context_course.location %>
|
||||
${context_course.display_name} |
|
||||
% endif
|
||||
edX Studio
|
||||
<%block name="title"></%block> |
|
||||
% if context_course:
|
||||
<% ctx_loc = context_course.location %>
|
||||
${context_course.display_name_with_default} |
|
||||
% endif
|
||||
edX Studio
|
||||
</title>
|
||||
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -66,4 +66,4 @@
|
||||
|
||||
<%block name="jsextra"></%block>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<%inherit file="base.html" />
|
||||
|
||||
<%include file="widgets/header.html"/>
|
||||
|
||||
<%block name="content">
|
||||
<section class="main-container">
|
||||
|
||||
<%include file="widgets/navigation.html"/>
|
||||
|
||||
<section class="main-content">
|
||||
</section>
|
||||
|
||||
</section>
|
||||
</%block>
|
||||
@@ -22,7 +22,7 @@
|
||||
<article class="subsection-body window" data-id="${subsection.location}">
|
||||
<div class="subsection-name-input">
|
||||
<label>Display Name:</label>
|
||||
<input type="text" value="${subsection.display_name | h}" class="subsection-display-name-input" data-metadata-name="display_name"/>
|
||||
<input type="text" value="${subsection.display_name_with_default | h}" class="subsection-display-name-input" data-metadata-name="display_name"/>
|
||||
</div>
|
||||
<div class="sortable-unit-list">
|
||||
<label>Units:</label>
|
||||
@@ -40,7 +40,7 @@
|
||||
<a href="#" class="save-button">Save</a>
|
||||
<a href="#" class="cancel-button">Cancel</a>
|
||||
<a href="#" class="delete-icon remove-policy-data"></a>
|
||||
</li>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
@@ -51,28 +51,28 @@
|
||||
<label>Release date:<!-- <span class="description">Determines when this subsection and the units within it will be released publicly.</span>--></label>
|
||||
<div class="datepair" data-language="javascript">
|
||||
<%
|
||||
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
|
||||
%>
|
||||
<input type="text" id="start_date" name="start_date" value="${start_date.strftime('%m/%d/%Y') if start_date is not None else ''}" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
<input type="text" id="start_time" name="start_time" value="${start_date.strftime('%H:%M') if start_date is not None else ''}" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
<input type="text" id="start_time" name="start_time" value="${start_date.strftime('%H:%M') if start_date is not None else ''}" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
</div>
|
||||
% 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:
|
||||
<p class="notice">The date above differs from the release date of ${parent_item.display_name}, which is unset.
|
||||
<p class="notice">The date above differs from the release date of ${parent_item.display_name_with_default}, which is unset.
|
||||
% else:
|
||||
<p class="notice">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')}.
|
||||
<p class="notice">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
|
||||
<a href="#" class="sync-date no-spinner">Sync to ${parent_item.display_name}.</a></p>
|
||||
<a href="#" class="sync-date no-spinner">Sync to ${parent_item.display_name_with_default}.</a></p>
|
||||
% endif
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row gradable">
|
||||
<label>Graded as:</label>
|
||||
|
||||
<div class="gradable-status" data-initial-status="${subsection.metadata.get('format', 'Not Graded')}">
|
||||
|
||||
<div class="gradable-status" data-initial-status="${subsection.lms.format if subsection.lms.format is not None else 'Not Graded'}">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="due-date-input row">
|
||||
<label>Due date:</label>
|
||||
<a href="#" class="set-date">Set a due date</a>
|
||||
@@ -80,9 +80,9 @@
|
||||
<p class="date-description">
|
||||
<%
|
||||
# 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
|
||||
%>
|
||||
<input type="text" id="due_date" name="due_date" value="${due_date.strftime('%m/%d/%Y') if due_date is not None else ''}" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
<input type="text" id="due_date" name="due_date" value="${due_date.strftime('%m/%d/%Y') if due_date is not None else ''}" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
<input type="text" id="due_time" name="due_time" value="${due_date.strftime('%H:%M') if due_date is not None else ''}" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
<a href="#" class="remove-date">Remove due date</a>
|
||||
</p>
|
||||
@@ -110,7 +110,7 @@
|
||||
<script type="text/javascript" src="${static.url('js/views/grader-select-view.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/models/settings/course_grading_policy.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/overview.js')}"></script>
|
||||
|
||||
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
@@ -128,7 +128,7 @@
|
||||
window.graderTypes.course_location = new CMS.Models.Location('${parent_location}');
|
||||
window.graderTypes.reset(${course_graders|n});
|
||||
}
|
||||
|
||||
|
||||
$(".gradable-status").each(function(index, ele) {
|
||||
var gradeView = new CMS.Views.OverviewAssignmentGrader({
|
||||
el : ele,
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
<div class="description">
|
||||
<p><strong>Importing a new course will delete all content currently associated with your course
|
||||
and replace it with the contents of the uploaded file.</strong></p>
|
||||
<p>File uploads must be gzipped tar files (.tar.gz or .tgz) containing, at a minimum, a <code>course.xml</code> file.</p>
|
||||
<p>File uploads must be gzipped tar files (.tar.gz) containing, at a minimum, a <code>course.xml</code> file.</p>
|
||||
<p>Please note that if your course has any problems with auto-generated <code>url_name</code> nodes,
|
||||
re-importing your course could cause the loss of student data associated with those problems.</p>
|
||||
</div>
|
||||
<form action="${reverse('import_course', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}" method="post" enctype="multipart/form-data" class="import-form">
|
||||
<h2>Course to import:</h2>
|
||||
<p class="error-block"></p>
|
||||
<a href="#" class="choose-file-button">Choose File</a>
|
||||
<a href="#" class="choose-file-button">Choose File</a>
|
||||
<p class="file-name-block"><span class="file-name"></span><a href="#" class="choose-file-button-inline">change</a></p>
|
||||
<input type="file" name="course-data" class="file-input">
|
||||
<input type="submit" value="Replace my course with the one above" class="submit-button">
|
||||
@@ -45,13 +45,13 @@
|
||||
<%block name="jsextra">
|
||||
<script>
|
||||
(function() {
|
||||
|
||||
|
||||
var bar = $('.progress-bar');
|
||||
var fill = $('.progress-fill');
|
||||
var percent = $('.percent');
|
||||
var status = $('#status');
|
||||
var submitBtn = $('.submit-button');
|
||||
|
||||
|
||||
$('form').ajaxForm({
|
||||
beforeSend: function() {
|
||||
status.empty();
|
||||
@@ -76,7 +76,7 @@ $('form').ajaxForm({
|
||||
submitBtn.show();
|
||||
bar.hide();
|
||||
}
|
||||
});
|
||||
})();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</%block>
|
||||
@@ -8,7 +8,7 @@
|
||||
<div>${module_type}</div>
|
||||
<div>
|
||||
% for template in module_templates:
|
||||
<a class="save" data-template-id="${template.location.url()}">${template.display_name}</a>
|
||||
<a class="save" data-template-id="${template.location.url()}">${template.display_name_with_default}</a>
|
||||
% endfor
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
window.graderTypes.course_location = new CMS.Models.Location('${parent_location}');
|
||||
window.graderTypes.reset(${course_graders|n});
|
||||
}
|
||||
|
||||
|
||||
$(".gradable-status").each(function(index, ele) {
|
||||
var gradeView = new CMS.Views.OverviewAssignmentGrader({
|
||||
el : ele,
|
||||
@@ -40,7 +40,7 @@
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
|
||||
<div class="main-wrapper">
|
||||
<div class="inner-wrapper">
|
||||
<article class="courseware-overview" data-course-id="${context_course.location.url()}">
|
||||
<article class="courseware-overview" data-course-id="${context_course.location.url()}">
|
||||
% for section in sections:
|
||||
<section class="courseware-section branch" data-id="${section.location}">
|
||||
<header>
|
||||
@@ -154,16 +154,16 @@
|
||||
|
||||
<div class="item-details" data-id="${section.location}">
|
||||
<h3 class="section-name">
|
||||
<span data-tooltip="Edit this section's name" class="section-name-span">${section.display_name}</span>
|
||||
<span data-tooltip="Edit this section's name" class="section-name-span">${section.display_name_with_default}</span>
|
||||
<form class="section-name-edit" style="display:none">
|
||||
<input type="text" value="${section.display_name | h}" class="edit-section-name" autocomplete="off"/>
|
||||
<input type="text" value="${section.display_name_with_default | h}" class="edit-section-name" autocomplete="off"/>
|
||||
<input type="submit" class="save-button edit-section-name-save" value="Save" />
|
||||
<input type="button" class="cancel-button edit-section-name-cancel" value="Cancel" />
|
||||
</form>
|
||||
</h3>
|
||||
<div class="section-published-date">
|
||||
<%
|
||||
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 @@
|
||||
<span class="published-status"><strong>Will Release:</strong> ${start_date_str} at ${start_time_str}</span>
|
||||
<a href="#" class="edit-button" data-date="${start_date_str}" data-time="${start_time_str}" data-id="${section.location}">Edit</a>
|
||||
%endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="item-actions">
|
||||
<a href="#" data-tooltip="Delete this section" class="delete-button delete-section-button"><span class="delete-icon"></span></a>
|
||||
<a href="#" data-tooltip="Drag to reorder" class="drag-handle"></a>
|
||||
@@ -196,15 +196,15 @@
|
||||
<a href="#" data-tooltip="Expand/collapse this subsection" class="expand-collapse-icon expand"></a>
|
||||
<a href="${reverse('edit_subsection', args=[subsection.location])}">
|
||||
<span class="folder-icon"></span>
|
||||
<span class="subsection-name"><span class="subsection-name-value">${subsection.display_name}</span></span>
|
||||
<span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="gradable-status" data-initial-status="${subsection.metadata.get('format', 'Not Graded')}">
|
||||
|
||||
<div class="gradable-status" data-initial-status="${subsection.lms.format if section.lms.format is not None else 'Not Graded'}">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="item-actions">
|
||||
<a href="#" data-tooltip="Delete this subsection" class="delete-button delete-subsection-button"><span class="delete-icon"></span></a>
|
||||
<a href="#" data-tooltip="Delete this subsection" class="delete-button delete-subsection-button"><span class="delete-icon"></span></a>
|
||||
<a href="#" data-tooltip="Drag to reorder" class="drag-handle"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,17 +70,17 @@ from contentstore import utils
|
||||
<ol class="list-input">
|
||||
<li class="field text is-not-editable" id="field-course-organization">
|
||||
<label for="course-organization">Organization</label>
|
||||
<input type="text" class="long" id="course-organization" value="[Course Organization]" disabled="disabled" />
|
||||
<input title="This field is disabled: this information cannot be changed." type="text" class="long" id="course-organization" value="[Course Organization]" readonly />
|
||||
</li>
|
||||
|
||||
<li class="field text is-not-editable" id="field-course-number">
|
||||
<label for="course-number">Course Number</label>
|
||||
<input type="text" class="short" id="course-number" value="[Course No.]" disabled="disabled">
|
||||
<input title="This field is disabled: this information cannot be changed." type="text" class="short" id="course-number" value="[Course No.]" readonly>
|
||||
</li>
|
||||
|
||||
<li class="field text is-not-editable" id="field-course-name">
|
||||
<label for="course-name">Course Name</label>
|
||||
<input type="text" class="long" id="course-name" value="[Course Name]" disabled="disabled" />
|
||||
<input title="This field is disabled: this information cannot be changed." type="text" class="long" id="course-name" value="[Course Name]" readonly />
|
||||
</li>
|
||||
</ol>
|
||||
<span class="tip tip-stacked">These are used in <a rel="external" href="${utils.get_lms_link_for_about_page(course_location)}" />your course URL</a>, and cannot be changed</span>
|
||||
|
||||
@@ -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();
|
||||
<span class="tip">Manually Edit Course Policy Values (JSON Key / Value pairs)</span>
|
||||
</header>
|
||||
|
||||
<p class="instructions"><strong>Warning</strong>: Add only manual policy data that you are familiar
|
||||
with.</p>
|
||||
<p class="instructions"><strong>Warning</strong>: Do not modify these policies unless you are familiar with their purpose.</p>
|
||||
|
||||
<ul class="list-input course-advanced-policy-list enum">
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="actions">
|
||||
<a href="#" class="button new-button new-advanced-policy-item add-policy-data">
|
||||
<span class="plus-icon white"></span>New Manual Policy
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</article>
|
||||
@@ -80,9 +72,9 @@ editor.render();
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<div class="bit">
|
||||
<h3 class="title-3">How will these settings be used?</h3>
|
||||
<p>Manual policies are JSON-based key and value pairs that allow you add additional settings which edX Studio will use when generating your course.</p>
|
||||
<p>Manual policies are JSON-based key and value pairs that give you control over specific course settings that edX Studio will use when displaying and running your course.</p>
|
||||
|
||||
<p>Any policies you define here will override any other information you've defined elsewhere in Studio. With this in mind, please be very careful and do not add policies that you are unfamiliar with (both their purpose and their syntax).</p>
|
||||
<p>Any policies you modify here will override any other information you've defined elsewhere in Studio. With this in mind, please be very careful and do not edit policies that you are unfamiliar with (both their purpose and their syntax).</p>
|
||||
</div>
|
||||
|
||||
<div class="bit">
|
||||
|
||||
@@ -43,12 +43,12 @@
|
||||
</div>
|
||||
<div class="main-column">
|
||||
<article class="unit-body window">
|
||||
<p class="unit-name-input"><label>Display Name:</label><input type="text" value="${unit.display_name | h}" class="unit-display-name-input" /></p>
|
||||
<p class="unit-name-input"><label>Display Name:</label><input type="text" value="${unit.display_name_with_default | h}" class="unit-display-name-input" /></p>
|
||||
<ol class="components">
|
||||
% for id in components:
|
||||
<li class="component" data-id="${id}"/>
|
||||
% endfor
|
||||
<li class="new-component-item adding">
|
||||
<li class="new-component-item adding">
|
||||
<div class="new-component">
|
||||
<h5>Add New Component</h5>
|
||||
<ul class="new-component-type">
|
||||
@@ -85,7 +85,7 @@
|
||||
<span class="name"> ${name}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
% else:
|
||||
<li class="editor-md">
|
||||
<a href="#" data-location="${location}">
|
||||
@@ -94,7 +94,7 @@
|
||||
</li>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
|
||||
%endfor
|
||||
</ul>
|
||||
</div>
|
||||
@@ -102,20 +102,20 @@
|
||||
<div class="tab" id="tab2">
|
||||
<ul class="new-component-template">
|
||||
% for name, location, has_markdown, is_empty in templates:
|
||||
% if not has_markdown:
|
||||
% if not has_markdown:
|
||||
% if is_empty:
|
||||
<li class="editor-manual empty">
|
||||
<a href="#" data-location="${location}">
|
||||
<span class="name">${name}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
% else:
|
||||
<li class="editor-manual">
|
||||
<a href="#" data-location="${location}">
|
||||
<span class="name"> ${name}</span>
|
||||
</a>
|
||||
|
||||
|
||||
</li>
|
||||
% endif
|
||||
% endif
|
||||
@@ -146,13 +146,13 @@
|
||||
<div class="row published-alert">
|
||||
<p class="edit-draft-message">This unit has been published. To make changes, you must <a href="#" class="create-draft">edit a draft</a>.</p>
|
||||
<p class="publish-draft-message">This is a draft of the published unit. To update the live version, you must <a href="#" class="publish-draft">replace it with this draft</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row status">
|
||||
<p>This unit is scheduled to be released to <strong>students</strong>
|
||||
<p>This unit is scheduled to be released to <strong>students</strong>
|
||||
% if release_date is not None:
|
||||
on <strong>${release_date}</strong>
|
||||
% endif
|
||||
with the subsection <a href="${reverse('edit_subsection', kwargs={'location': subsection.location})}">"${subsection.display_name}"</a></p>
|
||||
% endif
|
||||
with the subsection <a href="${reverse('edit_subsection', kwargs={'location': subsection.location})}">"${subsection.display_name_with_default}"</a></p>
|
||||
</div>
|
||||
<div class="row unit-actions">
|
||||
<a href="#" class="delete-draft delete-button">Delete Draft</a>
|
||||
@@ -167,18 +167,18 @@
|
||||
<div><input type="text" class="url" value="/courseware/${section.url_name}/${subsection.url_name}" disabled /></div>
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#" class="section-item">${section.display_name}</a>
|
||||
<a href="#" class="section-item">${section.display_name_with_default}</a>
|
||||
<ol>
|
||||
<li>
|
||||
<a href="${reverse('edit_subsection', args=[subsection.location])}" class="section-item">
|
||||
<span class="folder-icon"></span>
|
||||
<span class="subsection-name"><span class="subsection-name-value">${subsection.display_name}</span></span>
|
||||
<span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
|
||||
</a>
|
||||
${units.enum_units(subsection, actions=False, selected=unit.location)}
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,24 +5,24 @@
|
||||
|
||||
<div class="wrapper wrapper-left ">
|
||||
<h1 class="branding"><a href="/">edX Studio</a></h1>
|
||||
|
||||
|
||||
% if context_course:
|
||||
<% ctx_loc = context_course.location %>
|
||||
<div class="info-course">
|
||||
<h2 class="sr">Current Course:</h2>
|
||||
<a href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">
|
||||
<span class="course-org">${ctx_loc.org}</span><span class="course-number">${ctx_loc.course}</span>
|
||||
<span class="course-title" title="${context_course.display_name}">${context_course.display_name}</span>
|
||||
<span class="course-title" title="${context_course.display_name_with_default}">${context_course.display_name_with_default}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<nav class="nav-course primary nav-dropdown" role="navigation">
|
||||
<h2 class="sr">${context_course.display_name}'s Navigation:</h2>
|
||||
<h2 class="sr">${context_course.display_name_with_default}'s Navigation:</h2>
|
||||
|
||||
<ol>
|
||||
<li class="nav-item nav-course-courseware">
|
||||
<h3 class="title"><span class="label-prefix">Course </span>Content <i class="ss-icon ss-symbolicons-block icon-expand">▾</i></h3>
|
||||
|
||||
|
||||
<div class="wrapper wrapper-nav-sub">
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
@@ -32,12 +32,12 @@
|
||||
<li class="nav-item nav-course-courseware-uploads"><a href="${reverse('asset_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Files & Uploads</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="nav-item nav-course-settings">
|
||||
<h3 class="title"><span class="label-prefix">Course </span>Settings <i class="ss-icon ss-symbolicons-block icon-expand">▾</i></h3>
|
||||
|
||||
|
||||
<div class="wrapper wrapper-nav-sub">
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
@@ -47,12 +47,12 @@
|
||||
<li class="nav-item nav-course-settings-advanced"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Advanced Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="nav-item nav-course-tools">
|
||||
<h3 class="title">Tools <i class="ss-icon ss-symbolicons-block icon-expand">▾</i></h3>
|
||||
|
||||
|
||||
<div class="wrapper wrapper-nav-sub">
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
@@ -66,16 +66,16 @@
|
||||
</nav>
|
||||
% endif
|
||||
</div>
|
||||
|
||||
|
||||
<div class="wrapper wrapper-right">
|
||||
% if user.is_authenticated():
|
||||
% if user.is_authenticated():
|
||||
<nav class="nav-account nav-is-signedin nav-dropdown">
|
||||
<h2 class="sr">Currently logged in as:</h2>
|
||||
<ol>
|
||||
<li class="nav-item nav-account-username">
|
||||
<a href="#" class="title">
|
||||
<span class="account-username">
|
||||
<i class="ss-icon ss-symbolicons-standard icon-user">👤</i>
|
||||
<i class="ss-icon ss-symbolicons-standard icon-user">👤</i>
|
||||
${ user.username }
|
||||
</span>
|
||||
<i class="ss-icon ss-symbolicons-block icon-expand">▾</i>
|
||||
@@ -111,7 +111,7 @@
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
% endif
|
||||
% endif
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
% if metadata:
|
||||
<%
|
||||
import hashlib
|
||||
hlskey = hashlib.md5(module.location.url()).hexdigest()
|
||||
%>
|
||||
<section class="metadata_edit">
|
||||
<ul>
|
||||
% for keyname in editable_metadata_fields:
|
||||
% for field_name, field_value in editable_metadata_fields.items():
|
||||
<li>
|
||||
% if keyname=='source_code':
|
||||
<a href="#hls-modal-${hlskey}" style="color:yellow;" id="hls-trig-${hlskey}" >Edit High Level Source</a>
|
||||
% else:
|
||||
<label>${keyname}:</label>
|
||||
<input type='text' data-metadata-name='${keyname}' value='${metadata[keyname]}' size='60' />
|
||||
% endif
|
||||
% if field_name == 'source_code':
|
||||
<a href="#hls-modal-${hlskey}" style="color:yellow;" id="hls-trig-${hlskey}" >Edit High Level Source</a>
|
||||
% else:
|
||||
<label>${field_name}:</label>
|
||||
<input type='text' data-metadata-name='${field_name}' value='${field_value}' size='60' />
|
||||
% endif
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
@@ -22,4 +21,3 @@
|
||||
% endif
|
||||
|
||||
</section>
|
||||
% endif
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<section class="cal">
|
||||
<header class="wip">
|
||||
<ul class="actions">
|
||||
<li><a href="#">Timeline view</a></li>
|
||||
<li><a href="#">Multi-Module edit</a></li>
|
||||
</ul>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<h2>Sort:</h2>
|
||||
<select>
|
||||
<option value="">Linear Order</option>
|
||||
<option value="">Recently Modified</option>
|
||||
<option value="">Type</option>
|
||||
<option value="">Alphabetically</option>
|
||||
</select>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2>Filter:</h2>
|
||||
<select>
|
||||
<option value="">All content</option>
|
||||
<option value="">Videos</option>
|
||||
<option value="">Problems</option>
|
||||
<option value="">Labs</option>
|
||||
<option value="">Tutorials</option>
|
||||
<option value="">HTML</option>
|
||||
</select>
|
||||
<a href="#" class="more">More</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Hide goals</a>
|
||||
</li>
|
||||
<li class="search">
|
||||
<input type="search" name="" id="" value="" placeholder="Search" />
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<ol id="weeks">
|
||||
% for week in weeks:
|
||||
<li class="week" data-id="${week.location.url()}">
|
||||
<header>
|
||||
<h1><a href="#" class="week-edit">${week.url_name}</a></h1>
|
||||
<ul>
|
||||
% if 'goals' in week.metadata:
|
||||
% for goal in week.metadata['goals']:
|
||||
<li class="goal editable">${goal}</li>
|
||||
% endfor
|
||||
% else:
|
||||
<li class="goal editable">Please create a learning goal for this week</li>
|
||||
% endif
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<ul class="modules">
|
||||
% for module in week.get_children():
|
||||
<li class="module"
|
||||
data-id="${module.location.url()}"
|
||||
data-type="${module.js_module_name}"
|
||||
data-preview-type="${module.module_class.js_module_name}">
|
||||
|
||||
<a href="#" class="module-edit">${module.display_name}</a>
|
||||
</li>
|
||||
% endfor
|
||||
<%include file="module-dropdown.html"/>
|
||||
</ul>
|
||||
</li>
|
||||
%endfor
|
||||
</ol>
|
||||
|
||||
<section class="new-section">
|
||||
<a href="#" class="wip" >+ Add New Section</a>
|
||||
|
||||
<section class="hidden">
|
||||
<form>
|
||||
<ul>
|
||||
<li>
|
||||
<input type="text" name="" id="" placeholder="Section title" />
|
||||
</li>
|
||||
<li>
|
||||
<select>
|
||||
<option>Blank</option>
|
||||
<option>6.002x</option>
|
||||
<option>6.00x</option>
|
||||
</select>
|
||||
</li>
|
||||
<li>
|
||||
<input type="submit" value="Save and edit week" class="edit-week" />
|
||||
|
||||
<div>
|
||||
<a href="#" class="close">Save without edit</a>
|
||||
<a href="#" class="close">cancel</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<a href="#" class="module-edit"
|
||||
data-id="${child.location.url()}"
|
||||
data-type="${child.js_module_name}"
|
||||
data-preview-type="${child.module_class.js_module_name}">${child.display_name}</a>
|
||||
data-preview-type="${child.module_class.js_module_name}">${child.display_name_with_default}</a>
|
||||
<a href="#" class="draggable">handle</a>
|
||||
</li>
|
||||
%endfor
|
||||
|
||||
@@ -22,7 +22,7 @@ This def will enumerate through a passed in subsection and list all of the units
|
||||
<div class="section-item ${selected_class}">
|
||||
<a href="${reverse('edit_unit', args=[unit.location])}" class="${unit_state}-item">
|
||||
<span class="${unit.category}-icon"></span>
|
||||
<span class="unit-name">${unit.display_name}</span>
|
||||
<span class="unit-name">${unit.display_name_with_default}</span>
|
||||
</a>
|
||||
% if actions:
|
||||
<div class="item-actions">
|
||||
@@ -39,7 +39,7 @@ This def will enumerate through a passed in subsection and list all of the units
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</%def>
|
||||
</%def>
|
||||
|
||||
|
||||
|
||||
|
||||
30
cms/xmodule_namespace.py
Normal file
30
cms/xmodule_namespace.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import datetime
|
||||
|
||||
from xblock.core import Namespace, Boolean, Scope, ModelType, String
|
||||
|
||||
|
||||
class StringyBoolean(Boolean):
|
||||
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):
|
||||
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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -139,17 +139,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "<font color='red'>Yes!</font>" if (now > mstart) else "<font color='green'>Not yet</font>"
|
||||
|
||||
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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'<span class="math">5[Ni(NH<sub>3</sub>)<sub>4</sub>]<sup>2+</sup>+<sup>5</sup>⁄<sub>2</sub>SO<sub>4</sub><sup>2-</sup></span>'
|
||||
log(out + ' ------- ' + correct, 'html')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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?:
|
||||
|
||||
@@ -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 <script> context; evaluate
|
||||
that function to obtain list of hint, hintmode for each answer_id.
|
||||
# Hint is determined by a function defined in the <script> context; evaluate
|
||||
# that function to obtain list of hint, hintmode for each answer_id.
|
||||
|
||||
The function should take arguments (answer_ids, student_answers, new_cmap, old_cmap)
|
||||
and it should modify new_cmap as appropriate.
|
||||
# The function should take arguments (answer_ids, student_answers, new_cmap, old_cmap)
|
||||
# and it should modify new_cmap as appropriate.
|
||||
|
||||
We may extend this in the future to add another argument which provides a
|
||||
callback procedure to a social hint generation system.
|
||||
'''
|
||||
# We may extend this in the future to add another argument which provides a
|
||||
# callback procedure to a social hint generation system.
|
||||
if not hintfn in self.context:
|
||||
msg = 'missing specified hint function %s in script context' % hintfn
|
||||
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>')
|
||||
@@ -329,7 +327,7 @@ class LoncapaResponse(object):
|
||||
""" Render a <div> for a message that applies to the entire response.
|
||||
|
||||
*response_msg* is a string, which may contain XHTML markup
|
||||
|
||||
|
||||
Returns an etree element representing the response message <div> """
|
||||
# First try wrapping the text in a <div> and parsing
|
||||
# it as an XHTML tree
|
||||
@@ -872,7 +870,7 @@ class CustomResponse(LoncapaResponse):
|
||||
Custom response. The python code to be run should be in <answer>...</answer>
|
||||
or in a <script>...</script>
|
||||
'''
|
||||
snippets = [{'snippet': """<customresponse>
|
||||
snippets = [{'snippet': r"""<customresponse>
|
||||
<text>
|
||||
<br/>
|
||||
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': '''<problem>
|
||||
snippets = [{'snippet': r'''<problem>
|
||||
<text>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: <br/>
|
||||
<symbolicresponse answer="">
|
||||
@@ -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) ])
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from calc import evaluator, UndefinedVariable
|
||||
from .calc import evaluator, UndefinedVariable
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
#
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
import requests
|
||||
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
log = logging.getLogger(__name__)
|
||||
dateformat = '%Y%m%d%H%M%S'
|
||||
|
||||
|
||||
|
||||
@@ -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'],
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\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:
|
||||
<D> day[s] (optional)
|
||||
<H> hour[s] (optional)
|
||||
<M> minute[s] (optional)
|
||||
<S> 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><text><span class="inline-error">'
|
||||
'Problem %s has an error:</span>%s</text></problem>' %
|
||||
(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 = '<div class="capa_reset">'\
|
||||
@@ -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 <solution>) may have embedded images
|
||||
# answers (eg <solution>) 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
|
||||
|
||||
@@ -6,19 +6,47 @@ 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
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
|
||||
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
|
||||
|
||||
VERSION_TUPLES = (
|
||||
('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module),
|
||||
('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module, V1_SETTINGS_ATTRIBUTES, V1_STUDENT_ATTRIBUTES),
|
||||
)
|
||||
|
||||
DEFAULT_VERSION = 1
|
||||
DEFAULT_VERSION = str(DEFAULT_VERSION)
|
||||
|
||||
|
||||
class CombinedOpenEndedModule(XModule):
|
||||
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 = Integer(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 +77,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 +87,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,25 +127,15 @@ 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 = {}
|
||||
|
||||
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
|
||||
if self.task_states is None:
|
||||
self.task_states = []
|
||||
|
||||
versions = [i[0] for i in VERSION_TUPLES]
|
||||
descriptors = [i[1] for i in VERSION_TUPLES]
|
||||
modules = [i[2] for i in VERSION_TUPLES]
|
||||
settings_attributes = [i[3] for i in VERSION_TUPLES]
|
||||
student_attributes = [i[4] for i in VERSION_TUPLES]
|
||||
version_error_string = "Could not find version {0}, using version {1} instead"
|
||||
|
||||
try:
|
||||
version_index = versions.index(self.version)
|
||||
@@ -128,22 +145,31 @@ class CombinedOpenEndedModule(XModule):
|
||||
self.version = DEFAULT_VERSION
|
||||
version_index = versions.index(self.version)
|
||||
|
||||
self.student_attributes = student_attributes[version_index]
|
||||
self.settings_attributes = settings_attributes[version_index]
|
||||
|
||||
attributes = self.student_attributes + self.settings_attributes
|
||||
|
||||
static_data = {
|
||||
'rewrite_content_links': self.rewrite_content_links,
|
||||
}
|
||||
|
||||
instance_state = {k: getattr(self, k) for k in attributes}
|
||||
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_definition = descriptors[version_index].definition_from_xml(etree.fromstring(self.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=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 +177,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 +187,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
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
<conditional condition="require_completed" required="tag/url_name1&tag/url_name2">
|
||||
<conditional sources="i4x://.../problem_1; i4x://.../problem_2" completed="True">
|
||||
<show sources="i4x://.../test_6; i4x://.../Avi_resources"/>
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
|
||||
<conditional condition="require_attempted" required="tag/url_name1&tag/url_name2">
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
<conditional> 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'),
|
||||
<conditional> 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: <tag attribute in xml>
|
||||
# value: <name of module attribute>
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
221
common/lib/xmodule/xmodule/css/poll/display.scss
Normal file
221
common/lib/xmodule/xmodule/css/poll/display.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
69
common/lib/xmodule/xmodule/fields.py
Normal file
69
common/lib/xmodule/xmodule/fields.py
Normal file
@@ -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<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
|
||||
class Timedelta(ModelType):
|
||||
def from_json(self, time_str):
|
||||
"""
|
||||
time_str: A string with the following components:
|
||||
<D> day[s] (optional)
|
||||
<H> hour[s] (optional)
|
||||
<M> minute[s] (optional)
|
||||
<S> 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)
|
||||
@@ -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):
|
||||
@@ -87,7 +89,7 @@ class FolditModule(XModule):
|
||||
from foldit.models import Score
|
||||
|
||||
leaders = [(e['username'], e['score']) for e in Score.get_tops_n(10)]
|
||||
leaders.sort(key=lambda x: x[1])
|
||||
leaders.sort(key=lambda x: -x[1])
|
||||
|
||||
return leaders
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
# <root> added for interface compatibility with xmltodict.parse
|
||||
# class added for javascript's part purposes
|
||||
return json.dumps(xmltodict.parse('<root class="' + self.html_class +
|
||||
'">' + self.definition['configuration'] + '</root>'))
|
||||
'">' + self.configuration + '</root>'))
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
54
common/lib/xmodule/xmodule/js/src/poll/logme.js
Normal file
54
common/lib/xmodule/xmodule/js/src/poll/logme.js
Normal file
@@ -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)
|
||||
5
common/lib/xmodule/xmodule/js/src/poll/poll.js
Normal file
5
common/lib/xmodule/xmodule/js/src/poll/poll.js
Normal file
@@ -0,0 +1,5 @@
|
||||
window.Poll = function (el) {
|
||||
RequireJS.require(['PollMain'], function (PollMain) {
|
||||
new PollMain(el);
|
||||
});
|
||||
};
|
||||
323
common/lib/xmodule/xmodule/js/src/poll/poll_main.js
Normal file
323
common/lib/xmodule/xmodule/js/src/poll/poll_main.js
Normal file
@@ -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(
|
||||
'<h3>Error!</h3>' +
|
||||
'<p>XML data format changed. List of answers was modified, but poll data was not updated.</p>'
|
||||
);
|
||||
|
||||
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 = $('<div />').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 = $('<div class="poll_answer"></div>');
|
||||
|
||||
answer.questionEl = $('<div class="question"></div>');
|
||||
answer.buttonEl = $('<div class="button"></div>');
|
||||
answer.textEl = $('<div class="text"></div>');
|
||||
answer.questionEl.append(answer.buttonEl);
|
||||
answer.questionEl.append(answer.textEl);
|
||||
|
||||
answer.el.append(answer.questionEl);
|
||||
|
||||
answer.statsEl = $('<div class="stats"></div>');
|
||||
answer.barEl = $('<div class="bar"></div>');
|
||||
answer.percentEl = $('<div class="percent"></div>');
|
||||
answer.barEl.append(answer.percentEl);
|
||||
answer.numberEl = $('<div class="number"></div>');
|
||||
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 = $('<div class="button reset-button">Change your vote</div>');
|
||||
|
||||
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));
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
10
common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee
Normal file
10
common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
67
common/lib/xmodule/xmodule/modulestore/inheritance.py
Normal file
67
common/lib/xmodule/xmodule/modulestore/inheritance.py
Normal file
@@ -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
|
||||
@@ -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,44 @@ 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 +379,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():
|
||||
@@ -293,7 +392,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
# 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 +506,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 +540,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 +585,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 +608,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 +628,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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <content_dir>/info or <content_dir>/info/<url_name>
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 <img src='foo.jpg' /> -> <img src='bar.jpg'>
|
||||
# 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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from grading_service_module import GradingService
|
||||
from .grading_service_module import GradingService
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user