Merge github.com:MITx/mitx into feature/kevin/groups_ui_changes
This commit is contained in:
@@ -5,6 +5,7 @@ from logging.handlers import SysLogHandler
|
||||
|
||||
LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
|
||||
|
||||
def get_logger_config(log_dir,
|
||||
logging_env="no_env",
|
||||
tracking_filename="tracking.log",
|
||||
@@ -13,7 +14,8 @@ def get_logger_config(log_dir,
|
||||
syslog_addr=None,
|
||||
debug=False,
|
||||
local_loglevel='INFO',
|
||||
console_loglevel=None):
|
||||
console_loglevel=None,
|
||||
service_variant=None):
|
||||
|
||||
"""
|
||||
|
||||
@@ -38,14 +40,21 @@ def get_logger_config(log_dir,
|
||||
if console_loglevel is None or console_loglevel not in LOG_LEVELS:
|
||||
console_loglevel = 'DEBUG' if debug else 'INFO'
|
||||
|
||||
if service_variant is None:
|
||||
# default to a blank string so that if SERVICE_VARIANT is not
|
||||
# set we will not log to a sub directory
|
||||
service_variant = ''
|
||||
|
||||
hostname = platform.node().split(".")[0]
|
||||
syslog_format = ("[%(name)s][env:{logging_env}] %(levelname)s "
|
||||
syslog_format = ("[service_variant={service_variant}]"
|
||||
"[%(name)s][env:{logging_env}] %(levelname)s "
|
||||
"[{hostname} %(process)d] [%(filename)s:%(lineno)d] "
|
||||
"- %(message)s").format(
|
||||
logging_env=logging_env, hostname=hostname)
|
||||
"- %(message)s").format(service_variant=service_variant,
|
||||
logging_env=logging_env,
|
||||
hostname=hostname)
|
||||
|
||||
handlers = ['console', 'local'] if debug else ['console',
|
||||
'syslogger-remote', 'local']
|
||||
'syslogger-remote', 'local']
|
||||
|
||||
logger_config = {
|
||||
'version': 1,
|
||||
@@ -78,11 +87,6 @@ def get_logger_config(log_dir,
|
||||
}
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': handlers,
|
||||
'propagate': True,
|
||||
'level': 'INFO'
|
||||
},
|
||||
'tracking': {
|
||||
'handlers': ['tracking'],
|
||||
'level': 'DEBUG',
|
||||
@@ -93,16 +97,6 @@ def get_logger_config(log_dir,
|
||||
'level': 'DEBUG',
|
||||
'propagate': False
|
||||
},
|
||||
'mitx': {
|
||||
'handlers': handlers,
|
||||
'level': 'DEBUG',
|
||||
'propagate': False
|
||||
},
|
||||
'keyedcache': {
|
||||
'handlers': handlers,
|
||||
'level': 'DEBUG',
|
||||
'propagate': False
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +122,9 @@ def get_logger_config(log_dir,
|
||||
},
|
||||
})
|
||||
else:
|
||||
# for production environments we will only
|
||||
# log INFO and up
|
||||
logger_config['loggers']['']['level'] = 'INFO'
|
||||
logger_config['handlers'].update({
|
||||
'local': {
|
||||
'level': local_loglevel,
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/jasmine-jquery.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/jquery.cookie.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/CodeMirror/codemirror.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/mathjax-MathJax-c9db6ac/MathJax.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/tiny_mce/jquery.tinymce.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/tiny_mce/tiny_mce.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/RequireJS.js"></script>
|
||||
|
||||
@@ -20,6 +20,7 @@ setup(
|
||||
"book = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"chapter = xmodule.seq_module:SequenceDescriptor",
|
||||
"combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor",
|
||||
"conditional = xmodule.conditional_module:ConditionalDescriptor",
|
||||
"course = xmodule.course_module:CourseDescriptor",
|
||||
"customtag = xmodule.template_module:CustomTagDescriptor",
|
||||
"discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
|
||||
@@ -356,7 +356,7 @@ class CapaModule(XModule):
|
||||
id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "</div>"
|
||||
|
||||
# now do the substitutions which are filesystem based, e.g. '/static/' prefixes
|
||||
return self.system.replace_urls(html)
|
||||
return self.system.replace_urls(html, self.metadata['data_dir'], course_namespace=self.location)
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
'''
|
||||
@@ -398,6 +398,15 @@ class CapaModule(XModule):
|
||||
|
||||
return False
|
||||
|
||||
def is_completed(self):
|
||||
# used by conditional module
|
||||
# return self.answer_available()
|
||||
return self.lcp.done
|
||||
|
||||
def is_attempted(self):
|
||||
# used by conditional module
|
||||
return self.attempts > 0
|
||||
|
||||
def answer_available(self):
|
||||
''' Is the user allowed to see an answer?
|
||||
'''
|
||||
@@ -461,7 +470,7 @@ class CapaModule(XModule):
|
||||
new_answers = dict()
|
||||
for answer_id in answers:
|
||||
try:
|
||||
new_answer = {answer_id: self.system.replace_urls(answers[answer_id])}
|
||||
new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.metadata['data_dir'], course_namespace=self.location)}
|
||||
except TypeError:
|
||||
log.debug('Unable to perform URL substitution on answers[%s]: %s' % (answer_id, answers[answer_id]))
|
||||
new_answer = {answer_id: answers[answer_id]}
|
||||
@@ -669,18 +678,18 @@ class CapaDescriptor(RawDescriptor):
|
||||
# TODO (vshnayder): do problems have any other metadata? Do they
|
||||
# actually use type and points?
|
||||
metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points')
|
||||
|
||||
|
||||
def get_context(self):
|
||||
_context = RawDescriptor.get_context(self)
|
||||
_context.update({'markdown': self.metadata.get('markdown', '')})
|
||||
return _context
|
||||
|
||||
|
||||
@property
|
||||
def editable_metadata_fields(self):
|
||||
"""Remove metadata from the editable fields since it has its own editor"""
|
||||
subset = super(CapaDescriptor,self).editable_metadata_fields
|
||||
if 'markdown' in subset:
|
||||
subset.remove('markdown')
|
||||
subset.remove('markdown')
|
||||
return subset
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,29 @@ MAX_ATTEMPTS = 10000
|
||||
# Overriden by max_score specified in xml.
|
||||
MAX_SCORE = 1
|
||||
|
||||
#The highest score allowed for the overall xmodule and for each rubric point
|
||||
MAX_SCORE_ALLOWED = 3
|
||||
|
||||
#If true, default behavior is to score module as a practice problem. Otherwise, no grade at all is shown in progress
|
||||
#Metadata overrides this.
|
||||
IS_SCORED = False
|
||||
|
||||
#If true, then default behavior is to require a file upload or pasted link from a student for this problem.
|
||||
#Metadata overrides this.
|
||||
ACCEPT_FILE_UPLOAD = False
|
||||
|
||||
#Contains all reasonable bool and case combinations of True
|
||||
TRUE_DICT = ["True", True, "TRUE", "true"]
|
||||
|
||||
HUMAN_TASK_TYPE = {
|
||||
'selfassessment' : "Self Assessment",
|
||||
'openended' : "External Grader",
|
||||
}
|
||||
|
||||
class IncorrectMaxScoreError(Exception):
|
||||
def __init__(self, msg):
|
||||
self.msg = msg
|
||||
|
||||
class CombinedOpenEndedModule(XModule):
|
||||
"""
|
||||
This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc).
|
||||
@@ -135,24 +158,31 @@ class CombinedOpenEndedModule(XModule):
|
||||
#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
|
||||
|
||||
# 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))
|
||||
|
||||
if self._max_score > MAX_SCORE_ALLOWED:
|
||||
error_message = "Max score {0} is higher than max score allowed {1} for location {2}".format(self._max_score,
|
||||
MAX_SCORE_ALLOWED, location)
|
||||
log.error(error_message)
|
||||
raise IncorrectMaxScoreError(error_message)
|
||||
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
try:
|
||||
rubric_feedback = rubric_renderer.render_rubric(stringify_children(definition['rubric']))
|
||||
except RubricParsingError:
|
||||
log.error("Failed to parse rubric in location: {1}".format(location))
|
||||
raise
|
||||
rubric_string = stringify_children(definition['rubric'])
|
||||
rubric_renderer.check_if_rubric_is_parseable(rubric_string, location, MAX_SCORE_ALLOWED)
|
||||
|
||||
#Static data is passed to the child modules to render
|
||||
self.static_data = {
|
||||
'max_score': self._max_score,
|
||||
'max_attempts': self.max_attempts,
|
||||
'prompt': definition['prompt'],
|
||||
'rubric': definition['rubric'],
|
||||
'display_name': self.display_name
|
||||
'display_name': self.display_name,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
self.task_xml = definition['task_xml']
|
||||
@@ -245,13 +275,13 @@ class CombinedOpenEndedModule(XModule):
|
||||
elif current_task_state is None and self.current_task_number > 0:
|
||||
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,
|
||||
'version' : self.STATE_VERSION,
|
||||
'max_score' : self._max_score,
|
||||
'attempts' : 0,
|
||||
'created' : True,
|
||||
'history' : [{'answer' : str(last_response)}],
|
||||
current_task_state = json.dumps({
|
||||
'state': self.ASSESSING,
|
||||
'version': self.STATE_VERSION,
|
||||
'max_score': self._max_score,
|
||||
'attempts': 0,
|
||||
'created': True,
|
||||
'history': [{'answer': last_response}],
|
||||
})
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
@@ -265,7 +295,6 @@ class CombinedOpenEndedModule(XModule):
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
instance_state=current_task_state)
|
||||
|
||||
log.debug(current_task_state)
|
||||
return True
|
||||
|
||||
def check_allow_reset(self):
|
||||
@@ -304,7 +333,8 @@ class CombinedOpenEndedModule(XModule):
|
||||
'task_count': len(self.task_xml),
|
||||
'task_number': self.current_task_number + 1,
|
||||
'status': self.get_status(),
|
||||
'display_name': self.display_name
|
||||
'display_name': self.display_name,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
return context
|
||||
@@ -392,6 +422,15 @@ class CombinedOpenEndedModule(XModule):
|
||||
last_correctness = task.is_last_response_correct()
|
||||
max_score = task.max_score()
|
||||
state = task.state
|
||||
if task_type in HUMAN_TASK_TYPE:
|
||||
human_task_name = HUMAN_TASK_TYPE[task_type]
|
||||
else:
|
||||
human_task_name = task_type
|
||||
|
||||
if state in task.HUMAN_NAMES:
|
||||
human_state = task.HUMAN_NAMES[state]
|
||||
else:
|
||||
human_state = state
|
||||
last_response_dict = {
|
||||
'response': last_response,
|
||||
'score': last_score,
|
||||
@@ -399,7 +438,8 @@ class CombinedOpenEndedModule(XModule):
|
||||
'type': task_type,
|
||||
'max_score': max_score,
|
||||
'state': state,
|
||||
'human_state': task.HUMAN_NAMES[state],
|
||||
'human_state': human_state,
|
||||
'human_task': human_task_name,
|
||||
'correct': last_correctness,
|
||||
'min_score_to_attempt': min_score_to_attempt,
|
||||
'max_score_to_attempt': max_score_to_attempt,
|
||||
@@ -547,6 +587,63 @@ class CombinedOpenEndedModule(XModule):
|
||||
|
||||
return status_html
|
||||
|
||||
def check_if_done_and_scored(self):
|
||||
"""
|
||||
Checks if the object is currently in a finished state (either student didn't meet criteria to move
|
||||
to next step, in which case they are in the allow_reset state, or they are done with the question
|
||||
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
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
Score the student received on the problem, or None if there is no
|
||||
score.
|
||||
|
||||
Returns:
|
||||
dictionary
|
||||
{'score': integer, from 0 to get_max_score(),
|
||||
'total': get_max_score()}
|
||||
"""
|
||||
max_score = None
|
||||
score = None
|
||||
if self.check_if_done_and_scored():
|
||||
last_response = self.get_last_response(self.current_task_number)
|
||||
max_score = last_response['max_score']
|
||||
score = last_response['score']
|
||||
|
||||
score_dict = {
|
||||
'score': score,
|
||||
'total': max_score,
|
||||
}
|
||||
|
||||
return score_dict
|
||||
|
||||
def max_score(self):
|
||||
''' Maximum score. Two notes:
|
||||
|
||||
* This is generic; in abstract, a problem could be 3/5 points on one
|
||||
randomization, and 5/7 on another
|
||||
'''
|
||||
max_score = None
|
||||
if self.check_if_done_and_scored():
|
||||
last_response = self.get_last_response(self.current_task_number)
|
||||
max_score = last_response['max_score']
|
||||
return max_score
|
||||
|
||||
def get_progress(self):
|
||||
''' Return a progress.Progress object that represents how far the
|
||||
student has gone in this module. Must be implemented to get correct
|
||||
progress tracking behavior in nesting modules like sequence and
|
||||
vertical.
|
||||
|
||||
If this module has no notion of progress, return None.
|
||||
'''
|
||||
progress_object = Progress(self.current_task_number, len(self.task_xml))
|
||||
|
||||
return progress_object
|
||||
|
||||
|
||||
class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
@@ -603,4 +700,4 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
for child in ['task']:
|
||||
add_child(child)
|
||||
|
||||
return elt
|
||||
return elt
|
||||
@@ -4,7 +4,8 @@ from lxml import etree
|
||||
log=logging.getLogger(__name__)
|
||||
|
||||
class RubricParsingError(Exception):
|
||||
pass
|
||||
def __init__(self, msg):
|
||||
self.msg = msg
|
||||
|
||||
class CombinedOpenEndedRubric(object):
|
||||
|
||||
@@ -23,15 +24,32 @@ class CombinedOpenEndedRubric(object):
|
||||
Output:
|
||||
html: the html that corresponds to the xml given
|
||||
'''
|
||||
success = False
|
||||
try:
|
||||
rubric_categories = self.extract_categories(rubric_xml)
|
||||
html = self.system.render_template('open_ended_rubric.html',
|
||||
{'categories' : rubric_categories,
|
||||
'has_score': self.has_score,
|
||||
'view_only': self.view_only})
|
||||
success = True
|
||||
except:
|
||||
raise RubricParsingError("[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml))
|
||||
return html
|
||||
return success, html
|
||||
|
||||
def check_if_rubric_is_parseable(self, rubric_string, location, max_score_allowed):
|
||||
success, rubric_feedback = self.render_rubric(rubric_string)
|
||||
if not success:
|
||||
error_message = "Could not parse rubric : {0} for location {1}".format(rubric_string, location.url())
|
||||
log.error(error_message)
|
||||
raise RubricParsingError(error_message)
|
||||
|
||||
rubric_categories = self.extract_categories(rubric_string)
|
||||
for category in rubric_categories:
|
||||
if len(category['options']) > (max_score_allowed + 1):
|
||||
error_message = "Number of score points in rubric {0} higher than the max allowed, which is {1}".format(
|
||||
len(category['options']), max_score_allowed)
|
||||
log.error(error_message)
|
||||
raise RubricParsingError(error_message)
|
||||
|
||||
def extract_categories(self, element):
|
||||
'''
|
||||
|
||||
141
common/lib/xmodule/xmodule/conditional_module.py
Normal file
141
common/lib/xmodule/xmodule/conditional_module.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
class ConditionalModule(XModule):
|
||||
'''
|
||||
Blocks child module from showing unless certain conditions are met.
|
||||
|
||||
Example:
|
||||
|
||||
<conditional condition="require_completed" required="tag/url_name1&tag/url_name2">
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
|
||||
<conditional condition="require_attempted" required="tag/url_name1&tag/url_name2">
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
|
||||
'''
|
||||
|
||||
js = {'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')]}
|
||||
|
||||
|
||||
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','')
|
||||
#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 is_condition_satisfied(self):
|
||||
self._get_required_modules()
|
||||
|
||||
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)
|
||||
|
||||
return True
|
||||
|
||||
def get_html(self):
|
||||
self.is_condition_satisfied()
|
||||
return self.system.render_template('conditional_ajax.html', {
|
||||
'element_id': self.location.html_id(),
|
||||
'id': self.id,
|
||||
'ajax_url': self.system.ajax_url,
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
if not self.is_condition_satisfied():
|
||||
context = {'module': self}
|
||||
html = self.system.render_template('conditional_module.html', context)
|
||||
return json.dumps({'html': html})
|
||||
|
||||
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]
|
||||
|
||||
return json.dumps({'html': html})
|
||||
|
||||
class ConditionalDescriptor(SequenceDescriptor):
|
||||
module_class = ConditionalModule
|
||||
|
||||
filename_extension = "xml"
|
||||
|
||||
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 (tag, name) in required_module_list:
|
||||
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)
|
||||
|
||||
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]
|
||||
|
||||
@@ -442,6 +442,14 @@ section.open-ended-child {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
span.short-form-response {
|
||||
padding: 9px;
|
||||
background: #F6F6F6;
|
||||
border: 1px solid #ddd;
|
||||
border-top: 0;
|
||||
margin-bottom: 20px;
|
||||
@include clearfix;
|
||||
}
|
||||
|
||||
.grader-status {
|
||||
padding: 9px;
|
||||
|
||||
@@ -117,7 +117,7 @@ th {
|
||||
table td, th {
|
||||
margin: 20px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc !important;
|
||||
border: 1px solid #ccc;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
123
common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html
Normal file
123
common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html
Normal file
@@ -0,0 +1,123 @@
|
||||
<section class="course-content">
|
||||
<section class="xmodule_display xmodule_CombinedOpenEndedModule" data-type="CombinedOpenEnded">
|
||||
<section id="combined-open-ended" class="combined-open-ended" data-ajax-url="/courses/MITx/6.002x/2012_Fall/modx/i4x://MITx/6.002x/combinedopenended/CombinedOE" data-allow_reset="False" data-state="assessing" data-task-count="2" data-task-number="1">
|
||||
|
||||
<h2>Problem 1</h2>
|
||||
<div class="status-container">
|
||||
<h4>Status</h4>
|
||||
<div class="status-elements">
|
||||
<section id="combined-open-ended-status" class="combined-open-ended-status">
|
||||
|
||||
<div class="statusitem" data-status-number="0">
|
||||
|
||||
Step 1 (Problem complete) : 1 / 1
|
||||
<span class="correct" id="status"></span>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="statusitem statusitem-current" data-status-number="1">
|
||||
|
||||
Step 2 (Being scored) : None / 1
|
||||
<span class="grading" id="status"></span>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="item-container">
|
||||
<h4>Problem</h4>
|
||||
<div class="problem-container">
|
||||
<div class="item"><section id="openended_open_ended" class="open-ended-child" data-state="assessing" data-child-type="openended"><div class="error"></div>
|
||||
<div class="prompt">
|
||||
|
||||
Some prompt.
|
||||
|
||||
</div>
|
||||
<textarea rows="30" cols="80" name="answer" class="answer short-form-response" id="input_open_ended" disabled="disabled">Test submission. Yaaaaaaay!</textarea><div class="message-wrapper"></div>
|
||||
<div class="grader-status">
|
||||
<span class="grading" id="status_open_ended">Submitted for grading.</span>
|
||||
|
||||
</div>
|
||||
|
||||
<input type="button" value="Submit assessment" class="submit-button" name="show" style="display: none;"><input name="skip" class="skip-button" type="button" value="Skip Post-Assessment" style="display: none;"><div class="open-ended-action"></div>
|
||||
|
||||
<span id="answer_open_ended"></span>
|
||||
</section></div>
|
||||
</div>
|
||||
|
||||
|
||||
<input type="button" value="Reset" class="reset-button" name="reset" style="display: none;">
|
||||
<input type="button" value="Next Step" class="next-step-button" name="reset" style="display: none;">
|
||||
</div>
|
||||
|
||||
<a name="results">
|
||||
<div class="result-container">
|
||||
</div>
|
||||
</a></section><a name="results">
|
||||
|
||||
|
||||
</a></section><a name="results">
|
||||
|
||||
</a><div><a name="results">
|
||||
</a><a href="https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml">Edit</a> /
|
||||
<a href="#i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa-modal" onclick="javascript:getlog('i4x_MITx_6_002x_combinedopenended_CombinedOE', {
|
||||
'location': 'i4x://MITx/6.002x/combinedopenended/CombinedOE',
|
||||
'xqa_key': 'KUBrWtK3RAaBALLbccHrXeD3RHOpmZ2A',
|
||||
'category': 'CombinedOpenEndedModule',
|
||||
'user': 'blah'
|
||||
})" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_log">QA</a>
|
||||
</div>
|
||||
<div><a href="#i4x_MITx_6_002x_combinedopenended_CombinedOE_debug" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_trig">Staff Debug Info</a></div>
|
||||
|
||||
<section id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa-modal" class="modal xqa-modal" style="width:80%; left:20%; height:80%; overflow:auto">
|
||||
<div class="inner-wrapper">
|
||||
<header>
|
||||
<h2>edX Content Quality Assessment</h2>
|
||||
</header>
|
||||
|
||||
<form id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_form" class="xqa_form">
|
||||
<label>Comment</label>
|
||||
<input id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_entry" type="text" placeholder="comment">
|
||||
<label>Tag</label>
|
||||
<span style="color:black;vertical-align: -10pt">Optional tag (eg "done" or "broken"): </span>
|
||||
<input id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_tag" type="text" placeholder="tag" style="width:80px;display:inline">
|
||||
<div class="submit">
|
||||
<button name="submit" type="submit">Add comment</button>
|
||||
</div>
|
||||
<hr>
|
||||
<div id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_log_data"></div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="modal staff-modal" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_debug" style="width:80%; left:20%; height:80%; overflow:auto;">
|
||||
<div class="inner-wrapper" style="color:black">
|
||||
<header>
|
||||
<h2>Staff Debug</h2>
|
||||
</header>
|
||||
<div class="staff_info" style="display:block">
|
||||
is_released = <font color="red">Yes!</font>
|
||||
location = i4x://MITx/6.002x/combinedopenended/CombinedOE
|
||||
github = <a href="https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml">https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml</a>
|
||||
definition = <pre>None</pre>
|
||||
metadata = {
|
||||
"showanswer": "attempted",
|
||||
"display_name": "Problem 1",
|
||||
"graceperiod": "1 day 12 hours 59 minutes 59 seconds",
|
||||
"xqa_key": "KUBrWtK3RAaBALLbccHrXeD3RHOpmZ2A",
|
||||
"rerandomize": "never",
|
||||
"start": "2012-09-05T12:00",
|
||||
"attempts": "10000",
|
||||
"data_dir": "content-mit-6002x",
|
||||
"max_score": "1"
|
||||
}
|
||||
category = CombinedOpenEndedModule
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="i4x_MITx_6_002x_combinedopenended_CombinedOE_setup"></div>
|
||||
</section>
|
||||
@@ -0,0 +1,111 @@
|
||||
describe 'CombinedOpenEnded', ->
|
||||
beforeEach ->
|
||||
spyOn Logger, 'log'
|
||||
# load up some fixtures
|
||||
loadFixtures 'combined-open-ended.html'
|
||||
jasmine.Clock.useMock()
|
||||
@element = $('.course-content')
|
||||
|
||||
|
||||
describe 'constructor', ->
|
||||
beforeEach ->
|
||||
spyOn(Collapsible, 'setCollapsibles')
|
||||
@combined = new CombinedOpenEnded @element
|
||||
|
||||
it 'set the element', ->
|
||||
expect(@combined.element).toEqual @element
|
||||
|
||||
it 'get the correct values from data fields', ->
|
||||
expect(@combined.ajax_url).toEqual '/courses/MITx/6.002x/2012_Fall/modx/i4x://MITx/6.002x/combinedopenended/CombinedOE'
|
||||
expect(@combined.state).toEqual 'assessing'
|
||||
expect(@combined.task_count).toEqual 2
|
||||
expect(@combined.task_number).toEqual 1
|
||||
|
||||
it 'subelements are made collapsible', ->
|
||||
expect(Collapsible.setCollapsibles).toHaveBeenCalled()
|
||||
|
||||
|
||||
describe 'poll', ->
|
||||
beforeEach =>
|
||||
# setup the spies
|
||||
@combined = new CombinedOpenEnded @element
|
||||
spyOn(@combined, 'reload').andCallFake -> return 0
|
||||
window.setTimeout = jasmine.createSpy().andCallFake (callback, timeout) -> return 5
|
||||
|
||||
it 'polls at the correct intervals', =>
|
||||
fakeResponseContinue = state: 'not done'
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(fakeResponseContinue)
|
||||
@combined.poll()
|
||||
expect(window.setTimeout).toHaveBeenCalledWith(@combined.poll, 10000)
|
||||
expect(window.queuePollerID).toBe(5)
|
||||
|
||||
it 'polling stops properly', =>
|
||||
fakeResponseDone = state: "done"
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(fakeResponseDone)
|
||||
@combined.poll()
|
||||
expect(window.queuePollerID).toBeUndefined()
|
||||
expect(window.setTimeout).not.toHaveBeenCalled()
|
||||
|
||||
describe 'rebind', ->
|
||||
beforeEach ->
|
||||
@combined = new CombinedOpenEnded @element
|
||||
spyOn(@combined, 'queueing').andCallFake -> return 0
|
||||
spyOn(@combined, 'skip_post_assessment').andCallFake -> return 0
|
||||
window.setTimeout = jasmine.createSpy().andCallFake (callback, timeout) -> return 5
|
||||
|
||||
it 'when our child is in an assessing state', ->
|
||||
@combined.child_state = 'assessing'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBe("disabled")
|
||||
expect(@combined.submit_button.val()).toBe("Submit assessment")
|
||||
expect(@combined.queueing).toHaveBeenCalled()
|
||||
|
||||
it 'when our child state is initial', ->
|
||||
@combined.child_state = 'initial'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBeUndefined()
|
||||
expect(@combined.submit_button.val()).toBe("Submit")
|
||||
|
||||
it 'when our child state is post_assessment', ->
|
||||
@combined.child_state = 'post_assessment'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBe("disabled")
|
||||
expect(@combined.submit_button.val()).toBe("Submit post-assessment")
|
||||
|
||||
it 'when our child state is done', ->
|
||||
spyOn(@combined, 'next_problem').andCallFake ->
|
||||
@combined.child_state = 'done'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBe("disabled")
|
||||
expect(@combined.next_problem).toHaveBeenCalled()
|
||||
|
||||
describe 'next_problem', ->
|
||||
beforeEach ->
|
||||
@combined = new CombinedOpenEnded @element
|
||||
@combined.child_state = 'done'
|
||||
|
||||
it 'handling a successful call', ->
|
||||
fakeResponse =
|
||||
success: true
|
||||
html: "dummy html"
|
||||
allow_reset: false
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, val, callback) -> callback(fakeResponse)
|
||||
spyOn(@combined, 'reinitialize')
|
||||
spyOn(@combined, 'rebind')
|
||||
@combined.next_problem()
|
||||
expect($.postWithPrefix).toHaveBeenCalled()
|
||||
expect(@combined.reinitialize).toHaveBeenCalledWith(@combined.element)
|
||||
expect(@combined.rebind).toHaveBeenCalled()
|
||||
expect(@combined.answer_area.val()).toBe('')
|
||||
expect(@combined.child_state).toBe('initial')
|
||||
|
||||
it 'handling an unsuccessful call', ->
|
||||
fakeResponse =
|
||||
success: false
|
||||
error: 'This is an error'
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, val, callback) -> callback(fakeResponse)
|
||||
@combined.next_problem()
|
||||
expect(@combined.errors_area.html()).toBe(fakeResponse.error)
|
||||
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
|
||||
if createPlayer
|
||||
return new VideoPlayer(video: context.video)
|
||||
|
||||
spyOn(window, 'onunload')
|
||||
|
||||
# Stub jQuery.cookie
|
||||
$.cookie = jasmine.createSpy('jQuery.cookie').andReturn '1.0'
|
||||
|
||||
@@ -140,15 +140,15 @@ class @Problem
|
||||
allowed_files = $(element).data("allowed_files")
|
||||
for file in element.files
|
||||
if allowed_files.length != 0 and file.name not in allowed_files
|
||||
unallowed_file_submitted = true
|
||||
errors.push "You submitted #{file.name}; only #{allowed_files} are allowed."
|
||||
unallowed_file_submitted = true
|
||||
errors.push "You submitted #{file.name}; only #{allowed_files} are allowed."
|
||||
if file.name in required_files
|
||||
required_files.splice(required_files.indexOf(file.name), 1)
|
||||
required_files.splice(required_files.indexOf(file.name), 1)
|
||||
if file.size > max_filesize
|
||||
file_too_large = true
|
||||
errors.push 'Your file "' + file.name '" is too large (max size: ' + max_filesize/(1000*1000) + ' MB)'
|
||||
fd.append(element.id, file)
|
||||
if element.files.length == 0
|
||||
if element.files.length == 0
|
||||
file_not_selected = true
|
||||
fd.append(element.id, '') # In case we want to allow submissions with no file
|
||||
if required_files.length != 0
|
||||
@@ -157,7 +157,7 @@ class @Problem
|
||||
else
|
||||
fd.append(element.id, element.value)
|
||||
|
||||
|
||||
|
||||
if file_not_selected
|
||||
errors.push 'You did not select any files to submit'
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class @CombinedOpenEnded
|
||||
@state = @el.data('state')
|
||||
@task_count = @el.data('task-count')
|
||||
@task_number = @el.data('task-number')
|
||||
@accept_file_upload = @el.data('accept-file-upload')
|
||||
|
||||
@allow_reset = @el.data('allow_reset')
|
||||
@reset_button = @$('.reset-button')
|
||||
@@ -44,6 +45,8 @@ class @CombinedOpenEnded
|
||||
@skip_button = @$('.skip-button')
|
||||
@skip_button.click @skip_post_assessment
|
||||
|
||||
@file_upload_area = @$('.file-upload')
|
||||
@can_upload_files = false
|
||||
@open_ended_child= @$('.open-ended-child')
|
||||
|
||||
@find_assessment_elements()
|
||||
@@ -55,6 +58,16 @@ class @CombinedOpenEnded
|
||||
$: (selector) ->
|
||||
$(selector, @el)
|
||||
|
||||
show_results_current: () =>
|
||||
data = {'task_number' : @task_number-1}
|
||||
$.postWithPrefix "#{@ajax_url}/get_results", data, (response) =>
|
||||
if response.success
|
||||
@results_container.after(response.html).remove()
|
||||
@results_container = $('div.result-container')
|
||||
@submit_evaluation_button = $('.submit-evaluation-button')
|
||||
@submit_evaluation_button.click @message_post
|
||||
Collapsible.setCollapsibles(@results_container)
|
||||
|
||||
show_results: (event) =>
|
||||
status_item = $(event.target).parent().parent()
|
||||
status_number = status_item.data('status-number')
|
||||
@@ -67,7 +80,7 @@ class @CombinedOpenEnded
|
||||
@submit_evaluation_button.click @message_post
|
||||
Collapsible.setCollapsibles(@results_container)
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
@gentle_alert response.error
|
||||
|
||||
message_post: (event)=>
|
||||
Logger.log 'message_post', @answers
|
||||
@@ -108,22 +121,28 @@ class @CombinedOpenEnded
|
||||
@submit_button.show()
|
||||
@reset_button.hide()
|
||||
@next_problem_button.hide()
|
||||
@hide_file_upload()
|
||||
@hint_area.attr('disabled', false)
|
||||
if @child_state == 'done'
|
||||
@rubric_wrapper.hide()
|
||||
if @child_type=="openended"
|
||||
@skip_button.hide()
|
||||
if @allow_reset=="True"
|
||||
@show_results_current
|
||||
@reset_button.show()
|
||||
@submit_button.hide()
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@hint_area.attr('disabled', true)
|
||||
else if @child_state == 'initial'
|
||||
@answer_area.attr("disabled", false)
|
||||
@submit_button.prop('value', 'Submit')
|
||||
@submit_button.click @save_answer
|
||||
@setup_file_upload()
|
||||
else if @child_state == 'assessing'
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@hide_file_upload()
|
||||
@submit_button.prop('value', 'Submit assessment')
|
||||
@submit_button.click @save_assessment
|
||||
if @child_type == "openended"
|
||||
@@ -134,6 +153,7 @@ class @CombinedOpenEnded
|
||||
@skip_button.show()
|
||||
@skip_post_assessment()
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@submit_button.prop('value', 'Submit post-assessment')
|
||||
if @child_type=="selfassessment"
|
||||
@submit_button.click @save_hint
|
||||
@@ -142,6 +162,7 @@ class @CombinedOpenEnded
|
||||
else if @child_state == 'done'
|
||||
@rubric_wrapper.hide()
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@hint_area.attr('disabled', true)
|
||||
@submit_button.hide()
|
||||
if @child_type=="openended"
|
||||
@@ -149,6 +170,7 @@ class @CombinedOpenEnded
|
||||
if @task_number<@task_count
|
||||
@next_problem()
|
||||
else
|
||||
@show_results_current()
|
||||
@reset_button.show()
|
||||
|
||||
|
||||
@@ -160,17 +182,41 @@ class @CombinedOpenEnded
|
||||
|
||||
save_answer: (event) =>
|
||||
event.preventDefault()
|
||||
max_filesize = 2*1000*1000 #2MB
|
||||
if @child_state == 'initial'
|
||||
data = {'student_answer' : @answer_area.val()}
|
||||
$.postWithPrefix "#{@ajax_url}/save_answer", data, (response) =>
|
||||
if response.success
|
||||
@rubric_wrapper.html(response.rubric_html)
|
||||
@rubric_wrapper.show()
|
||||
@child_state = 'assessing'
|
||||
@find_assessment_elements()
|
||||
@rebind()
|
||||
files = ""
|
||||
if @can_upload_files == true
|
||||
files = $('.file-upload-box')[0].files[0]
|
||||
if files != undefined
|
||||
if files.size > max_filesize
|
||||
@can_upload_files = false
|
||||
files = ""
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
@can_upload_files = false
|
||||
|
||||
fd = new FormData()
|
||||
fd.append('student_answer', @answer_area.val())
|
||||
fd.append('student_file', files)
|
||||
fd.append('can_upload_files', @can_upload_files)
|
||||
|
||||
settings =
|
||||
type: "POST"
|
||||
data: fd
|
||||
processData: false
|
||||
contentType: false
|
||||
success: (response) =>
|
||||
if response.success
|
||||
@rubric_wrapper.html(response.rubric_html)
|
||||
@rubric_wrapper.show()
|
||||
@answer_area.html(response.student_response)
|
||||
@child_state = 'assessing'
|
||||
@find_assessment_elements()
|
||||
@rebind()
|
||||
else
|
||||
@gentle_alert response.error
|
||||
|
||||
$.ajaxWithPrefix("#{@ajax_url}/save_answer",settings)
|
||||
|
||||
else
|
||||
@errors_area.html('Problem state got out of sync. Try reloading the page.')
|
||||
|
||||
@@ -260,6 +306,7 @@ class @CombinedOpenEnded
|
||||
@gentle_alert "Moved to next step."
|
||||
else
|
||||
@gentle_alert "Your score did not meet the criteria to move to the next step."
|
||||
@show_results_current()
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
else
|
||||
@@ -282,6 +329,31 @@ class @CombinedOpenEnded
|
||||
$.postWithPrefix "#{@ajax_url}/check_for_score", (response) =>
|
||||
if response.state == "done" or response.state=="post_assessment"
|
||||
delete window.queuePollerID
|
||||
location.reload()
|
||||
@reload
|
||||
else
|
||||
window.queuePollerID = window.setTimeout(@poll, 10000)
|
||||
|
||||
setup_file_upload: =>
|
||||
if window.File and window.FileReader and window.FileList and window.Blob
|
||||
if @accept_file_upload == "True"
|
||||
@can_upload_files = true
|
||||
@file_upload_area.html('<input type="file" class="file-upload-box">')
|
||||
@file_upload_area.show()
|
||||
else
|
||||
@gentle_alert 'File uploads are required for this question, but are not supported in this browser. Try the newest version of google chrome. Alternatively, if you have uploaded the image to the web, you can paste a link to it into the answer box.'
|
||||
|
||||
hide_file_upload: =>
|
||||
if @accept_file_upload == "True"
|
||||
@file_upload_area.hide()
|
||||
|
||||
replace_text_inputs: =>
|
||||
answer_class = @answer_area.attr('class')
|
||||
answer_id = @answer_area.attr('id')
|
||||
answer_val = @answer_area.val()
|
||||
new_text = ''
|
||||
new_text = "<span class='#{answer_class}' id='#{answer_id}'>#{answer_val}</span>"
|
||||
@answer_area.replaceWith(new_text)
|
||||
|
||||
# wrap this so that it can be mocked
|
||||
reload: ->
|
||||
location.reload()
|
||||
|
||||
26
common/lib/xmodule/xmodule/js/src/conditional/display.coffee
Normal file
26
common/lib/xmodule/xmodule/js/src/conditional/display.coffee
Normal file
@@ -0,0 +1,26 @@
|
||||
class @Conditional
|
||||
|
||||
constructor: (element) ->
|
||||
@el = $(element).find('.conditional-wrapper')
|
||||
@id = @el.data('problem-id')
|
||||
@element_id = @el.attr('id')
|
||||
@url = @el.data('url')
|
||||
@render()
|
||||
|
||||
$: (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('display', @el)
|
||||
else
|
||||
$.postWithPrefix "#{@url}/conditional_get", (response) =>
|
||||
@el.html(response.html)
|
||||
XModule.loadModules('display', @el)
|
||||
|
||||
@@ -23,6 +23,9 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
|
||||
# export the custom tags
|
||||
export_extra_content(export_fs, modulestore, course_location, 'custom_tag_template', 'custom_tags')
|
||||
|
||||
# export the course updates
|
||||
export_extra_content(export_fs, modulestore, course_location, 'course_info', 'info', '.html')
|
||||
|
||||
|
||||
def export_extra_content(export_fs, modulestore, course_location, category_type, dirname, file_suffix = ''):
|
||||
query_loc = Location('i4x', course_location.org, course_location.course, category_type, None)
|
||||
|
||||
261
common/lib/xmodule/xmodule/open_ended_image_submission.py
Normal file
261
common/lib/xmodule/xmodule/open_ended_image_submission.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
This contains functions and classes used to evaluate if images are acceptable (do not show improper content, etc), and
|
||||
to send them to S3.
|
||||
"""
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
ENABLE_PIL = True
|
||||
except:
|
||||
ENABLE_PIL = False
|
||||
|
||||
from urlparse import urlparse
|
||||
import requests
|
||||
from boto.s3.connection import S3Connection
|
||||
from boto.s3.key import Key
|
||||
from django.conf import settings
|
||||
import pickle
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#Domains where any image linked to can be trusted to have acceptable content.
|
||||
TRUSTED_IMAGE_DOMAINS = [
|
||||
'wikipedia',
|
||||
'edxuploads.s3.amazonaws.com',
|
||||
'wikimedia',
|
||||
]
|
||||
|
||||
#Suffixes that are allowed in image urls
|
||||
ALLOWABLE_IMAGE_SUFFIXES = [
|
||||
'jpg',
|
||||
'png',
|
||||
'gif',
|
||||
'jpeg'
|
||||
]
|
||||
|
||||
#Maximum allowed dimensions (x and y) for an uploaded image
|
||||
MAX_ALLOWED_IMAGE_DIM = 1000
|
||||
|
||||
#Dimensions to which image is resized before it is evaluated for color count, etc
|
||||
MAX_IMAGE_DIM = 150
|
||||
|
||||
#Maximum number of colors that should be counted in ImageProperties
|
||||
MAX_COLORS_TO_COUNT = 16
|
||||
|
||||
#Maximum number of colors allowed in an uploaded image
|
||||
MAX_COLORS = 400
|
||||
|
||||
class ImageProperties(object):
|
||||
"""
|
||||
Class to check properties of an image and to validate if they are allowed.
|
||||
"""
|
||||
def __init__(self, image_data):
|
||||
"""
|
||||
Initializes class variables
|
||||
@param image: Image object (from PIL)
|
||||
@return: None
|
||||
"""
|
||||
self.image = Image.open(image_data)
|
||||
image_size = self.image.size
|
||||
self.image_too_large = False
|
||||
if image_size[0] > MAX_ALLOWED_IMAGE_DIM or image_size[1] > MAX_ALLOWED_IMAGE_DIM:
|
||||
self.image_too_large = True
|
||||
if image_size[0] > MAX_IMAGE_DIM or image_size[1] > MAX_IMAGE_DIM:
|
||||
self.image = self.image.resize((MAX_IMAGE_DIM, MAX_IMAGE_DIM))
|
||||
self.image_size = self.image.size
|
||||
|
||||
def count_colors(self):
|
||||
"""
|
||||
Counts the number of colors in an image, and matches them to the max allowed
|
||||
@return: boolean true if color count is acceptable, false otherwise
|
||||
"""
|
||||
colors = self.image.getcolors(MAX_COLORS_TO_COUNT)
|
||||
if colors is None:
|
||||
color_count = MAX_COLORS_TO_COUNT
|
||||
else:
|
||||
color_count = len(colors)
|
||||
|
||||
too_many_colors = (color_count <= MAX_COLORS)
|
||||
return too_many_colors
|
||||
|
||||
def check_if_rgb_is_skin(self, rgb):
|
||||
"""
|
||||
Checks if a given input rgb tuple/list is a skin tone
|
||||
@param rgb: RGB tuple
|
||||
@return: Boolean true false
|
||||
"""
|
||||
colors_okay = False
|
||||
try:
|
||||
r = rgb[0]
|
||||
g = rgb[1]
|
||||
b = rgb[2]
|
||||
check_r = (r > 60)
|
||||
check_g = (r * 0.4) < g < (r * 0.85)
|
||||
check_b = (r * 0.2) < b < (r * 0.7)
|
||||
colors_okay = check_r and check_b and check_g
|
||||
except:
|
||||
pass
|
||||
|
||||
return colors_okay
|
||||
|
||||
def get_skin_ratio(self):
|
||||
"""
|
||||
Gets the ratio of skin tone colors in an image
|
||||
@return: True if the ratio is low enough to be acceptable, false otherwise
|
||||
"""
|
||||
colors = self.image.getcolors(MAX_COLORS_TO_COUNT)
|
||||
is_okay = True
|
||||
if colors is not None:
|
||||
skin = sum([count for count, rgb in colors if self.check_if_rgb_is_skin(rgb)])
|
||||
total_colored_pixels = sum([count for count, rgb in colors])
|
||||
bad_color_val = float(skin) / total_colored_pixels
|
||||
if bad_color_val > .4:
|
||||
is_okay = False
|
||||
|
||||
return is_okay
|
||||
|
||||
def run_tests(self):
|
||||
"""
|
||||
Does all available checks on an image to ensure that it is okay (size, skin ratio, colors)
|
||||
@return: Boolean indicating whether or not image passes all checks
|
||||
"""
|
||||
image_is_okay = False
|
||||
try:
|
||||
image_is_okay = self.count_colors() and self.get_skin_ratio() and not self.image_too_large
|
||||
except:
|
||||
log.exception("Could not run image tests.")
|
||||
|
||||
return image_is_okay
|
||||
|
||||
|
||||
class URLProperties(object):
|
||||
"""
|
||||
Checks to see if a URL points to acceptable content. Added to check if students are submitting reasonable
|
||||
links to the peer grading image functionality of the external grading service.
|
||||
"""
|
||||
def __init__(self, url_string):
|
||||
self.url_string = url_string
|
||||
|
||||
def check_if_parses(self):
|
||||
"""
|
||||
Check to see if a URL parses properly
|
||||
@return: success (True if parses, false if not)
|
||||
"""
|
||||
success = False
|
||||
try:
|
||||
self.parsed_url = urlparse(self.url_string)
|
||||
success = True
|
||||
except:
|
||||
pass
|
||||
|
||||
return success
|
||||
|
||||
def check_suffix(self):
|
||||
"""
|
||||
Checks the suffix of a url to make sure that it is allowed
|
||||
@return: True if suffix is okay, false if not
|
||||
"""
|
||||
good_suffix = False
|
||||
for suffix in ALLOWABLE_IMAGE_SUFFIXES:
|
||||
if self.url_string.endswith(suffix):
|
||||
good_suffix = True
|
||||
break
|
||||
return good_suffix
|
||||
|
||||
def run_tests(self):
|
||||
"""
|
||||
Runs all available url tests
|
||||
@return: True if URL passes tests, false if not.
|
||||
"""
|
||||
url_is_okay = self.check_suffix() and self.check_if_parses() and self.check_domain()
|
||||
return url_is_okay
|
||||
|
||||
def check_domain(self):
|
||||
"""
|
||||
Checks to see if url is from a trusted domain
|
||||
"""
|
||||
success = False
|
||||
for domain in TRUSTED_IMAGE_DOMAINS:
|
||||
if domain in self.url_string:
|
||||
success = True
|
||||
return success
|
||||
return success
|
||||
|
||||
def run_url_tests(url_string):
|
||||
"""
|
||||
Creates a URLProperties object and runs all tests
|
||||
@param url_string: A URL in string format
|
||||
@return: Boolean indicating whether or not URL has passed all tests
|
||||
"""
|
||||
url_properties = URLProperties(url_string)
|
||||
return url_properties.run_tests()
|
||||
|
||||
|
||||
def run_image_tests(image):
|
||||
"""
|
||||
Runs all available image tests
|
||||
@param image: PIL Image object
|
||||
@return: Boolean indicating whether or not all tests have been passed
|
||||
"""
|
||||
success = False
|
||||
try:
|
||||
image_properties = ImageProperties(image)
|
||||
success = image_properties.run_tests()
|
||||
except:
|
||||
log.exception("Cannot run image tests in combined open ended xmodule. May be an issue with a particular image,"
|
||||
"or an issue with the deployment configuration of PIL/Pillow")
|
||||
return success
|
||||
|
||||
|
||||
def upload_to_s3(file_to_upload, keyname):
|
||||
'''
|
||||
Upload file to S3 using provided keyname.
|
||||
|
||||
Returns:
|
||||
public_url: URL to access uploaded file
|
||||
'''
|
||||
|
||||
#This commented out code is kept here in case we change the uploading method and require images to be
|
||||
#converted before they are sent to S3.
|
||||
#TODO: determine if commented code is needed and remove
|
||||
#im = Image.open(file_to_upload)
|
||||
#out_im = cStringIO.StringIO()
|
||||
#im.save(out_im, 'PNG')
|
||||
|
||||
try:
|
||||
conn = S3Connection(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY)
|
||||
bucketname = str(settings.AWS_STORAGE_BUCKET_NAME)
|
||||
bucket = conn.create_bucket(bucketname.lower())
|
||||
|
||||
k = Key(bucket)
|
||||
k.key = keyname
|
||||
k.set_metadata('filename', file_to_upload.name)
|
||||
k.set_contents_from_file(file_to_upload)
|
||||
|
||||
#This commented out code is kept here in case we change the uploading method and require images to be
|
||||
#converted before they are sent to S3.
|
||||
#k.set_contents_from_string(out_im.getvalue())
|
||||
#k.set_metadata("Content-Type", 'images/png')
|
||||
|
||||
k.set_acl("public-read")
|
||||
public_url = k.generate_url(60 * 60 * 24 * 365) # URL timeout in seconds.
|
||||
|
||||
return True, public_url
|
||||
except:
|
||||
return False, "Could not connect to S3."
|
||||
|
||||
|
||||
def get_from_s3(s3_public_url):
|
||||
"""
|
||||
Gets an image from a given S3 url
|
||||
@param s3_public_url: The URL where an image is located
|
||||
@return: The image data
|
||||
"""
|
||||
r = requests.get(s3_public_url, timeout=2)
|
||||
data = r.text
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
"""
|
||||
new_score_msg = self._parse_score_msg(score_msg, system)
|
||||
if not new_score_msg['valid']:
|
||||
score_msg['feedback'] = 'Invalid grader reply. Please contact the course staff.'
|
||||
new_score_msg['feedback'] = 'Invalid grader reply. Please contact the course staff.'
|
||||
|
||||
self.record_latest_score(new_score_msg['score'])
|
||||
self.record_latest_post_assessment(score_msg)
|
||||
@@ -378,12 +378,11 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
Return error message or feedback template
|
||||
"""
|
||||
|
||||
log.debug(response_items)
|
||||
rubric_feedback=""
|
||||
rubric_feedback = ""
|
||||
feedback = self._convert_longform_feedback_to_html(response_items)
|
||||
if response_items['rubric_scores_complete']==True:
|
||||
if response_items['rubric_scores_complete'] == True:
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
rubric_feedback = rubric_renderer.render_rubric(response_items['rubric_xml'])
|
||||
success, rubric_feedback = rubric_renderer.render_rubric(response_items['rubric_xml'])
|
||||
|
||||
if not response_items['success']:
|
||||
return system.render_template("open_ended_error.html",
|
||||
@@ -393,7 +392,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'grader_type': response_items['grader_type'],
|
||||
'score': "{0} / {1}".format(response_items['score'], self.max_score()),
|
||||
'feedback': feedback,
|
||||
'rubric_feedback' : rubric_feedback
|
||||
'rubric_feedback': rubric_feedback
|
||||
})
|
||||
|
||||
return feedback_template
|
||||
@@ -406,6 +405,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'score': Numeric value (floating point is okay) to assign to answer
|
||||
'msg': grader_msg
|
||||
'feedback' : feedback from grader
|
||||
'grader_type': what type of grader resulted in this score
|
||||
'grader_id': id of the grader
|
||||
'submission_id' : id of the submission
|
||||
'success': whether or not this submission was successful
|
||||
'rubric_scores': a list of rubric scores
|
||||
'rubric_scores_complete': boolean if rubric scores are complete
|
||||
'rubric_xml': the xml of the rubric in string format
|
||||
}
|
||||
|
||||
Returns (valid_score_msg, correct, score, msg):
|
||||
@@ -437,7 +443,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
log.error(error_message)
|
||||
fail['feedback'] = error_message
|
||||
return fail
|
||||
#This is to support peer grading
|
||||
#This is to support peer grading
|
||||
if isinstance(score_result['score'], list):
|
||||
feedback_items = []
|
||||
for i in xrange(0, len(score_result['score'])):
|
||||
@@ -448,8 +454,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'success': score_result['success'],
|
||||
'grader_id': score_result['grader_id'][i],
|
||||
'submission_id': score_result['submission_id'],
|
||||
'rubric_scores_complete' : score_result['rubric_scores_complete'][i],
|
||||
'rubric_xml' : score_result['rubric_xml'][i],
|
||||
'rubric_scores_complete': score_result['rubric_scores_complete'][i],
|
||||
'rubric_xml': score_result['rubric_xml'][i],
|
||||
}
|
||||
feedback_items.append(self._format_feedback(new_score_result, system))
|
||||
if join_feedback:
|
||||
@@ -476,7 +482,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
if not self.history:
|
||||
return ""
|
||||
|
||||
feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), system, join_feedback=join_feedback)
|
||||
feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), system,
|
||||
join_feedback=join_feedback)
|
||||
if not short_feedback:
|
||||
return feedback_dict['feedback'] if feedback_dict['valid'] else ''
|
||||
if feedback_dict['valid']:
|
||||
@@ -554,11 +561,21 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
# add new history element with answer and empty score and hint.
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.send_to_grader(get['student_answer'], system)
|
||||
self.change_state(self.ASSESSING)
|
||||
success, get = self.append_image_to_student_answer(get)
|
||||
error_message = ""
|
||||
if success:
|
||||
get['student_answer'] = OpenEndedModule.sanitize_html(get['student_answer'])
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.send_to_grader(get['student_answer'], system)
|
||||
self.change_state(self.ASSESSING)
|
||||
else:
|
||||
error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box."
|
||||
|
||||
return {'success': True, }
|
||||
return {
|
||||
'success': True,
|
||||
'error': error_message,
|
||||
'student_response': get['student_answer']
|
||||
}
|
||||
|
||||
def update_score(self, get, system):
|
||||
"""
|
||||
@@ -602,8 +619,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'msg': post_assessment,
|
||||
'child_type': 'openended',
|
||||
'correct': correct,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
log.debug(context)
|
||||
html = system.render_template('open_ended.html', context)
|
||||
return html
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ import json
|
||||
import logging
|
||||
from lxml import etree
|
||||
from lxml.html import rewrite_links
|
||||
from lxml.html.clean import Cleaner, autolink_html
|
||||
from path import path
|
||||
import os
|
||||
import sys
|
||||
import hashlib
|
||||
import capa.xqueue_interface as xqueue_interface
|
||||
import re
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
@@ -21,6 +23,7 @@ from .stringify import stringify_children
|
||||
from .xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from capa.util import *
|
||||
import open_ended_image_submission
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@@ -94,6 +97,7 @@ class OpenEndedChild(object):
|
||||
self.prompt = static_data['prompt']
|
||||
self.rubric = static_data['rubric']
|
||||
self.display_name = static_data['display_name']
|
||||
self.accept_file_upload = static_data['accept_file_upload']
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
@@ -113,7 +117,7 @@ class OpenEndedChild(object):
|
||||
pass
|
||||
|
||||
def latest_answer(self):
|
||||
"""None if not available"""
|
||||
"""Empty string if not available"""
|
||||
if not self.history:
|
||||
return ""
|
||||
return self.history[-1].get('answer', "")
|
||||
@@ -125,17 +129,31 @@ class OpenEndedChild(object):
|
||||
return self.history[-1].get('score')
|
||||
|
||||
def latest_post_assessment(self, system):
|
||||
"""None if not available"""
|
||||
"""Empty string if not available"""
|
||||
if not self.history:
|
||||
return ""
|
||||
return self.history[-1].get('post_assessment', "")
|
||||
|
||||
@staticmethod
|
||||
def sanitize_html(answer):
|
||||
try:
|
||||
answer = autolink_html(answer)
|
||||
cleaner = Cleaner(style=True, links=True, add_nofollow=False, page_structure=True, safe_attrs_only=True,
|
||||
host_whitelist=open_ended_image_submission.TRUSTED_IMAGE_DOMAINS,
|
||||
whitelist_tags=set(['embed', 'iframe', 'a', 'img']))
|
||||
clean_html = cleaner.clean_html(answer)
|
||||
clean_html = re.sub(r'</p>$', '', re.sub(r'^<p>', '', clean_html))
|
||||
except:
|
||||
clean_html = answer
|
||||
return clean_html
|
||||
|
||||
def new_history_entry(self, answer):
|
||||
"""
|
||||
Adds a new entry to the history dictionary
|
||||
@param answer: The student supplied answer
|
||||
@return: None
|
||||
"""
|
||||
answer = OpenEndedChild.sanitize_html(answer)
|
||||
self.history.append({'answer': answer})
|
||||
|
||||
def record_latest_score(self, score):
|
||||
@@ -260,5 +278,115 @@ class OpenEndedChild(object):
|
||||
correctness = 'correct' if self.is_submission_correct(score) else 'incorrect'
|
||||
return correctness
|
||||
|
||||
def upload_image_to_s3(self, image_data):
|
||||
"""
|
||||
Uploads an image to S3
|
||||
Image_data: InMemoryUploadedFileObject that responds to read() and seek()
|
||||
@return:Success and a URL corresponding to the uploaded object
|
||||
"""
|
||||
success = False
|
||||
s3_public_url = ""
|
||||
image_ok = False
|
||||
try:
|
||||
image_data.seek(0)
|
||||
image_ok = open_ended_image_submission.run_image_tests(image_data)
|
||||
except:
|
||||
log.exception("Could not create image and check it.")
|
||||
|
||||
if image_ok:
|
||||
image_key = image_data.name + datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
|
||||
try:
|
||||
image_data.seek(0)
|
||||
success, s3_public_url = open_ended_image_submission.upload_to_s3(image_data, image_key)
|
||||
except:
|
||||
log.exception("Could not upload image to S3.")
|
||||
|
||||
return success, image_ok, s3_public_url
|
||||
|
||||
def check_for_image_and_upload(self, get_data):
|
||||
"""
|
||||
Checks to see if an image was passed back in the AJAX query. If so, it will upload it to S3
|
||||
@param get_data: AJAX get data
|
||||
@return: Success, whether or not a file was in the get dictionary,
|
||||
and the html corresponding to the uploaded image
|
||||
"""
|
||||
has_file_to_upload = False
|
||||
uploaded_to_s3 = False
|
||||
image_tag = ""
|
||||
image_ok = False
|
||||
if 'can_upload_files' in get_data:
|
||||
if get_data['can_upload_files'] == 'true':
|
||||
has_file_to_upload = True
|
||||
file = get_data['student_file'][0]
|
||||
uploaded_to_s3, image_ok, s3_public_url = self.upload_image_to_s3(file)
|
||||
if uploaded_to_s3:
|
||||
image_tag = self.generate_image_tag_from_url(s3_public_url, file.name)
|
||||
|
||||
return has_file_to_upload, uploaded_to_s3, image_ok, image_tag
|
||||
|
||||
def generate_image_tag_from_url(self, s3_public_url, image_name):
|
||||
"""
|
||||
Makes an image tag from a given URL
|
||||
@param s3_public_url: URL of the image
|
||||
@param image_name: Name of the image
|
||||
@return: Boolean success, updated AJAX get data
|
||||
"""
|
||||
image_template = """
|
||||
<a href="{0}" target="_blank">{1}</a>
|
||||
""".format(s3_public_url, image_name)
|
||||
return image_template
|
||||
|
||||
def append_image_to_student_answer(self, get_data):
|
||||
"""
|
||||
Adds an image to a student answer after uploading it to S3
|
||||
@param get_data: AJAx get data
|
||||
@return: Boolean success, updated AJAX get data
|
||||
"""
|
||||
overall_success = False
|
||||
if not self.accept_file_upload:
|
||||
#If the question does not accept file uploads, do not do anything
|
||||
return True, get_data
|
||||
|
||||
has_file_to_upload, uploaded_to_s3, image_ok, image_tag = self.check_for_image_and_upload(get_data)
|
||||
if uploaded_to_s3 and has_file_to_upload and image_ok:
|
||||
get_data['student_answer'] += image_tag
|
||||
overall_success = True
|
||||
elif has_file_to_upload and not uploaded_to_s3 and image_ok:
|
||||
#In this case, an image was submitted by the student, but the image could not be uploaded to S3. Likely
|
||||
#a config issue (development vs deployment). For now, just treat this as a "success"
|
||||
log.warning("Student AJAX post to combined open ended xmodule indicated that it contained an image, "
|
||||
"but the image was not able to be uploaded to S3. This could indicate a config"
|
||||
"issue with this deployment, but it could also indicate a problem with S3 or with the"
|
||||
"student image itself.")
|
||||
overall_success = True
|
||||
elif not has_file_to_upload:
|
||||
#If there is no file to upload, probably the student has embedded the link in the answer text
|
||||
success, get_data['student_answer'] = self.check_for_url_in_text(get_data['student_answer'])
|
||||
overall_success = success
|
||||
|
||||
return overall_success, get_data
|
||||
|
||||
def check_for_url_in_text(self, string):
|
||||
"""
|
||||
Checks for urls in a string
|
||||
@param string: Arbitrary string
|
||||
@return: Boolean success, the edited string
|
||||
"""
|
||||
success = False
|
||||
links = re.findall(r'(https?://\S+)', string)
|
||||
if len(links)>0:
|
||||
for link in links:
|
||||
success = open_ended_image_submission.run_url_tests(link)
|
||||
if not success:
|
||||
string = re.sub(link, '', string)
|
||||
else:
|
||||
string = re.sub(link, self.generate_image_tag_from_url(link,link), string)
|
||||
success = True
|
||||
|
||||
return success, string
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
'state': self.state,
|
||||
'allow_reset': self._allow_reset(),
|
||||
'child_type': 'selfassessment',
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
html = system.render_template('self_assessment_prompt.html', context)
|
||||
@@ -106,6 +107,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
if dispatch not in handlers:
|
||||
return 'Error'
|
||||
|
||||
log.debug(get)
|
||||
before = self.get_progress()
|
||||
d = handlers[dispatch](get, system)
|
||||
after = self.get_progress()
|
||||
@@ -123,7 +125,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
return ''
|
||||
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
rubric_html = rubric_renderer.render_rubric(self.rubric)
|
||||
success, rubric_html = rubric_renderer.render_rubric(self.rubric)
|
||||
|
||||
# we'll render it
|
||||
context = {'rubric': rubric_html,
|
||||
@@ -200,13 +202,21 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
if self.state != self.INITIAL:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
error_message = ""
|
||||
# add new history element with answer and empty score and hint.
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.change_state(self.ASSESSING)
|
||||
success, get = self.append_image_to_student_answer(get)
|
||||
if success:
|
||||
get['student_answer'] = SelfAssessmentModule.sanitize_html(get['student_answer'])
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.change_state(self.ASSESSING)
|
||||
else:
|
||||
error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box."
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'rubric_html': self.get_rubric_html(system)
|
||||
'success': success,
|
||||
'rubric_html': self.get_rubric_html(system),
|
||||
'error': error_message,
|
||||
'student_response': get['student_answer'],
|
||||
}
|
||||
|
||||
def save_assessment(self, get, system):
|
||||
|
||||
339
common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
Normal file
339
common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
Normal file
@@ -0,0 +1,339 @@
|
||||
import json
|
||||
from mock import Mock, MagicMock, ANY
|
||||
import unittest
|
||||
|
||||
from xmodule.openendedchild import OpenEndedChild
|
||||
from xmodule.open_ended_module import OpenEndedModule
|
||||
from xmodule.combined_open_ended_module import CombinedOpenEndedModule
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from lxml import etree
|
||||
import capa.xqueue_interface as xqueue_interface
|
||||
from datetime import datetime
|
||||
|
||||
from . import test_system
|
||||
"""
|
||||
Tests for the various pieces of the CombinedOpenEndedGrading system
|
||||
|
||||
OpenEndedChild
|
||||
OpenEndedModule
|
||||
|
||||
"""
|
||||
|
||||
class OpenEndedChildTest(unittest.TestCase):
|
||||
location = Location(["i4x", "edX", "sa_test", "selfassessment",
|
||||
"SampleQuestion"])
|
||||
|
||||
metadata = json.dumps({'attempts': '10'})
|
||||
prompt = etree.XML("<prompt>This is a question prompt</prompt>")
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
max_score = 4
|
||||
|
||||
static_data = {
|
||||
'max_attempts': 20,
|
||||
'prompt': prompt,
|
||||
'rubric': rubric,
|
||||
'max_score': max_score,
|
||||
'display_name': 'Name',
|
||||
'accept_file_upload' : False,
|
||||
}
|
||||
definition = Mock()
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
self.openendedchild = OpenEndedChild(test_system, self.location,
|
||||
self.definition, self.descriptor, self.static_data, self.metadata)
|
||||
|
||||
|
||||
def test_latest_answer_empty(self):
|
||||
answer = self.openendedchild.latest_answer()
|
||||
self.assertEqual(answer, "")
|
||||
|
||||
|
||||
def test_latest_score_empty(self):
|
||||
answer = self.openendedchild.latest_score()
|
||||
self.assertEqual(answer, None)
|
||||
|
||||
|
||||
def test_latest_post_assessment_empty(self):
|
||||
answer = self.openendedchild.latest_post_assessment(test_system)
|
||||
self.assertEqual(answer, "")
|
||||
|
||||
|
||||
def test_new_history_entry(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
answer = self.openendedchild.latest_answer()
|
||||
self.assertEqual(answer, new_answer)
|
||||
|
||||
new_answer = "Newer Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
answer = self.openendedchild.latest_answer()
|
||||
self.assertEqual(new_answer, answer)
|
||||
|
||||
def test_record_latest_score(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
new_score = 3
|
||||
self.openendedchild.record_latest_score(new_score)
|
||||
score = self.openendedchild.latest_score()
|
||||
self.assertEqual(score, 3)
|
||||
|
||||
new_score = 4
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(new_score)
|
||||
score = self.openendedchild.latest_score()
|
||||
self.assertEqual(score, 4)
|
||||
|
||||
|
||||
def test_record_latest_post_assessment(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
|
||||
post_assessment = "Post assessment"
|
||||
self.openendedchild.record_latest_post_assessment(post_assessment)
|
||||
self.assertEqual(post_assessment,
|
||||
self.openendedchild.latest_post_assessment(test_system))
|
||||
|
||||
def test_get_score(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
|
||||
score = self.openendedchild.get_score()
|
||||
self.assertEqual(score['score'], 0)
|
||||
self.assertEqual(score['total'], self.static_data['max_score'])
|
||||
|
||||
new_score = 4
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(new_score)
|
||||
score = self.openendedchild.get_score()
|
||||
self.assertEqual(score['score'], new_score)
|
||||
self.assertEqual(score['total'], self.static_data['max_score'])
|
||||
|
||||
|
||||
def test_reset(self):
|
||||
self.openendedchild.reset(test_system)
|
||||
state = json.loads(self.openendedchild.get_instance_state())
|
||||
self.assertEqual(state['state'], OpenEndedChild.INITIAL)
|
||||
|
||||
|
||||
def test_is_last_response_correct(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(self.static_data['max_score'])
|
||||
self.assertEqual(self.openendedchild.is_last_response_correct(),
|
||||
'correct')
|
||||
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(0)
|
||||
self.assertEqual(self.openendedchild.is_last_response_correct(),
|
||||
'incorrect')
|
||||
|
||||
class OpenEndedModuleTest(unittest.TestCase):
|
||||
location = Location(["i4x", "edX", "sa_test", "selfassessment",
|
||||
"SampleQuestion"])
|
||||
|
||||
metadata = json.dumps({'attempts': '10'})
|
||||
prompt = etree.XML("<prompt>This is a question prompt</prompt>")
|
||||
rubric = etree.XML('''<rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric>''')
|
||||
max_score = 4
|
||||
|
||||
static_data = {
|
||||
'max_attempts': 20,
|
||||
'prompt': prompt,
|
||||
'rubric': rubric,
|
||||
'max_score': max_score,
|
||||
'display_name': 'Name',
|
||||
'accept_file_upload': False,
|
||||
}
|
||||
|
||||
oeparam = etree.XML('''
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
''')
|
||||
definition = {'oeparam': oeparam}
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
test_system.location = self.location
|
||||
self.mock_xqueue = MagicMock()
|
||||
self.mock_xqueue.send_to_queue.return_value=(None, "Message")
|
||||
test_system.xqueue = {'interface':self.mock_xqueue, 'callback_url':'/', 'default_queuename': 'testqueue', 'waittime': 1}
|
||||
self.openendedmodule = OpenEndedModule(test_system, self.location,
|
||||
self.definition, self.descriptor, self.static_data, self.metadata)
|
||||
|
||||
def test_message_post(self):
|
||||
get = {'feedback': 'feedback text',
|
||||
'submission_id': '1',
|
||||
'grader_id': '1',
|
||||
'score': 3}
|
||||
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
|
||||
student_info = {'anonymous_student_id': test_system.anonymous_student_id,
|
||||
'submission_time': qtime}
|
||||
contents = {
|
||||
'feedback': get['feedback'],
|
||||
'submission_id': int(get['submission_id']),
|
||||
'grader_id': int(get['grader_id']),
|
||||
'score': get['score'],
|
||||
'student_info': json.dumps(student_info)
|
||||
}
|
||||
|
||||
result = self.openendedmodule.message_post(get, test_system)
|
||||
self.assertTrue(result['success'])
|
||||
# make sure it's actually sending something we want to the queue
|
||||
self.mock_xqueue.send_to_queue.assert_called_with(body = json.dumps(contents), header=ANY)
|
||||
|
||||
state = json.loads(self.openendedmodule.get_instance_state())
|
||||
self.assertIsNotNone(state['state'], OpenEndedModule.DONE)
|
||||
|
||||
def test_send_to_grader(self):
|
||||
submission = "This is a student submission"
|
||||
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
|
||||
student_info = {'anonymous_student_id': test_system.anonymous_student_id,
|
||||
'submission_time': qtime}
|
||||
contents = self.openendedmodule.payload.copy()
|
||||
contents.update({
|
||||
'student_info': json.dumps(student_info),
|
||||
'student_response': submission,
|
||||
'max_score': self.max_score
|
||||
})
|
||||
result = self.openendedmodule.send_to_grader(submission, test_system)
|
||||
self.assertTrue(result)
|
||||
self.mock_xqueue.send_to_queue.assert_called_with(body = json.dumps(contents), header=ANY)
|
||||
|
||||
def update_score_single(self):
|
||||
self.openendedmodule.new_history_entry("New Entry")
|
||||
score_msg = {
|
||||
'correct': True,
|
||||
'score': 4,
|
||||
'msg' : 'Grader Message',
|
||||
'feedback': "Grader Feedback"
|
||||
}
|
||||
get = {'queuekey': "abcd",
|
||||
'xqueue_body': score_msg}
|
||||
self.openendedmodule.update_score(get, test_system)
|
||||
|
||||
def update_score_single(self):
|
||||
self.openendedmodule.new_history_entry("New Entry")
|
||||
feedback = {
|
||||
"success": True,
|
||||
"feedback": "Grader Feedback"
|
||||
}
|
||||
score_msg = {
|
||||
'correct': True,
|
||||
'score': 4,
|
||||
'msg' : 'Grader Message',
|
||||
'feedback': json.dumps(feedback),
|
||||
'grader_type': 'IN',
|
||||
'grader_id': '1',
|
||||
'submission_id': '1',
|
||||
'success': True,
|
||||
'rubric_scores': [0],
|
||||
'rubric_scores_complete': True,
|
||||
'rubric_xml': etree.tostring(self.rubric)
|
||||
}
|
||||
get = {'queuekey': "abcd",
|
||||
'xqueue_body': json.dumps(score_msg)}
|
||||
self.openendedmodule.update_score(get, test_system)
|
||||
|
||||
def test_latest_post_assessment(self):
|
||||
self.update_score_single()
|
||||
assessment = self.openendedmodule.latest_post_assessment(test_system)
|
||||
self.assertFalse(assessment == '')
|
||||
# check for errors
|
||||
self.assertFalse('errors' in assessment)
|
||||
|
||||
def test_update_score(self):
|
||||
self.update_score_single()
|
||||
score = self.openendedmodule.latest_score()
|
||||
self.assertEqual(score, 4)
|
||||
|
||||
class CombinedOpenEndedModuleTest(unittest.TestCase):
|
||||
location = Location(["i4x", "edX", "open_ended", "combinedopenended",
|
||||
"SampleQuestion"])
|
||||
|
||||
prompt = "<prompt>This is a question prompt</prompt>"
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
max_score = 3
|
||||
|
||||
metadata = {'attempts': '10', 'max_score': max_score}
|
||||
|
||||
static_data = json.dumps({
|
||||
'max_attempts': 20,
|
||||
'prompt': prompt,
|
||||
'rubric': rubric,
|
||||
'max_score': max_score,
|
||||
'display_name': 'Name'
|
||||
})
|
||||
|
||||
oeparam = etree.XML('''
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
''')
|
||||
|
||||
task_xml1 = '''
|
||||
<selfassessment>
|
||||
<hintprompt>
|
||||
What hint about this problem would you give to someone?
|
||||
</hintprompt>
|
||||
<submitmessage>
|
||||
Save Succcesful. Thanks for participating!
|
||||
</submitmessage>
|
||||
</selfassessment>
|
||||
'''
|
||||
task_xml2 = '''
|
||||
<openended min_score_to_attempt="1" max_score_to_attempt="1">
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>'''
|
||||
definition = {'prompt': etree.XML(prompt), 'rubric': etree.XML(rubric), 'task_xml': [task_xml1, task_xml2]}
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
self.combinedoe = CombinedOpenEndedModule(test_system, self.location, self.definition, self.descriptor, self.static_data, metadata=self.metadata)
|
||||
|
||||
def test_get_tag_name(self):
|
||||
name = self.combinedoe.get_tag_name("<t>Tag</t>")
|
||||
self.assertEqual(name, "t")
|
||||
|
||||
def test_get_last_response(self):
|
||||
response_dict = self.combinedoe.get_last_response(0)
|
||||
self.assertEqual(response_dict['type'], "selfassessment")
|
||||
self.assertEqual(response_dict['max_score'], self.max_score)
|
||||
self.assertEqual(response_dict['state'], CombinedOpenEndedModule.INITIAL)
|
||||
|
||||
def test_update_task_states(self):
|
||||
changed = self.combinedoe.update_task_states()
|
||||
self.assertFalse(changed)
|
||||
|
||||
current_task = self.combinedoe.current_task
|
||||
current_task.change_state(CombinedOpenEndedModule.DONE)
|
||||
changed = self.combinedoe.update_task_states()
|
||||
|
||||
self.assertTrue(changed)
|
||||
|
||||
|
||||
119
common/lib/xmodule/xmodule/tests/test_conditional.py
Normal file
119
common/lib/xmodule/xmodule/tests/test_conditional.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
from path import path
|
||||
import unittest
|
||||
from fs.memoryfs import MemoryFS
|
||||
|
||||
from lxml import etree
|
||||
from mock import Mock, patch
|
||||
from collections import defaultdict
|
||||
|
||||
from xmodule.x_module import XMLParsingSystem, XModuleDescriptor
|
||||
from xmodule.xml_module import is_pointer_tag
|
||||
from xmodule.errortracker import make_error_tracker
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from .test_export import DATA_DIR
|
||||
|
||||
ORG = 'test_org'
|
||||
COURSE = 'conditional' # name of directory with course data
|
||||
|
||||
from . import test_system
|
||||
|
||||
class DummySystem(ImportSystem):
|
||||
|
||||
@patch('xmodule.modulestore.xml.OSFS', lambda dir: MemoryFS())
|
||||
def __init__(self, load_error_modules):
|
||||
|
||||
xmlstore = XMLModuleStore("data_dir", course_dirs=[], load_error_modules=load_error_modules)
|
||||
course_id = "/".join([ORG, COURSE, 'test_run'])
|
||||
course_dir = "test_dir"
|
||||
policy = {}
|
||||
error_tracker = Mock()
|
||||
parent_tracker = Mock()
|
||||
|
||||
super(DummySystem, self).__init__(
|
||||
xmlstore,
|
||||
course_id,
|
||||
course_dir,
|
||||
policy,
|
||||
error_tracker,
|
||||
parent_tracker,
|
||||
load_error_modules=load_error_modules,
|
||||
)
|
||||
|
||||
def render_template(self, template, context):
|
||||
raise Exception("Shouldn't be called")
|
||||
|
||||
|
||||
|
||||
class ConditionalModuleTest(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def get_system(load_error_modules=True):
|
||||
'''Get a dummy system'''
|
||||
return DummySystem(load_error_modules)
|
||||
|
||||
def get_course(self, name):
|
||||
"""Get a test course by directory name. If there's more than one, error."""
|
||||
print "Importing {0}".format(name)
|
||||
|
||||
modulestore = XMLModuleStore(DATA_DIR, course_dirs=[name])
|
||||
courses = modulestore.get_courses()
|
||||
self.modulestore = modulestore
|
||||
self.assertEquals(len(courses), 1)
|
||||
return courses[0]
|
||||
|
||||
def test_conditional_module(self):
|
||||
"""Make sure that conditional module works"""
|
||||
|
||||
print "Starting import"
|
||||
course = self.get_course('conditional')
|
||||
|
||||
print "Course: ", course
|
||||
print "id: ", course.id
|
||||
|
||||
instance_states = dict(problem=None)
|
||||
shared_state = None
|
||||
|
||||
def inner_get_module(descriptor):
|
||||
if isinstance(descriptor, Location):
|
||||
location = descriptor
|
||||
descriptor = self.modulestore.get_instance(course.id, location, depth=None)
|
||||
location = descriptor.location
|
||||
instance_state = instance_states.get(location.category,None)
|
||||
print "inner_get_module, location.category=%s, inst_state=%s" % (location.category, instance_state)
|
||||
return descriptor.xmodule_constructor(test_system)(instance_state, shared_state)
|
||||
|
||||
location = Location(["i4x", "edX", "cond_test", "conditional","condone"])
|
||||
module = inner_get_module(location)
|
||||
|
||||
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None):
|
||||
return text
|
||||
test_system.replace_urls = replace_urls
|
||||
test_system.get_module = inner_get_module
|
||||
|
||||
print "module: ", module
|
||||
|
||||
html = module.get_html()
|
||||
print "html type: ", type(html)
|
||||
print "html: ", html
|
||||
html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}"
|
||||
self.assertEqual(html, html_expect)
|
||||
|
||||
gdi = module.get_display_items()
|
||||
print "gdi=", gdi
|
||||
|
||||
ajax = json.loads(module.handle_ajax('',''))
|
||||
self.assertTrue('xmodule.conditional_module' in ajax['html'])
|
||||
print "ajax: ", ajax
|
||||
|
||||
# now change state of the capa problem to make it completed
|
||||
instance_states['problem'] = json.dumps({'attempts':1})
|
||||
|
||||
ajax = json.loads(module.handle_ajax('',''))
|
||||
self.assertTrue('This is a secret' in ajax['html'])
|
||||
print "post-attempt ajax: ", ajax
|
||||
|
||||
|
||||
@@ -10,8 +10,16 @@ from . import test_system
|
||||
|
||||
class SelfAssessmentTest(unittest.TestCase):
|
||||
|
||||
definition = {'rubric': 'A rubric',
|
||||
'prompt': 'Who?',
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
|
||||
prompt = etree.XML("<prompt>This is sample prompt text.</prompt>")
|
||||
definition = {'rubric': rubric,
|
||||
'prompt': prompt,
|
||||
'submitmessage': 'Shall we submit now?',
|
||||
'hintprompt': 'Consider this...',
|
||||
}
|
||||
@@ -23,47 +31,47 @@ class SelfAssessmentTest(unittest.TestCase):
|
||||
|
||||
descriptor = Mock()
|
||||
|
||||
def test_import(self):
|
||||
def setUp(self):
|
||||
state = json.dumps({'student_answers': ["Answer 1", "answer 2", "answer 3"],
|
||||
'scores': [0, 1],
|
||||
'hints': ['o hai'],
|
||||
'state': SelfAssessmentModule.INITIAL,
|
||||
'attempts': 2})
|
||||
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
|
||||
prompt = etree.XML("<prompt>Text</prompt>")
|
||||
static_data = {
|
||||
'max_attempts': 10,
|
||||
'rubric': etree.XML(rubric),
|
||||
'prompt': prompt,
|
||||
'rubric': etree.XML(self.rubric),
|
||||
'prompt': self.prompt,
|
||||
'max_score': 1,
|
||||
'display_name': "Name"
|
||||
'display_name': "Name",
|
||||
'accept_file_upload' : False,
|
||||
}
|
||||
|
||||
module = SelfAssessmentModule(test_system, self.location,
|
||||
self.module = SelfAssessmentModule(test_system, self.location,
|
||||
self.definition, self.descriptor,
|
||||
static_data, state, metadata=self.metadata)
|
||||
|
||||
self.assertEqual(module.get_score()['score'], 0)
|
||||
def test_get_html(self):
|
||||
html = self.module.get_html(test_system)
|
||||
self.assertTrue("This is sample prompt text" in html)
|
||||
|
||||
def test_self_assessment_flow(self):
|
||||
|
||||
self.assertEqual(self.module.get_score()['score'], 0)
|
||||
|
||||
self.module.save_answer({'student_answer': "I am an answer"}, test_system)
|
||||
self.assertEqual(self.module.state, self.module.ASSESSING)
|
||||
|
||||
self.module.save_assessment({'assessment': '0'}, test_system)
|
||||
self.assertEqual(self.module.state, self.module.DONE)
|
||||
|
||||
|
||||
module.save_answer({'student_answer': "I am an answer"}, test_system)
|
||||
self.assertEqual(module.state, module.ASSESSING)
|
||||
|
||||
module.save_assessment({'assessment': '0'}, test_system)
|
||||
self.assertEqual(module.state, module.DONE)
|
||||
|
||||
d = module.reset({})
|
||||
d = self.module.reset({})
|
||||
self.assertTrue(d['success'])
|
||||
self.assertEqual(module.state, module.INITIAL)
|
||||
self.assertEqual(self.module.state, self.module.INITIAL)
|
||||
|
||||
# if we now assess as right, skip the REQUEST_HINT state
|
||||
module.save_answer({'student_answer': 'answer 4'}, test_system)
|
||||
module.save_assessment({'assessment': '1'}, test_system)
|
||||
self.assertEqual(module.state, module.DONE)
|
||||
self.module.save_answer({'student_answer': 'answer 4'}, test_system)
|
||||
self.module.save_assessment({'assessment': '1'}, test_system)
|
||||
self.assertEqual(self.module.state, self.module.DONE)
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ class ResourceTemplates(object):
|
||||
log.warning("Skipping unknown template file %s" % template_file)
|
||||
continue
|
||||
template_content = resource_string(__name__, os.path.join(dirname, template_file))
|
||||
template = yaml.load(template_content)
|
||||
template = yaml.safe_load(template_content)
|
||||
templates.append(Template(**template))
|
||||
|
||||
return templates
|
||||
@@ -585,6 +585,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
|
||||
self._inherited_metadata.add(attr)
|
||||
self.metadata[attr] = metadata[attr]
|
||||
|
||||
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 []
|
||||
|
||||
def get_children(self):
|
||||
"""Returns a list of XModuleDescriptor instances for the children of
|
||||
this module"""
|
||||
|
||||
3
common/test/data/conditional/README.md
Normal file
3
common/test/data/conditional/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
course for testing conditional module
|
||||
|
||||
|
||||
3
common/test/data/conditional/conditional/condone.xml
Normal file
3
common/test/data/conditional/conditional/condone.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<conditional condition="require_attempted" required="problem/choiceprob">
|
||||
<html url_name="secret_page" />
|
||||
</conditional>
|
||||
8
common/test/data/conditional/course.xml
Normal file
8
common/test/data/conditional/course.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<course name="Conditional Course" org="edX" course="cond_test" graceperiod="1 day 5 hours 59 minutes 59 seconds" slug="2012_Fall" start="2015-07-17T12:00">
|
||||
<chapter name="Problems with Condition">
|
||||
<sequential>
|
||||
<problem url_name="choiceprob" />
|
||||
<conditional url_name="condone"/>
|
||||
</sequential>
|
||||
</chapter>
|
||||
</course>
|
||||
4
common/test/data/conditional/html/secret_page.xml
Normal file
4
common/test/data/conditional/html/secret_page.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<html display_name="Secret Page">
|
||||
<p>This is a secret!</p>
|
||||
</html>
|
||||
|
||||
22
common/test/data/conditional/problem/choiceprob.xml
Normal file
22
common/test/data/conditional/problem/choiceprob.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<problem display_name="S3E2: Lorentz Force">
|
||||
|
||||
<startouttext/>
|
||||
<p>Consider a hypothetical magnetic field pointing out of your computer screen. Now imagine an electron traveling from right to left in the plane of your screen. A diagram of this situation is show below…</p>
|
||||
<center><img width="400" src="/static/images/LSQimages/LSQ_W01_8.png"/></center>
|
||||
|
||||
<p>a. The magnitude of the force experienced by the electron is proportional the product of which of the following? (Select all that apply.)</p>
|
||||
<endouttext/>
|
||||
<choiceresponse>
|
||||
<checkboxgroup>
|
||||
<!-- include ellipses to test non-ascii characters -->
|
||||
<choice correct="true"><text>Magnetic field strength…</text></choice>
|
||||
<choice correct="false"><text>Electric field strength…</text></choice>
|
||||
<choice correct="true"><text>Electric charge of the electron…</text></choice>
|
||||
<choice correct="false"><text>Radius of the electron…</text></choice>
|
||||
<choice correct="false"><text>Mass of the electron…</text></choice>
|
||||
<choice correct="true"><text>Velocity of the electron…</text></choice>
|
||||
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
|
||||
</problem>
|
||||
1
common/test/data/full/tabs/resources.html
Normal file
1
common/test/data/full/tabs/resources.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>This is another sample tab</p>
|
||||
Reference in New Issue
Block a user