Merge branch 'master' into diana/rubric-ui-improvements
Conflicts: common/lib/xmodule/xmodule/combined_open_ended_rubric.py common/lib/xmodule/xmodule/self_assessment_module.py lms/djangoapps/open_ended_grading/grading_service.py lms/static/sass/course.scss
This commit is contained in:
@@ -1 +1 @@
|
||||
1.8.7-p371
|
||||
1.8.7-p371
|
||||
@@ -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,6 +24,7 @@ class CombinedOpenEndedRubric(object):
|
||||
Output:
|
||||
html: the html that corresponds to the xml given
|
||||
'''
|
||||
success = False
|
||||
try:
|
||||
rubric_categories = self.extract_categories(rubric_xml)
|
||||
max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories)
|
||||
@@ -32,9 +34,25 @@ class CombinedOpenEndedRubric(object):
|
||||
'has_score': self.has_score,
|
||||
'view_only': self.view_only,
|
||||
'max_score': max_score})
|
||||
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):
|
||||
'''
|
||||
|
||||
@@ -401,6 +401,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;
|
||||
|
||||
@@ -45,7 +45,6 @@ describe 'CombinedOpenEnded', ->
|
||||
@combined.poll()
|
||||
expect(window.queuePollerID).toBeUndefined()
|
||||
expect(window.setTimeout).not.toHaveBeenCalled()
|
||||
expect(@combined.reload).toHaveBeenCalled()
|
||||
|
||||
describe 'rebind', ->
|
||||
beforeEach ->
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -40,10 +40,12 @@ class @CombinedOpenEnded
|
||||
@wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule')
|
||||
@el = $(element).find('section.combined-open-ended')
|
||||
@combined_open_ended=$(element).find('section.combined-open-ended')
|
||||
@id = @el.data('id')
|
||||
@ajax_url = @el.data('ajax-url')
|
||||
@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')
|
||||
@@ -76,6 +78,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()
|
||||
@@ -87,6 +91,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')
|
||||
@@ -99,7 +113,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
|
||||
@@ -140,22 +154,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"
|
||||
@@ -166,6 +186,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
|
||||
@@ -174,6 +195,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"
|
||||
@@ -181,6 +203,7 @@ class @CombinedOpenEnded
|
||||
if @task_number<@task_count
|
||||
@next_problem()
|
||||
else
|
||||
@show_results_current()
|
||||
@reset_button.show()
|
||||
|
||||
|
||||
@@ -192,17 +215,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.')
|
||||
|
||||
@@ -292,6 +339,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
|
||||
@@ -314,10 +362,31 @@ class @CombinedOpenEnded
|
||||
$.postWithPrefix "#{@ajax_url}/check_for_score", (response) =>
|
||||
if response.state == "done" or response.state=="post_assessment"
|
||||
delete window.queuePollerID
|
||||
@reload()
|
||||
@reload
|
||||
else
|
||||
window.queuePollerID = window.setTimeout(@poll, 10000)
|
||||
|
||||
# wrap this so that it can be mocked
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -444,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'])):
|
||||
@@ -455,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:
|
||||
@@ -483,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']:
|
||||
@@ -561,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):
|
||||
"""
|
||||
@@ -609,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).
|
||||
@@ -130,12 +134,26 @@ class OpenEndedChild(object):
|
||||
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, False)
|
||||
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):
|
||||
|
||||
@@ -39,7 +39,8 @@ class OpenEndedChildTest(unittest.TestCase):
|
||||
'prompt': prompt,
|
||||
'rubric': rubric,
|
||||
'max_score': max_score,
|
||||
'display_name': 'Name'
|
||||
'display_name': 'Name',
|
||||
'accept_file_upload' : False,
|
||||
}
|
||||
definition = Mock()
|
||||
descriptor = Mock()
|
||||
@@ -152,7 +153,8 @@ class OpenEndedModuleTest(unittest.TestCase):
|
||||
'prompt': prompt,
|
||||
'rubric': rubric,
|
||||
'max_score': max_score,
|
||||
'display_name': 'Name'
|
||||
'display_name': 'Name',
|
||||
'accept_file_upload': False,
|
||||
}
|
||||
|
||||
oeparam = etree.XML('''
|
||||
@@ -270,7 +272,7 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
|
||||
<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
|
||||
max_score = 3
|
||||
|
||||
metadata = {'attempts': '10', 'max_score': max_score}
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ class SelfAssessmentTest(unittest.TestCase):
|
||||
'rubric': etree.XML(self.rubric),
|
||||
'prompt': self.prompt,
|
||||
'max_score': 1,
|
||||
'display_name': "Name"
|
||||
'display_name': "Name",
|
||||
'accept_file_upload' : False,
|
||||
}
|
||||
|
||||
self.module = SelfAssessmentModule(test_system, self.location,
|
||||
|
||||
@@ -18,8 +18,10 @@ from django.core.urlresolvers import reverse
|
||||
|
||||
from fs.errors import ResourceNotFoundError
|
||||
|
||||
from lxml.html import rewrite_links
|
||||
from courseware.access import has_access
|
||||
from static_replace import replace_urls
|
||||
|
||||
from lxml.html import rewrite_links
|
||||
from module_render import get_module
|
||||
from courseware.access import has_access
|
||||
from static_replace import replace_urls
|
||||
@@ -27,13 +29,10 @@ from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.x_module import XModule
|
||||
|
||||
|
||||
|
||||
from open_ended_grading.peer_grading_service import PeerGradingService
|
||||
from open_ended_grading.staff_grading_service import StaffGradingService
|
||||
from student.models import unique_id_for_user
|
||||
|
||||
from open_ended_grading import open_ended_notifications
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class InvalidTabsException(Exception):
|
||||
@@ -118,49 +117,45 @@ def _textbooks(tab, user, course, active_page):
|
||||
def _staff_grading(tab, user, course, active_page):
|
||||
if has_access(user, course, 'staff'):
|
||||
link = reverse('staff_grading', args=[course.id])
|
||||
staff_gs = StaffGradingService(settings.STAFF_GRADING_INTERFACE)
|
||||
pending_grading=False
|
||||
tab_name = "Staff grading"
|
||||
img_path= ""
|
||||
try:
|
||||
notifications = json.loads(staff_gs.get_notifications(course.id))
|
||||
if notifications['success']:
|
||||
if notifications['staff_needs_to_grade']:
|
||||
pending_grading=True
|
||||
except:
|
||||
#Non catastrophic error, so no real action
|
||||
log.info("Problem with getting notifications from staff grading service.")
|
||||
|
||||
if pending_grading:
|
||||
img_path = "/static/images/slider-handle.png"
|
||||
tab_name = "Staff grading"
|
||||
|
||||
notifications = open_ended_notifications.staff_grading_notifications(course, user)
|
||||
pending_grading = notifications['pending_grading']
|
||||
img_path = notifications['img_path']
|
||||
|
||||
tab = [CourseTab(tab_name, link, active_page == "staff_grading", pending_grading, img_path)]
|
||||
return tab
|
||||
return []
|
||||
|
||||
def _peer_grading(tab, user, course, active_page):
|
||||
|
||||
if user.is_authenticated():
|
||||
link = reverse('peer_grading', args=[course.id])
|
||||
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE)
|
||||
pending_grading=False
|
||||
tab_name = "Peer grading"
|
||||
img_path= ""
|
||||
try:
|
||||
notifications = json.loads(peer_gs.get_notifications(course.id,unique_id_for_user(user)))
|
||||
if notifications['success']:
|
||||
if notifications['student_needs_to_peer_grade']:
|
||||
pending_grading=True
|
||||
except:
|
||||
#Non catastrophic error, so no real action
|
||||
log.info("Problem with getting notifications from peer grading service.")
|
||||
|
||||
if pending_grading:
|
||||
img_path = "/static/images/slider-handle.png"
|
||||
notifications = open_ended_notifications.peer_grading_notifications(course, user)
|
||||
pending_grading = notifications['pending_grading']
|
||||
img_path = notifications['img_path']
|
||||
|
||||
tab = [CourseTab(tab_name, link, active_page == "peer_grading", pending_grading, img_path)]
|
||||
return tab
|
||||
return []
|
||||
|
||||
def _combined_open_ended_grading(tab, user, course, active_page):
|
||||
if user.is_authenticated():
|
||||
link = reverse('open_ended_notifications', args=[course.id])
|
||||
tab_name = "Open Ended Panel"
|
||||
|
||||
notifications = open_ended_notifications.combined_notifications(course, user)
|
||||
pending_grading = notifications['pending_grading']
|
||||
img_path = notifications['img_path']
|
||||
|
||||
tab = [CourseTab(tab_name, link, active_page == "open_ended", pending_grading, img_path)]
|
||||
return tab
|
||||
return []
|
||||
|
||||
|
||||
#### Validators
|
||||
|
||||
|
||||
@@ -198,6 +193,7 @@ VALID_TAB_TYPES = {
|
||||
'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab),
|
||||
'peer_grading': TabImpl(null_validator, _peer_grading),
|
||||
'staff_grading': TabImpl(null_validator, _staff_grading),
|
||||
'open_ended': TabImpl(null_validator, _combined_open_ended_grading),
|
||||
}
|
||||
|
||||
|
||||
@@ -326,4 +322,4 @@ def get_static_tab_contents(request, cache, course, tab):
|
||||
if tab_module is not None:
|
||||
html = tab_module.get_html()
|
||||
|
||||
return html
|
||||
return html
|
||||
@@ -0,0 +1,59 @@
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, ConnectionError, HTTPError
|
||||
import sys
|
||||
from grading_service import GradingService
|
||||
from grading_service import GradingServiceError
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, Http404
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class ControllerQueryService(GradingService):
|
||||
"""
|
||||
Interface to staff grading backend.
|
||||
"""
|
||||
def __init__(self, config):
|
||||
super(ControllerQueryService, self).__init__(config)
|
||||
self.check_eta_url = self.url + '/get_submission_eta/'
|
||||
self.is_unique_url = self.url + '/is_name_unique/'
|
||||
self.combined_notifications_url = self.url + '/combined_notifications/'
|
||||
self.grading_status_list_url = self.url + '/get_grading_status_list/'
|
||||
|
||||
def check_if_name_is_unique(self, location, problem_id, course_id):
|
||||
params = {
|
||||
'course_id': course_id,
|
||||
'location' : location,
|
||||
'problem_id' : problem_id
|
||||
}
|
||||
response = self.get(self.is_unique_url, params)
|
||||
return response
|
||||
|
||||
def check_for_eta(self, location):
|
||||
params = {
|
||||
'location' : location,
|
||||
}
|
||||
response = self.get(self.check_eta_url, params)
|
||||
return response
|
||||
|
||||
def check_combined_notifications(self, course_id, student_id, user_is_staff, last_time_viewed):
|
||||
params= {
|
||||
'student_id' : student_id,
|
||||
'course_id' : course_id,
|
||||
'user_is_staff' : user_is_staff,
|
||||
'last_time_viewed' : last_time_viewed,
|
||||
}
|
||||
log.debug(self.combined_notifications_url)
|
||||
response = self.get(self.combined_notifications_url,params)
|
||||
return response
|
||||
|
||||
def get_grading_status_list(self, course_id, student_id):
|
||||
params = {
|
||||
'student_id' : student_id,
|
||||
'course_id' : course_id,
|
||||
}
|
||||
|
||||
response = self.get(self.grading_status_list_url, params)
|
||||
return response
|
||||
@@ -116,7 +116,7 @@ class GradingService(object):
|
||||
if 'rubric' in response_json:
|
||||
rubric = response_json['rubric']
|
||||
rubric_renderer = CombinedOpenEndedRubric(self.system, view_only)
|
||||
rubric_html = rubric_renderer.render_rubric(rubric)
|
||||
success, rubric_html = rubric_renderer.render_rubric(rubric)
|
||||
response_json['rubric'] = rubric_html
|
||||
return response_json
|
||||
# if we can't parse the rubric into HTML,
|
||||
|
||||
158
lms/djangoapps/open_ended_grading/open_ended_notifications.py
Normal file
158
lms/djangoapps/open_ended_grading/open_ended_notifications.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from django.conf import settings
|
||||
from staff_grading_service import StaffGradingService
|
||||
from peer_grading_service import PeerGradingService
|
||||
from open_ended_grading.controller_query_service import ControllerQueryService
|
||||
import json
|
||||
from student.models import unique_id_for_user
|
||||
import open_ended_util
|
||||
from courseware.models import StudentModule
|
||||
import logging
|
||||
from courseware.access import has_access
|
||||
from util.cache import cache
|
||||
import datetime
|
||||
|
||||
log=logging.getLogger(__name__)
|
||||
|
||||
NOTIFICATION_CACHE_TIME = 300
|
||||
KEY_PREFIX = "open_ended_"
|
||||
|
||||
NOTIFICATION_TYPES = (
|
||||
('student_needs_to_peer_grade', 'peer_grading', 'Peer Grading'),
|
||||
('staff_needs_to_grade', 'staff_grading', 'Staff Grading'),
|
||||
('new_student_grading_to_view', 'open_ended_problems', 'Problems you have submitted')
|
||||
)
|
||||
|
||||
def staff_grading_notifications(course, user):
|
||||
staff_gs = StaffGradingService(settings.STAFF_GRADING_INTERFACE)
|
||||
pending_grading=False
|
||||
img_path= ""
|
||||
course_id = course.id
|
||||
student_id = unique_id_for_user(user)
|
||||
notification_type = "staff"
|
||||
|
||||
success, notification_dict = get_value_from_cache(student_id, course_id, notification_type)
|
||||
if success:
|
||||
return notification_dict
|
||||
|
||||
try:
|
||||
notifications = json.loads(staff_gs.get_notifications(course_id))
|
||||
if notifications['success']:
|
||||
if notifications['staff_needs_to_grade']:
|
||||
pending_grading=True
|
||||
except:
|
||||
#Non catastrophic error, so no real action
|
||||
notifications = {}
|
||||
log.info("Problem with getting notifications from staff grading service.")
|
||||
|
||||
if pending_grading:
|
||||
img_path = "/static/images/slider-handle.png"
|
||||
|
||||
notification_dict = {'pending_grading' : pending_grading, 'img_path' : img_path, 'response' : notifications}
|
||||
|
||||
set_value_in_cache(student_id, course_id, notification_type, notification_dict)
|
||||
|
||||
return notification_dict
|
||||
|
||||
def peer_grading_notifications(course, user):
|
||||
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE)
|
||||
pending_grading=False
|
||||
img_path= ""
|
||||
course_id = course.id
|
||||
student_id = unique_id_for_user(user)
|
||||
notification_type = "peer"
|
||||
|
||||
success, notification_dict = get_value_from_cache(student_id, course_id, notification_type)
|
||||
if success:
|
||||
return notification_dict
|
||||
|
||||
try:
|
||||
notifications = json.loads(peer_gs.get_notifications(course_id,student_id))
|
||||
if notifications['success']:
|
||||
if notifications['student_needs_to_peer_grade']:
|
||||
pending_grading=True
|
||||
except:
|
||||
#Non catastrophic error, so no real action
|
||||
notifications = {}
|
||||
log.info("Problem with getting notifications from peer grading service.")
|
||||
|
||||
if pending_grading:
|
||||
img_path = "/static/images/slider-handle.png"
|
||||
|
||||
notification_dict = {'pending_grading' : pending_grading, 'img_path' : img_path, 'response' : notifications}
|
||||
|
||||
set_value_in_cache(student_id, course_id, notification_type, notification_dict)
|
||||
|
||||
return notification_dict
|
||||
|
||||
def combined_notifications(course, user):
|
||||
controller_url = open_ended_util.get_controller_url()
|
||||
controller_qs = ControllerQueryService(controller_url)
|
||||
student_id = unique_id_for_user(user)
|
||||
user_is_staff = has_access(user, course, 'staff')
|
||||
course_id = course.id
|
||||
notification_type = "combined"
|
||||
|
||||
success, notification_dict = get_value_from_cache(student_id, course_id, notification_type)
|
||||
if success:
|
||||
return notification_dict
|
||||
|
||||
min_time_to_query = user.last_login
|
||||
last_module_seen = StudentModule.objects.filter(student=user, course_id = course_id, modified__gt=min_time_to_query).values('modified').order_by('-modified')
|
||||
last_module_seen_count = last_module_seen.count()
|
||||
|
||||
if last_module_seen_count>0:
|
||||
last_time_viewed = last_module_seen[0]['modified'] - datetime.timedelta(seconds=(NOTIFICATION_CACHE_TIME + 60))
|
||||
else:
|
||||
last_time_viewed = user.last_login
|
||||
|
||||
pending_grading= False
|
||||
|
||||
img_path= ""
|
||||
try:
|
||||
controller_response = controller_qs.check_combined_notifications(course.id,student_id, user_is_staff, last_time_viewed)
|
||||
log.debug(controller_response)
|
||||
notifications = json.loads(controller_response)
|
||||
if notifications['success']:
|
||||
if notifications['overall_need_to_check']:
|
||||
pending_grading=True
|
||||
except:
|
||||
#Non catastrophic error, so no real action
|
||||
notifications = {}
|
||||
log.exception("Problem with getting notifications from controller query service.")
|
||||
|
||||
if pending_grading:
|
||||
img_path = "/static/images/slider-handle.png"
|
||||
|
||||
notification_dict = {'pending_grading' : pending_grading, 'img_path' : img_path, 'response' : notifications}
|
||||
|
||||
set_value_in_cache(student_id, course_id, notification_type, notification_dict)
|
||||
|
||||
return notification_dict
|
||||
|
||||
def get_value_from_cache(student_id, course_id, notification_type):
|
||||
key_name = create_key_name(student_id, course_id, notification_type)
|
||||
success, value = _get_value_from_cache(key_name)
|
||||
return success, value
|
||||
|
||||
def set_value_in_cache(student_id, course_id, notification_type, value):
|
||||
key_name = create_key_name(student_id, course_id, notification_type)
|
||||
_set_value_in_cache(key_name, value)
|
||||
|
||||
def create_key_name(student_id, course_id, notification_type):
|
||||
key_name = "{prefix}{type}_{course}_{student}".format(prefix=KEY_PREFIX, type=notification_type, course=course_id, student=student_id)
|
||||
return key_name
|
||||
|
||||
def _get_value_from_cache(key_name):
|
||||
value = cache.get(key_name)
|
||||
success = False
|
||||
if value is None:
|
||||
return success , value
|
||||
try:
|
||||
value = json.loads(value)
|
||||
success = True
|
||||
except:
|
||||
pass
|
||||
return success , value
|
||||
|
||||
def _set_value_in_cache(key_name, value):
|
||||
cache.set(key_name, json.dumps(value), NOTIFICATION_CACHE_TIME)
|
||||
12
lms/djangoapps/open_ended_grading/open_ended_util.py
Normal file
12
lms/djangoapps/open_ended_grading/open_ended_util.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
log=logging.getLogger(__name__)
|
||||
|
||||
def get_controller_url():
|
||||
peer_grading_url = settings.PEER_GRADING_INTERFACE['url']
|
||||
split_url = peer_grading_url.split("/")
|
||||
controller_url = "http://" + split_url[2] + "/grading_controller"
|
||||
controller_settings=settings.PEER_GRADING_INTERFACE.copy()
|
||||
controller_settings['url'] = controller_url
|
||||
return controller_settings
|
||||
@@ -13,10 +13,17 @@ from courseware.courses import get_course_with_access
|
||||
|
||||
from peer_grading_service import PeerGradingService
|
||||
from peer_grading_service import MockPeerGradingService
|
||||
from controller_query_service import ControllerQueryService
|
||||
from grading_service import GradingServiceError
|
||||
import json
|
||||
from .staff_grading import StaffGrading
|
||||
from student.models import unique_id_for_user
|
||||
|
||||
import open_ended_util
|
||||
import open_ended_notifications
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import search
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,18 +33,34 @@ if settings.MOCK_PEER_GRADING:
|
||||
else:
|
||||
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE)
|
||||
|
||||
controller_url = open_ended_util.get_controller_url()
|
||||
controller_qs = ControllerQueryService(controller_url)
|
||||
|
||||
"""
|
||||
Reverses the URL from the name and the course id, and then adds a trailing slash if
|
||||
it does not exist yet
|
||||
|
||||
"""
|
||||
def _reverse_with_slash(url_name, course_id):
|
||||
ajax_url = reverse(url_name, kwargs={'course_id': course_id})
|
||||
ajax_url = _reverse_without_slash(url_name, course_id)
|
||||
if not ajax_url.endswith('/'):
|
||||
ajax_url += '/'
|
||||
return ajax_url
|
||||
|
||||
def _reverse_without_slash(url_name, course_id):
|
||||
ajax_url = reverse(url_name, kwargs={'course_id': course_id})
|
||||
return ajax_url
|
||||
|
||||
DESCRIPTION_DICT = {
|
||||
'Peer Grading': "View all problems that require peer assessment in this particular course.",
|
||||
'Staff Grading': "View ungraded submissions submitted by students for the open ended problems in the course.",
|
||||
'Problems you have submitted': "View open ended problems that you have previously submitted for grading."
|
||||
}
|
||||
ALERT_DICT = {
|
||||
'Peer Grading': "New submissions to grade",
|
||||
'Staff Grading': "New submissions to grade",
|
||||
'Problems you have submitted': "New grades have been returned"
|
||||
}
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def staff_grading(request, course_id):
|
||||
"""
|
||||
@@ -114,5 +137,111 @@ def peer_grading_problem(request, course_id):
|
||||
'ajax_url': ajax_url,
|
||||
# Checked above
|
||||
'staff_access': False, })
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def student_problem_list(request, course_id):
|
||||
'''
|
||||
Show a student problem list
|
||||
'''
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
student_id = unique_id_for_user(request.user)
|
||||
|
||||
# call problem list service
|
||||
success = False
|
||||
error_text = ""
|
||||
problem_list = []
|
||||
base_course_url = reverse('courses')
|
||||
|
||||
try:
|
||||
problem_list_json = controller_qs.get_grading_status_list(course_id, unique_id_for_user(request.user))
|
||||
problem_list_dict = json.loads(problem_list_json)
|
||||
success = problem_list_dict['success']
|
||||
if 'error' in problem_list_dict:
|
||||
error_text = problem_list_dict['error']
|
||||
|
||||
problem_list = problem_list_dict['problem_list']
|
||||
|
||||
for i in xrange(0,len(problem_list)):
|
||||
problem_url_parts = search.path_to_location(modulestore(), course.id, problem_list[i]['location'])
|
||||
problem_url = base_course_url + "/"
|
||||
for z in xrange(0,len(problem_url_parts)):
|
||||
part = problem_url_parts[z]
|
||||
if part is not None:
|
||||
if z==1:
|
||||
problem_url += "courseware/"
|
||||
problem_url += part + "/"
|
||||
|
||||
problem_list[i].update({'actual_url' : problem_url})
|
||||
|
||||
except GradingServiceError:
|
||||
error_text = "Error occured while contacting the grading service"
|
||||
success = False
|
||||
# catch error if if the json loads fails
|
||||
except ValueError:
|
||||
error_text = "Could not get problem list"
|
||||
success = False
|
||||
|
||||
ajax_url = _reverse_with_slash('open_ended_problems', course_id)
|
||||
|
||||
return render_to_response('open_ended_problems/open_ended_problems.html', {
|
||||
'course': course,
|
||||
'course_id': course_id,
|
||||
'ajax_url': ajax_url,
|
||||
'success': success,
|
||||
'problem_list': problem_list,
|
||||
'error_text': error_text,
|
||||
# Checked above
|
||||
'staff_access': False, })
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def combined_notifications(request, course_id):
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
user = request.user
|
||||
notifications = open_ended_notifications.combined_notifications(course, user)
|
||||
log.debug(notifications)
|
||||
response = notifications['response']
|
||||
notification_tuples=open_ended_notifications.NOTIFICATION_TYPES
|
||||
|
||||
notification_list = []
|
||||
for response_num in xrange(0,len(notification_tuples)):
|
||||
tag=notification_tuples[response_num][0]
|
||||
if tag in response:
|
||||
url_name = notification_tuples[response_num][1]
|
||||
human_name = notification_tuples[response_num][2]
|
||||
url = _reverse_without_slash(url_name, course_id)
|
||||
has_img = response[tag]
|
||||
|
||||
# check to make sure we have descriptions and alert messages
|
||||
if human_name in DESCRIPTION_DICT:
|
||||
description = DESCRIPTION_DICT[human_name]
|
||||
else:
|
||||
description = ""
|
||||
|
||||
if human_name in ALERT_DICT:
|
||||
alert_message = ALERT_DICT[human_name]
|
||||
else:
|
||||
alert_message = ""
|
||||
|
||||
notification_item = {
|
||||
'url' : url,
|
||||
'name' : human_name,
|
||||
'alert' : has_img,
|
||||
'description': description,
|
||||
'alert_message': alert_message
|
||||
}
|
||||
notification_list.append(notification_item)
|
||||
|
||||
ajax_url = _reverse_with_slash('open_ended_notifications', course_id)
|
||||
combined_dict = {
|
||||
'error_text' : "",
|
||||
'notification_list' : notification_list,
|
||||
'course' : course,
|
||||
'success' : True,
|
||||
'ajax_url' : ajax_url,
|
||||
}
|
||||
|
||||
return render_to_response('open_ended_problems/combined_notifications.html',
|
||||
combined_dict
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
@import "course/tabs";
|
||||
@import "course/staff_grading";
|
||||
@import "course/rubric";
|
||||
@import "course/open_ended_grading";
|
||||
|
||||
// instructor
|
||||
@import "course/instructor/instructor";
|
||||
|
||||
65
lms/static/sass/course/_open_ended_grading.scss
Normal file
65
lms/static/sass/course/_open_ended_grading.scss
Normal file
@@ -0,0 +1,65 @@
|
||||
.open-ended-problems,
|
||||
.combined-notifications
|
||||
{
|
||||
padding: 40px;
|
||||
.problem-list
|
||||
{
|
||||
table-layout: auto;
|
||||
margin-top: 10px;
|
||||
width:70%;
|
||||
td, th
|
||||
{
|
||||
padding: 7px;
|
||||
}
|
||||
}
|
||||
.notification-container
|
||||
{
|
||||
margin: 30px 0px;
|
||||
}
|
||||
|
||||
.notification
|
||||
{
|
||||
margin: 10px;
|
||||
width: 30%;
|
||||
@include inline-block;
|
||||
vertical-align: top;
|
||||
.notification-link
|
||||
{
|
||||
display:block;
|
||||
height: 9em;
|
||||
padding: 10px;
|
||||
border: 1px solid black;
|
||||
text-align: center;
|
||||
p
|
||||
{
|
||||
font-size: .9em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
.notification-title
|
||||
{
|
||||
text-transform: uppercase;
|
||||
background: $blue;
|
||||
color: white;
|
||||
padding: 5px 0px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.notification-link:hover
|
||||
{
|
||||
background-color: #eee;
|
||||
}
|
||||
.notification-description
|
||||
{
|
||||
padding-top:5%;
|
||||
}
|
||||
.alert-message
|
||||
{
|
||||
img
|
||||
{
|
||||
vertical-align: baseline;
|
||||
}
|
||||
}
|
||||
@include clearfix;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<section id="combined-open-ended" class="combined-open-ended" data-ajax-url="${ajax_url}" data-allow_reset="${allow_reset}" data-state="${state}" data-task-count="${task_count}" data-task-number="${task_number}">
|
||||
<section id="combined-open-ended" class="combined-open-ended" data-ajax-url="${ajax_url}" data-allow_reset="${allow_reset}" data-state="${state}" data-task-count="${task_count}" data-task-number="${task_number}" data-accept-file-upload = "${accept_file_upload}">
|
||||
|
||||
<h2>${display_name}</h2>
|
||||
<div class="status-container">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="statusitem" data-status-number="${i}">
|
||||
%endif
|
||||
|
||||
Step ${status['task_number']} (${status['human_state']}) : ${status['score']} / ${status['max_score']}
|
||||
${status['task_number']}. ${status['human_task']} (${status['human_state']}) : ${status['score']} / ${status['max_score']}
|
||||
% if status['state'] == 'initial':
|
||||
<span class="unanswered" id="status"></span>
|
||||
% elif status['state'] in ['done', 'post_assessment'] and status['correct'] == 'correct':
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="prompt">
|
||||
${prompt|n}
|
||||
</div>
|
||||
<h4>Answer</h4>
|
||||
<textarea rows="${rows}" cols="${cols}" name="answer" class="answer short-form-response" id="input_${id}">${previous_answer|h}</textarea>
|
||||
|
||||
<div class="message-wrapper"></div>
|
||||
@@ -22,6 +23,8 @@
|
||||
% endif
|
||||
</div>
|
||||
|
||||
<div class="file-upload"></div>
|
||||
|
||||
<input type="button" value="Submit" class="submit-button" name="show"/>
|
||||
<input name="skip" class="skip-button" type="button" value="Skip Post-Assessment"/>
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<%inherit file="/main.html" />
|
||||
<%block name="bodyclass">${course.css_class}</%block>
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
|
||||
<%block name="headextra">
|
||||
<%static:css group='course'/>
|
||||
</%block>
|
||||
|
||||
<%block name="title"><title>${course.number} Combined Notifications</title></%block>
|
||||
|
||||
<%include file="/courseware/course_navigation.html" args="active_page='open_ended'" />
|
||||
|
||||
|
||||
<section class="container">
|
||||
<div class="combined-notifications" data-ajax_url="${ajax_url}">
|
||||
<div class="error-container">${error_text}</div>
|
||||
<h1>Open Ended Console</h1>
|
||||
<h2>Instructions</h2>
|
||||
<p>Here are items that could potentially need your attention.</p>
|
||||
% if success:
|
||||
% if len(notification_list) == 0:
|
||||
<div class="message-container">
|
||||
No items require attention at the moment.
|
||||
</div>
|
||||
%else:
|
||||
<div class="notification-container">
|
||||
%for notification in notification_list:
|
||||
% if notification['alert']:
|
||||
<div class="notification alert">
|
||||
% else:
|
||||
<div class="notification">
|
||||
% endif
|
||||
<a href="${notification['url']}" class="notification-link">
|
||||
<div class="notification-title">${notification['name']}</div>
|
||||
%if notification['alert']:
|
||||
<p class="alert-message"><img src="/static/images/white-error-icon.png" /> ${notification['alert_message']}</p>
|
||||
%endif
|
||||
<div class="notification-description">
|
||||
<p>${notification['description']}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
%endfor
|
||||
</div>
|
||||
%endif
|
||||
%endif
|
||||
</div>
|
||||
</section>
|
||||
49
lms/templates/open_ended_problems/open_ended_problems.html
Normal file
49
lms/templates/open_ended_problems/open_ended_problems.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<%inherit file="/main.html" />
|
||||
<%block name="bodyclass">${course.css_class}</%block>
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
|
||||
<%block name="headextra">
|
||||
<%static:css group='course'/>
|
||||
</%block>
|
||||
|
||||
<%block name="title"><title>${course.number} Open Ended Problems</title></%block>
|
||||
|
||||
<%include file="/courseware/course_navigation.html" args="active_page='open_ended_problems'" />
|
||||
|
||||
|
||||
<section class="container">
|
||||
<div class="open-ended-problems" data-ajax_url="${ajax_url}">
|
||||
<div class="error-container">${error_text}</div>
|
||||
<h1>Open Ended Problems</h1>
|
||||
<h2>Instructions</h2>
|
||||
<p>Here are a list of open ended problems for this course.</p>
|
||||
% if success:
|
||||
% if len(problem_list) == 0:
|
||||
<div class="message-container">
|
||||
You have not attempted any open ended problems yet.
|
||||
</div>
|
||||
%else:
|
||||
<table class="problem-list">
|
||||
<tr>
|
||||
<th>Problem Name</th>
|
||||
<th>Status</th>
|
||||
<th>Type of Grading</th>
|
||||
</tr>
|
||||
%for problem in problem_list:
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${problem['actual_url']}">${problem['problem_name']}</a>
|
||||
</td>
|
||||
<td>
|
||||
${problem['state']}
|
||||
</td>
|
||||
<td>
|
||||
${problem['grader_type']}
|
||||
</td>
|
||||
</tr>
|
||||
%endfor
|
||||
</table>
|
||||
%endif
|
||||
%endif
|
||||
</div>
|
||||
</section>
|
||||
@@ -5,8 +5,9 @@
|
||||
${prompt}
|
||||
</div>
|
||||
|
||||
<h4>Answer</h4>
|
||||
<div>
|
||||
<textarea name="answer" class="answer short-form-response" cols="70" rows="20">${previous_answer|h}</textarea>
|
||||
<textarea name="answer" class="answer short-form-response" cols="70" rows="20">${previous_answer|n}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="open-ended-action"></div>
|
||||
@@ -16,6 +17,7 @@
|
||||
<div class="hint-wrapper">${initial_hint}</div>
|
||||
|
||||
<div class="message-wrapper">${initial_message}</div>
|
||||
|
||||
|
||||
<div class="file-upload"></div>
|
||||
<input type="button" value="Submit" class="submit-button" name="show"/>
|
||||
</section>
|
||||
|
||||
@@ -285,7 +285,11 @@ if settings.COURSEWARE_ENABLED:
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/save_calibration_essay$',
|
||||
'open_ended_grading.peer_grading_service.save_calibration_essay', name='peer_grading_save_calibration_essay'),
|
||||
|
||||
# Cohorts management
|
||||
# Open Ended problem list
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/open_ended_problems$',
|
||||
'open_ended_grading.views.student_problem_list', name='open_ended_problems'),
|
||||
|
||||
# Cohorts management
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts$',
|
||||
'course_groups.views.list_cohorts', name="cohorts"),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts/add$',
|
||||
@@ -304,6 +308,9 @@ if settings.COURSEWARE_ENABLED:
|
||||
'course_groups.views.debug_cohort_mgmt',
|
||||
name="debug_cohort_mgmt"),
|
||||
|
||||
# Open Ended Notifications
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/open_ended_notifications$',
|
||||
'open_ended_grading.views.combined_notifications', name='open_ended_notifications'),
|
||||
)
|
||||
|
||||
# discussion forums live within courseware, so courseware must be enabled first
|
||||
|
||||
@@ -48,7 +48,6 @@ sorl-thumbnail==11.12
|
||||
networkx==1.7
|
||||
pygraphviz==1.1
|
||||
-r repo-requirements.txt
|
||||
pil==1.1.7
|
||||
nltk==2.0.4
|
||||
django-debug-toolbar-mongo
|
||||
dogstatsd-python==0.2.1
|
||||
@@ -59,3 +58,4 @@ Shapely==1.2.16
|
||||
ipython==0.13.1
|
||||
xmltodict==0.4.1
|
||||
paramiko==1.9.0
|
||||
Pillow==1.7.8
|
||||
|
||||
Reference in New Issue
Block a user