Commit 01fab2ec by VikParuchuri

Merge pull request #1346 from MITx/feature/vik/combined-xmodule-changes

Feature/vik/combined xmodule changes
parents 1c09d9ca 9310dcbe
1.8.7-p371 1.8.7-p371
\ No newline at end of file
...@@ -35,6 +35,29 @@ MAX_ATTEMPTS = 10000 ...@@ -35,6 +35,29 @@ MAX_ATTEMPTS = 10000
# Overriden by max_score specified in xml. # Overriden by max_score specified in xml.
MAX_SCORE = 1 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): class CombinedOpenEndedModule(XModule):
""" """
This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc). This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc).
...@@ -135,24 +158,31 @@ class CombinedOpenEndedModule(XModule): ...@@ -135,24 +158,31 @@ class CombinedOpenEndedModule(XModule):
#Allow reset is true if student has failed the criteria to move to the next child task #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.allow_reset = instance_state.get('ready_to_reset', False)
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS)) 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 # Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect). # completion (doesn't matter if you self-assessed correct/incorrect).
self._max_score = int(self.metadata.get('max_score', MAX_SCORE)) 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) rubric_renderer = CombinedOpenEndedRubric(system, True)
try: rubric_string = stringify_children(definition['rubric'])
rubric_feedback = rubric_renderer.render_rubric(stringify_children(definition['rubric'])) rubric_renderer.check_if_rubric_is_parseable(rubric_string, location, MAX_SCORE_ALLOWED)
except RubricParsingError:
log.error("Failed to parse rubric in location: {1}".format(location))
raise
#Static data is passed to the child modules to render #Static data is passed to the child modules to render
self.static_data = { self.static_data = {
'max_score': self._max_score, 'max_score': self._max_score,
'max_attempts': self.max_attempts, 'max_attempts': self.max_attempts,
'prompt': definition['prompt'], 'prompt': definition['prompt'],
'rubric': definition['rubric'], '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'] self.task_xml = definition['task_xml']
...@@ -245,13 +275,13 @@ class CombinedOpenEndedModule(XModule): ...@@ -245,13 +275,13 @@ class CombinedOpenEndedModule(XModule):
elif current_task_state is None and self.current_task_number > 0: 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_data = self.get_last_response(self.current_task_number - 1)
last_response = last_response_data['response'] last_response = last_response_data['response']
current_task_state=json.dumps({ current_task_state = json.dumps({
'state' : self.ASSESSING, 'state': self.ASSESSING,
'version' : self.STATE_VERSION, 'version': self.STATE_VERSION,
'max_score' : self._max_score, 'max_score': self._max_score,
'attempts' : 0, 'attempts': 0,
'created' : True, 'created': True,
'history' : [{'answer' : str(last_response)}], 'history': [{'answer': last_response}],
}) })
self.current_task = child_task_module(self.system, self.location, self.current_task = child_task_module(self.system, self.location,
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
...@@ -265,7 +295,6 @@ class CombinedOpenEndedModule(XModule): ...@@ -265,7 +295,6 @@ class CombinedOpenEndedModule(XModule):
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
instance_state=current_task_state) instance_state=current_task_state)
log.debug(current_task_state)
return True return True
def check_allow_reset(self): def check_allow_reset(self):
...@@ -304,7 +333,8 @@ class CombinedOpenEndedModule(XModule): ...@@ -304,7 +333,8 @@ class CombinedOpenEndedModule(XModule):
'task_count': len(self.task_xml), 'task_count': len(self.task_xml),
'task_number': self.current_task_number + 1, 'task_number': self.current_task_number + 1,
'status': self.get_status(), 'status': self.get_status(),
'display_name': self.display_name 'display_name': self.display_name,
'accept_file_upload': self.accept_file_upload,
} }
return context return context
...@@ -392,6 +422,15 @@ class CombinedOpenEndedModule(XModule): ...@@ -392,6 +422,15 @@ class CombinedOpenEndedModule(XModule):
last_correctness = task.is_last_response_correct() last_correctness = task.is_last_response_correct()
max_score = task.max_score() max_score = task.max_score()
state = task.state 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 = { last_response_dict = {
'response': last_response, 'response': last_response,
'score': last_score, 'score': last_score,
...@@ -399,7 +438,8 @@ class CombinedOpenEndedModule(XModule): ...@@ -399,7 +438,8 @@ class CombinedOpenEndedModule(XModule):
'type': task_type, 'type': task_type,
'max_score': max_score, 'max_score': max_score,
'state': state, 'state': state,
'human_state': task.HUMAN_NAMES[state], 'human_state': human_state,
'human_task': human_task_name,
'correct': last_correctness, 'correct': last_correctness,
'min_score_to_attempt': min_score_to_attempt, 'min_score_to_attempt': min_score_to_attempt,
'max_score_to_attempt': max_score_to_attempt, 'max_score_to_attempt': max_score_to_attempt,
...@@ -547,6 +587,63 @@ class CombinedOpenEndedModule(XModule): ...@@ -547,6 +587,63 @@ class CombinedOpenEndedModule(XModule):
return status_html 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): class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
""" """
...@@ -603,4 +700,4 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor): ...@@ -603,4 +700,4 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
for child in ['task']: for child in ['task']:
add_child(child) add_child(child)
return elt return elt
\ No newline at end of file
...@@ -4,7 +4,8 @@ from lxml import etree ...@@ -4,7 +4,8 @@ from lxml import etree
log=logging.getLogger(__name__) log=logging.getLogger(__name__)
class RubricParsingError(Exception): class RubricParsingError(Exception):
pass def __init__(self, msg):
self.msg = msg
class CombinedOpenEndedRubric(object): class CombinedOpenEndedRubric(object):
...@@ -23,15 +24,32 @@ class CombinedOpenEndedRubric(object): ...@@ -23,15 +24,32 @@ class CombinedOpenEndedRubric(object):
Output: Output:
html: the html that corresponds to the xml given html: the html that corresponds to the xml given
''' '''
success = False
try: try:
rubric_categories = self.extract_categories(rubric_xml) rubric_categories = self.extract_categories(rubric_xml)
html = self.system.render_template('open_ended_rubric.html', html = self.system.render_template('open_ended_rubric.html',
{'categories' : rubric_categories, {'categories' : rubric_categories,
'has_score': self.has_score, 'has_score': self.has_score,
'view_only': self.view_only}) 'view_only': self.view_only})
success = True
except: except:
raise RubricParsingError("[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml)) 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): def extract_categories(self, element):
''' '''
......
...@@ -442,6 +442,14 @@ section.open-ended-child { ...@@ -442,6 +442,14 @@ section.open-ended-child {
margin: 10px; margin: 10px;
} }
span.short-form-response {
padding: 9px;
background: #F6F6F6;
border: 1px solid #ddd;
border-top: 0;
margin-bottom: 20px;
@include clearfix;
}
.grader-status { .grader-status {
padding: 9px; padding: 9px;
......
...@@ -45,7 +45,6 @@ describe 'CombinedOpenEnded', -> ...@@ -45,7 +45,6 @@ describe 'CombinedOpenEnded', ->
@combined.poll() @combined.poll()
expect(window.queuePollerID).toBeUndefined() expect(window.queuePollerID).toBeUndefined()
expect(window.setTimeout).not.toHaveBeenCalled() expect(window.setTimeout).not.toHaveBeenCalled()
expect(@combined.reload).toHaveBeenCalled()
describe 'rebind', -> describe 'rebind', ->
beforeEach -> beforeEach ->
......
...@@ -140,15 +140,15 @@ class @Problem ...@@ -140,15 +140,15 @@ class @Problem
allowed_files = $(element).data("allowed_files") allowed_files = $(element).data("allowed_files")
for file in element.files for file in element.files
if allowed_files.length != 0 and file.name not in allowed_files if allowed_files.length != 0 and file.name not in allowed_files
unallowed_file_submitted = true unallowed_file_submitted = true
errors.push "You submitted #{file.name}; only #{allowed_files} are allowed." errors.push "You submitted #{file.name}; only #{allowed_files} are allowed."
if file.name in required_files 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 if file.size > max_filesize
file_too_large = true file_too_large = true
errors.push 'Your file "' + file.name '" is too large (max size: ' + max_filesize/(1000*1000) + ' MB)' errors.push 'Your file "' + file.name '" is too large (max size: ' + max_filesize/(1000*1000) + ' MB)'
fd.append(element.id, file) fd.append(element.id, file)
if element.files.length == 0 if element.files.length == 0
file_not_selected = true file_not_selected = true
fd.append(element.id, '') # In case we want to allow submissions with no file fd.append(element.id, '') # In case we want to allow submissions with no file
if required_files.length != 0 if required_files.length != 0
...@@ -157,7 +157,7 @@ class @Problem ...@@ -157,7 +157,7 @@ class @Problem
else else
fd.append(element.id, element.value) fd.append(element.id, element.value)
if file_not_selected if file_not_selected
errors.push 'You did not select any files to submit' errors.push 'You did not select any files to submit'
......
...@@ -7,10 +7,12 @@ class @CombinedOpenEnded ...@@ -7,10 +7,12 @@ class @CombinedOpenEnded
@wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule') @wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule')
@el = $(element).find('section.combined-open-ended') @el = $(element).find('section.combined-open-ended')
@combined_open_ended=$(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') @ajax_url = @el.data('ajax-url')
@state = @el.data('state') @state = @el.data('state')
@task_count = @el.data('task-count') @task_count = @el.data('task-count')
@task_number = @el.data('task-number') @task_number = @el.data('task-number')
@accept_file_upload = @el.data('accept-file-upload')
@allow_reset = @el.data('allow_reset') @allow_reset = @el.data('allow_reset')
@reset_button = @$('.reset-button') @reset_button = @$('.reset-button')
...@@ -43,6 +45,8 @@ class @CombinedOpenEnded ...@@ -43,6 +45,8 @@ class @CombinedOpenEnded
@skip_button = @$('.skip-button') @skip_button = @$('.skip-button')
@skip_button.click @skip_post_assessment @skip_button.click @skip_post_assessment
@file_upload_area = @$('.file-upload')
@can_upload_files = false
@open_ended_child= @$('.open-ended-child') @open_ended_child= @$('.open-ended-child')
@find_assessment_elements() @find_assessment_elements()
...@@ -54,6 +58,16 @@ class @CombinedOpenEnded ...@@ -54,6 +58,16 @@ class @CombinedOpenEnded
$: (selector) -> $: (selector) ->
$(selector, @el) $(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) => show_results: (event) =>
status_item = $(event.target).parent().parent() status_item = $(event.target).parent().parent()
status_number = status_item.data('status-number') status_number = status_item.data('status-number')
...@@ -66,7 +80,7 @@ class @CombinedOpenEnded ...@@ -66,7 +80,7 @@ class @CombinedOpenEnded
@submit_evaluation_button.click @message_post @submit_evaluation_button.click @message_post
Collapsible.setCollapsibles(@results_container) Collapsible.setCollapsibles(@results_container)
else else
@errors_area.html(response.error) @gentle_alert response.error
message_post: (event)=> message_post: (event)=>
Logger.log 'message_post', @answers Logger.log 'message_post', @answers
...@@ -107,22 +121,28 @@ class @CombinedOpenEnded ...@@ -107,22 +121,28 @@ class @CombinedOpenEnded
@submit_button.show() @submit_button.show()
@reset_button.hide() @reset_button.hide()
@next_problem_button.hide() @next_problem_button.hide()
@hide_file_upload()
@hint_area.attr('disabled', false) @hint_area.attr('disabled', false)
if @child_state == 'done' if @child_state == 'done'
@rubric_wrapper.hide() @rubric_wrapper.hide()
if @child_type=="openended" if @child_type=="openended"
@skip_button.hide() @skip_button.hide()
if @allow_reset=="True" if @allow_reset=="True"
@show_results_current
@reset_button.show() @reset_button.show()
@submit_button.hide() @submit_button.hide()
@answer_area.attr("disabled", true) @answer_area.attr("disabled", true)
@replace_text_inputs()
@hint_area.attr('disabled', true) @hint_area.attr('disabled', true)
else if @child_state == 'initial' else if @child_state == 'initial'
@answer_area.attr("disabled", false) @answer_area.attr("disabled", false)
@submit_button.prop('value', 'Submit') @submit_button.prop('value', 'Submit')
@submit_button.click @save_answer @submit_button.click @save_answer
@setup_file_upload()
else if @child_state == 'assessing' else if @child_state == 'assessing'
@answer_area.attr("disabled", true) @answer_area.attr("disabled", true)
@replace_text_inputs()
@hide_file_upload()
@submit_button.prop('value', 'Submit assessment') @submit_button.prop('value', 'Submit assessment')
@submit_button.click @save_assessment @submit_button.click @save_assessment
if @child_type == "openended" if @child_type == "openended"
...@@ -133,6 +153,7 @@ class @CombinedOpenEnded ...@@ -133,6 +153,7 @@ class @CombinedOpenEnded
@skip_button.show() @skip_button.show()
@skip_post_assessment() @skip_post_assessment()
@answer_area.attr("disabled", true) @answer_area.attr("disabled", true)
@replace_text_inputs()
@submit_button.prop('value', 'Submit post-assessment') @submit_button.prop('value', 'Submit post-assessment')
if @child_type=="selfassessment" if @child_type=="selfassessment"
@submit_button.click @save_hint @submit_button.click @save_hint
...@@ -141,6 +162,7 @@ class @CombinedOpenEnded ...@@ -141,6 +162,7 @@ class @CombinedOpenEnded
else if @child_state == 'done' else if @child_state == 'done'
@rubric_wrapper.hide() @rubric_wrapper.hide()
@answer_area.attr("disabled", true) @answer_area.attr("disabled", true)
@replace_text_inputs()
@hint_area.attr('disabled', true) @hint_area.attr('disabled', true)
@submit_button.hide() @submit_button.hide()
if @child_type=="openended" if @child_type=="openended"
...@@ -148,6 +170,7 @@ class @CombinedOpenEnded ...@@ -148,6 +170,7 @@ class @CombinedOpenEnded
if @task_number<@task_count if @task_number<@task_count
@next_problem() @next_problem()
else else
@show_results_current()
@reset_button.show() @reset_button.show()
...@@ -159,17 +182,41 @@ class @CombinedOpenEnded ...@@ -159,17 +182,41 @@ class @CombinedOpenEnded
save_answer: (event) => save_answer: (event) =>
event.preventDefault() event.preventDefault()
max_filesize = 2*1000*1000 #2MB
if @child_state == 'initial' if @child_state == 'initial'
data = {'student_answer' : @answer_area.val()} files = ""
$.postWithPrefix "#{@ajax_url}/save_answer", data, (response) => if @can_upload_files == true
if response.success files = $('.file-upload-box')[0].files[0]
@rubric_wrapper.html(response.rubric_html) if files != undefined
@rubric_wrapper.show() if files.size > max_filesize
@child_state = 'assessing' @can_upload_files = false
@find_assessment_elements() files = ""
@rebind()
else 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 else
@errors_area.html('Problem state got out of sync. Try reloading the page.') @errors_area.html('Problem state got out of sync. Try reloading the page.')
...@@ -259,6 +306,7 @@ class @CombinedOpenEnded ...@@ -259,6 +306,7 @@ class @CombinedOpenEnded
@gentle_alert "Moved to next step." @gentle_alert "Moved to next step."
else else
@gentle_alert "Your score did not meet the criteria to move to the next step." @gentle_alert "Your score did not meet the criteria to move to the next step."
@show_results_current()
else else
@errors_area.html(response.error) @errors_area.html(response.error)
else else
...@@ -281,10 +329,31 @@ class @CombinedOpenEnded ...@@ -281,10 +329,31 @@ class @CombinedOpenEnded
$.postWithPrefix "#{@ajax_url}/check_for_score", (response) => $.postWithPrefix "#{@ajax_url}/check_for_score", (response) =>
if response.state == "done" or response.state=="post_assessment" if response.state == "done" or response.state=="post_assessment"
delete window.queuePollerID delete window.queuePollerID
@reload() @reload
else else
window.queuePollerID = window.setTimeout(@poll, 10000) 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: -> reload: ->
location.reload() location.reload()
"""
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): ...@@ -378,12 +378,11 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
Return error message or feedback template Return error message or feedback template
""" """
log.debug(response_items) rubric_feedback = ""
rubric_feedback=""
feedback = self._convert_longform_feedback_to_html(response_items) 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_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']: if not response_items['success']:
return system.render_template("open_ended_error.html", return system.render_template("open_ended_error.html",
...@@ -393,7 +392,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -393,7 +392,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
'grader_type': response_items['grader_type'], 'grader_type': response_items['grader_type'],
'score': "{0} / {1}".format(response_items['score'], self.max_score()), 'score': "{0} / {1}".format(response_items['score'], self.max_score()),
'feedback': feedback, 'feedback': feedback,
'rubric_feedback' : rubric_feedback 'rubric_feedback': rubric_feedback
}) })
return feedback_template return feedback_template
...@@ -444,7 +443,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -444,7 +443,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
log.error(error_message) log.error(error_message)
fail['feedback'] = error_message fail['feedback'] = error_message
return fail return fail
#This is to support peer grading #This is to support peer grading
if isinstance(score_result['score'], list): if isinstance(score_result['score'], list):
feedback_items = [] feedback_items = []
for i in xrange(0, len(score_result['score'])): for i in xrange(0, len(score_result['score'])):
...@@ -455,8 +454,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -455,8 +454,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
'success': score_result['success'], 'success': score_result['success'],
'grader_id': score_result['grader_id'][i], 'grader_id': score_result['grader_id'][i],
'submission_id': score_result['submission_id'], 'submission_id': score_result['submission_id'],
'rubric_scores_complete' : score_result['rubric_scores_complete'][i], 'rubric_scores_complete': score_result['rubric_scores_complete'][i],
'rubric_xml' : score_result['rubric_xml'][i], 'rubric_xml': score_result['rubric_xml'][i],
} }
feedback_items.append(self._format_feedback(new_score_result, system)) feedback_items.append(self._format_feedback(new_score_result, system))
if join_feedback: if join_feedback:
...@@ -483,7 +482,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -483,7 +482,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
if not self.history: if not self.history:
return "" 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: if not short_feedback:
return feedback_dict['feedback'] if feedback_dict['valid'] else '' return feedback_dict['feedback'] if feedback_dict['valid'] else ''
if feedback_dict['valid']: if feedback_dict['valid']:
...@@ -561,11 +561,21 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -561,11 +561,21 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
return self.out_of_sync_error(get) return self.out_of_sync_error(get)
# add new history element with answer and empty score and hint. # add new history element with answer and empty score and hint.
self.new_history_entry(get['student_answer']) success, get = self.append_image_to_student_answer(get)
self.send_to_grader(get['student_answer'], system) error_message = ""
self.change_state(self.ASSESSING) 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): def update_score(self, get, system):
""" """
...@@ -609,8 +619,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -609,8 +619,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
'msg': post_assessment, 'msg': post_assessment,
'child_type': 'openended', 'child_type': 'openended',
'correct': correct, 'correct': correct,
'accept_file_upload': self.accept_file_upload,
} }
log.debug(context)
html = system.render_template('open_ended.html', context) html = system.render_template('open_ended.html', context)
return html return html
......
...@@ -5,11 +5,13 @@ import json ...@@ -5,11 +5,13 @@ import json
import logging import logging
from lxml import etree from lxml import etree
from lxml.html import rewrite_links from lxml.html import rewrite_links
from lxml.html.clean import Cleaner, autolink_html
from path import path from path import path
import os import os
import sys import sys
import hashlib import hashlib
import capa.xqueue_interface as xqueue_interface import capa.xqueue_interface as xqueue_interface
import re
from pkg_resources import resource_string from pkg_resources import resource_string
...@@ -21,6 +23,7 @@ from .stringify import stringify_children ...@@ -21,6 +23,7 @@ from .stringify import stringify_children
from .xml_module import XmlDescriptor from .xml_module import XmlDescriptor
from xmodule.modulestore import Location from xmodule.modulestore import Location
from capa.util import * from capa.util import *
import open_ended_image_submission
from datetime import datetime from datetime import datetime
...@@ -94,6 +97,7 @@ class OpenEndedChild(object): ...@@ -94,6 +97,7 @@ class OpenEndedChild(object):
self.prompt = static_data['prompt'] self.prompt = static_data['prompt']
self.rubric = static_data['rubric'] self.rubric = static_data['rubric']
self.display_name = static_data['display_name'] 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 # Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect). # completion (doesn't matter if you self-assessed correct/incorrect).
...@@ -130,12 +134,26 @@ class OpenEndedChild(object): ...@@ -130,12 +134,26 @@ class OpenEndedChild(object):
return "" return ""
return self.history[-1].get('post_assessment', "") 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): def new_history_entry(self, answer):
""" """
Adds a new entry to the history dictionary Adds a new entry to the history dictionary
@param answer: The student supplied answer @param answer: The student supplied answer
@return: None @return: None
""" """
answer = OpenEndedChild.sanitize_html(answer)
self.history.append({'answer': answer}) self.history.append({'answer': answer})
def record_latest_score(self, score): def record_latest_score(self, score):
...@@ -260,5 +278,115 @@ class OpenEndedChild(object): ...@@ -260,5 +278,115 @@ class OpenEndedChild(object):
correctness = 'correct' if self.is_submission_correct(score) else 'incorrect' correctness = 'correct' if self.is_submission_correct(score) else 'incorrect'
return correctness 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): ...@@ -80,6 +80,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
'state': self.state, 'state': self.state,
'allow_reset': self._allow_reset(), 'allow_reset': self._allow_reset(),
'child_type': 'selfassessment', 'child_type': 'selfassessment',
'accept_file_upload': self.accept_file_upload,
} }
html = system.render_template('self_assessment_prompt.html', context) html = system.render_template('self_assessment_prompt.html', context)
...@@ -106,6 +107,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -106,6 +107,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
if dispatch not in handlers: if dispatch not in handlers:
return 'Error' return 'Error'
log.debug(get)
before = self.get_progress() before = self.get_progress()
d = handlers[dispatch](get, system) d = handlers[dispatch](get, system)
after = self.get_progress() after = self.get_progress()
...@@ -123,7 +125,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -123,7 +125,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
return '' return ''
rubric_renderer = CombinedOpenEndedRubric(system, True) 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 # we'll render it
context = {'rubric': rubric_html, context = {'rubric': rubric_html,
...@@ -200,13 +202,21 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -200,13 +202,21 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
if self.state != self.INITIAL: if self.state != self.INITIAL:
return self.out_of_sync_error(get) return self.out_of_sync_error(get)
error_message = ""
# add new history element with answer and empty score and hint. # add new history element with answer and empty score and hint.
self.new_history_entry(get['student_answer']) success, get = self.append_image_to_student_answer(get)
self.change_state(self.ASSESSING) 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 { return {
'success': True, 'success': success,
'rubric_html': self.get_rubric_html(system) 'rubric_html': self.get_rubric_html(system),
'error': error_message,
'student_response': get['student_answer'],
} }
def save_assessment(self, get, system): def save_assessment(self, get, system):
......
...@@ -39,7 +39,8 @@ class OpenEndedChildTest(unittest.TestCase): ...@@ -39,7 +39,8 @@ class OpenEndedChildTest(unittest.TestCase):
'prompt': prompt, 'prompt': prompt,
'rubric': rubric, 'rubric': rubric,
'max_score': max_score, 'max_score': max_score,
'display_name': 'Name' 'display_name': 'Name',
'accept_file_upload' : False,
} }
definition = Mock() definition = Mock()
descriptor = Mock() descriptor = Mock()
...@@ -152,7 +153,8 @@ class OpenEndedModuleTest(unittest.TestCase): ...@@ -152,7 +153,8 @@ class OpenEndedModuleTest(unittest.TestCase):
'prompt': prompt, 'prompt': prompt,
'rubric': rubric, 'rubric': rubric,
'max_score': max_score, 'max_score': max_score,
'display_name': 'Name' 'display_name': 'Name',
'accept_file_upload': False,
} }
oeparam = etree.XML(''' oeparam = etree.XML('''
...@@ -270,7 +272,7 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): ...@@ -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> <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> </category>
</rubric></rubric>''' </rubric></rubric>'''
max_score = 4 max_score = 3
metadata = {'attempts': '10', 'max_score': max_score} metadata = {'attempts': '10', 'max_score': max_score}
......
...@@ -43,7 +43,8 @@ class SelfAssessmentTest(unittest.TestCase): ...@@ -43,7 +43,8 @@ class SelfAssessmentTest(unittest.TestCase):
'rubric': etree.XML(self.rubric), 'rubric': etree.XML(self.rubric),
'prompt': self.prompt, 'prompt': self.prompt,
'max_score': 1, 'max_score': 1,
'display_name': "Name" 'display_name': "Name",
'accept_file_upload' : False,
} }
self.module = SelfAssessmentModule(test_system, self.location, self.module = SelfAssessmentModule(test_system, self.location,
......
...@@ -18,8 +18,10 @@ from django.core.urlresolvers import reverse ...@@ -18,8 +18,10 @@ from django.core.urlresolvers import reverse
from fs.errors import ResourceNotFoundError 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 module_render import get_module
from courseware.access import has_access from courseware.access import has_access
from static_replace import replace_urls from static_replace import replace_urls
...@@ -27,13 +29,10 @@ from xmodule.modulestore import Location ...@@ -27,13 +29,10 @@ from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.xml import XMLModuleStore from xmodule.modulestore.xml import XMLModuleStore
from xmodule.x_module import XModule 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 student.models import unique_id_for_user
from open_ended_grading import open_ended_notifications
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class InvalidTabsException(Exception): class InvalidTabsException(Exception):
...@@ -118,49 +117,45 @@ def _textbooks(tab, user, course, active_page): ...@@ -118,49 +117,45 @@ def _textbooks(tab, user, course, active_page):
def _staff_grading(tab, user, course, active_page): def _staff_grading(tab, user, course, active_page):
if has_access(user, course, 'staff'): if has_access(user, course, 'staff'):
link = reverse('staff_grading', args=[course.id]) link = reverse('staff_grading', args=[course.id])
staff_gs = StaffGradingService(settings.STAFF_GRADING_INTERFACE)
pending_grading=False
tab_name = "Staff grading" tab_name = "Staff grading"
img_path= ""
try: notifications = open_ended_notifications.staff_grading_notifications(course, user)
notifications = json.loads(staff_gs.get_notifications(course.id)) pending_grading = notifications['pending_grading']
if notifications['success']: img_path = notifications['img_path']
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 = [CourseTab(tab_name, link, active_page == "staff_grading", pending_grading, img_path)] tab = [CourseTab(tab_name, link, active_page == "staff_grading", pending_grading, img_path)]
return tab return tab
return [] return []
def _peer_grading(tab, user, course, active_page): def _peer_grading(tab, user, course, active_page):
if user.is_authenticated(): if user.is_authenticated():
link = reverse('peer_grading', args=[course.id]) link = reverse('peer_grading', args=[course.id])
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE)
pending_grading=False
tab_name = "Peer grading" tab_name = "Peer grading"
img_path= ""
try: notifications = open_ended_notifications.peer_grading_notifications(course, user)
notifications = json.loads(peer_gs.get_notifications(course.id,unique_id_for_user(user))) pending_grading = notifications['pending_grading']
if notifications['success']: img_path = notifications['img_path']
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"
tab = [CourseTab(tab_name, link, active_page == "peer_grading", pending_grading, img_path)] tab = [CourseTab(tab_name, link, active_page == "peer_grading", pending_grading, img_path)]
return tab return tab
return [] 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 #### Validators
...@@ -198,6 +193,7 @@ VALID_TAB_TYPES = { ...@@ -198,6 +193,7 @@ VALID_TAB_TYPES = {
'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab), 'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab),
'peer_grading': TabImpl(null_validator, _peer_grading), 'peer_grading': TabImpl(null_validator, _peer_grading),
'staff_grading': TabImpl(null_validator, _staff_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): ...@@ -326,4 +322,4 @@ def get_static_tab_contents(request, cache, course, tab):
if tab_module is not None: if tab_module is not None:
html = tab_module.get_html() html = tab_module.get_html()
return html return html
\ No newline at end of file
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): ...@@ -116,7 +116,7 @@ class GradingService(object):
if 'rubric' in response_json: if 'rubric' in response_json:
rubric = response_json['rubric'] rubric = response_json['rubric']
rubric_renderer = CombinedOpenEndedRubric(self.system, False) rubric_renderer = CombinedOpenEndedRubric(self.system, False)
rubric_html = rubric_renderer.render_rubric(rubric) success, rubric_html = rubric_renderer.render_rubric(rubric)
response_json['rubric'] = rubric_html response_json['rubric'] = rubric_html
return response_json return response_json
# if we can't parse the rubric into HTML, # if we can't parse the rubric into HTML,
......
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)
\ No newline at end of file
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 ...@@ -13,10 +13,17 @@ from courseware.courses import get_course_with_access
from peer_grading_service import PeerGradingService from peer_grading_service import PeerGradingService
from peer_grading_service import MockPeerGradingService from peer_grading_service import MockPeerGradingService
from controller_query_service import ControllerQueryService
from grading_service import GradingServiceError from grading_service import GradingServiceError
import json import json
from .staff_grading import StaffGrading 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__) log = logging.getLogger(__name__)
...@@ -26,18 +33,34 @@ if settings.MOCK_PEER_GRADING: ...@@ -26,18 +33,34 @@ if settings.MOCK_PEER_GRADING:
else: else:
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE) 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 Reverses the URL from the name and the course id, and then adds a trailing slash if
it does not exist yet it does not exist yet
""" """
def _reverse_with_slash(url_name, course_id): 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('/'): if not ajax_url.endswith('/'):
ajax_url += '/' ajax_url += '/'
return 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) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
def staff_grading(request, course_id): def staff_grading(request, course_id):
""" """
...@@ -114,5 +137,111 @@ def peer_grading_problem(request, course_id): ...@@ -114,5 +137,111 @@ def peer_grading_problem(request, course_id):
'ajax_url': ajax_url, 'ajax_url': ajax_url,
# Checked above # Checked above
'staff_access': False, }) '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
)
...@@ -44,6 +44,8 @@ ...@@ -44,6 +44,8 @@
@import "course/gradebook"; @import "course/gradebook";
@import "course/tabs"; @import "course/tabs";
@import "course/staff_grading"; @import "course/staff_grading";
@import "course/open_ended_grading";
// instructor // instructor
@import "course/instructor/instructor"; @import "course/instructor/instructor";
......
.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;
}
}
<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> <h2>${display_name}</h2>
<div class="status-container"> <div class="status-container">
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
<div class="statusitem" data-status-number="${i}"> <div class="statusitem" data-status-number="${i}">
%endif %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': % if status['state'] == 'initial':
<span class="unanswered" id="status"></span> <span class="unanswered" id="status"></span>
% elif status['state'] in ['done', 'post_assessment'] and status['correct'] == 'correct': % elif status['state'] in ['done', 'post_assessment'] and status['correct'] == 'correct':
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
<div class="prompt"> <div class="prompt">
${prompt|n} ${prompt|n}
</div> </div>
<h4>Answer</h4>
<textarea rows="${rows}" cols="${cols}" name="answer" class="answer short-form-response" id="input_${id}">${previous_answer|h}</textarea> <textarea rows="${rows}" cols="${cols}" name="answer" class="answer short-form-response" id="input_${id}">${previous_answer|h}</textarea>
<div class="message-wrapper"></div> <div class="message-wrapper"></div>
...@@ -22,6 +23,8 @@ ...@@ -22,6 +23,8 @@
% endif % endif
</div> </div>
<div class="file-upload"></div>
<input type="button" value="Submit" class="submit-button" name="show"/> <input type="button" value="Submit" class="submit-button" name="show"/>
<input name="skip" class="skip-button" type="button" value="Skip Post-Assessment"/> <input name="skip" class="skip-button" type="button" value="Skip Post-Assessment"/>
......
<%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>
<%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 @@ ...@@ -5,8 +5,9 @@
${prompt} ${prompt}
</div> </div>
<h4>Answer</h4>
<div> <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>
<div class="open-ended-action"></div> <div class="open-ended-action"></div>
...@@ -16,6 +17,7 @@ ...@@ -16,6 +17,7 @@
<div class="hint-wrapper">${initial_hint}</div> <div class="hint-wrapper">${initial_hint}</div>
<div class="message-wrapper">${initial_message}</div> <div class="message-wrapper">${initial_message}</div>
<div class="file-upload"></div>
<input type="button" value="Submit" class="submit-button" name="show"/> <input type="button" value="Submit" class="submit-button" name="show"/>
</section> </section>
...@@ -285,7 +285,11 @@ if settings.COURSEWARE_ENABLED: ...@@ -285,7 +285,11 @@ if settings.COURSEWARE_ENABLED:
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/save_calibration_essay$', 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'), '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$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts$',
'course_groups.views.list_cohorts', name="cohorts"), 'course_groups.views.list_cohorts', name="cohorts"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts/add$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts/add$',
...@@ -304,6 +308,9 @@ if settings.COURSEWARE_ENABLED: ...@@ -304,6 +308,9 @@ if settings.COURSEWARE_ENABLED:
'course_groups.views.debug_cohort_mgmt', 'course_groups.views.debug_cohort_mgmt',
name="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 # discussion forums live within courseware, so courseware must be enabled first
......
...@@ -48,7 +48,6 @@ sorl-thumbnail==11.12 ...@@ -48,7 +48,6 @@ sorl-thumbnail==11.12
networkx==1.7 networkx==1.7
pygraphviz==1.1 pygraphviz==1.1
-r repo-requirements.txt -r repo-requirements.txt
pil==1.1.7
nltk==2.0.4 nltk==2.0.4
django-debug-toolbar-mongo django-debug-toolbar-mongo
dogstatsd-python==0.2.1 dogstatsd-python==0.2.1
...@@ -59,3 +58,4 @@ Shapely==1.2.16 ...@@ -59,3 +58,4 @@ Shapely==1.2.16
ipython==0.13.1 ipython==0.13.1
xmltodict==0.4.1 xmltodict==0.4.1
paramiko==1.9.0 paramiko==1.9.0
Pillow==1.7.8
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment