Merge branch 'master' into feature/cas/manual-policy

This commit is contained in:
cahrens
2013-02-04 10:54:22 -05:00
69 changed files with 2868 additions and 461 deletions

View File

@@ -1,5 +1,6 @@
from optparse import make_option
from json import dump
from datetime import datetime
from django.core.management.base import BaseCommand, CommandError
@@ -32,10 +33,9 @@ class Command(BaseCommand):
def handle(self, *args, **options):
if len(args) < 1:
raise CommandError("Missing single argument: output JSON file")
# get output location:
outputfile = args[0]
outputfile = datetime.utcnow().strftime("pearson-dump-%Y%m%d-%H%M%S.json")
else:
outputfile = args[0]
# construct the query object to dump:
registrations = TestCenterRegistration.objects.all()
@@ -65,6 +65,8 @@ class Command(BaseCommand):
}
if len(registration.upload_error_message) > 0:
record['registration_error'] = registration.upload_error_message
if len(registration.testcenter_user.upload_error_message) > 0:
record['demographics_error'] = registration.testcenter_user.upload_error_message
if registration.needs_uploading:
record['needs_uploading'] = True
@@ -72,5 +74,5 @@ class Command(BaseCommand):
# dump output:
with open(outputfile, 'w') as outfile:
dump(output, outfile)
dump(output, outfile, indent=2)

View File

@@ -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):
"""
@@ -39,13 +41,15 @@ def get_logger_config(log_dir,
console_loglevel = 'DEBUG' if debug else 'INFO'
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 +82,6 @@ def get_logger_config(log_dir,
}
},
'loggers': {
'django': {
'handlers': handlers,
'propagate': True,
'level': 'INFO'
},
'tracking': {
'handlers': ['tracking'],
'level': 'DEBUG',
@@ -93,16 +92,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 +117,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,

View File

@@ -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>

View File

@@ -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

View File

@@ -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):
'''

View File

@@ -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;

View File

@@ -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;
}

View 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"):&nbsp; </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>

View File

@@ -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)

View File

@@ -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'

View File

@@ -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'

View File

@@ -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()

View File

@@ -17,4 +17,26 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
# export the static assets
contentstore.export_all_for_course(course_location, root_dir + '/' + course_dir + '/static/')
# export the static tabs
export_extra_content(export_fs, modulestore, course_location, 'static_tab', 'tabs', '.html')
# export the 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)
items = modulestore.get_items(query_loc)
if len(items) > 0:
item_dir = export_fs.makeopendir(dirname)
for item in items:
with item_dir.open(item.location.name + file_suffix, 'w') as item_file:
item_file.write(item.definition['data'].encode('utf8'))

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View 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)

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1 @@
<p>This is another sample tab</p>