Inspired by: http://eldarion.com/blog/2013/02/14/entry-point-hook-django-projects/ Moves startup code to lms.startup and cms.startup, and calls the startup methods in wsgi.py and manage.py for both projects.
642 lines
24 KiB
Python
642 lines
24 KiB
Python
import json
|
|
import logging
|
|
|
|
from lxml import etree
|
|
|
|
from datetime import datetime
|
|
from pkg_resources import resource_string
|
|
from .capa_module import ComplexEncoder
|
|
from .x_module import XModule
|
|
from xmodule.raw_module import RawDescriptor
|
|
from xmodule.modulestore.exceptions import ItemNotFoundError
|
|
from .timeinfo import TimeInfo
|
|
from xblock.core import Dict, String, Scope, Boolean, Float
|
|
from xmodule.fields import Date, Timedelta
|
|
|
|
from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService
|
|
from open_ended_grading_classes import combined_open_ended_rubric
|
|
from django.utils.timezone import UTC
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please notify course staff."
|
|
|
|
|
|
class PeerGradingFields(object):
|
|
use_for_single_location = Boolean(
|
|
display_name="Show Single Problem",
|
|
help='When True, only the single problem specified by "Link to Problem Location" is shown. '
|
|
'When False, a panel is displayed with all problems available for peer grading.',
|
|
default=False,
|
|
scope=Scope.settings
|
|
)
|
|
link_to_location = String(
|
|
display_name="Link to Problem Location",
|
|
help='The location of the problem being graded. Only used when "Show Single Problem" is True.',
|
|
default="",
|
|
scope=Scope.settings
|
|
)
|
|
graded = Boolean(
|
|
display_name="Graded",
|
|
help='Defines whether the student gets credit for grading this problem. Only used when "Show Single Problem" is True.',
|
|
default=False,
|
|
scope=Scope.settings
|
|
)
|
|
due = Date(
|
|
help="Due date that should be displayed.",
|
|
scope=Scope.settings)
|
|
graceperiod = Timedelta(
|
|
help="Amount of grace to give on the due date.",
|
|
scope=Scope.settings
|
|
)
|
|
student_data_for_location = Dict(
|
|
help="Student data for a given peer grading problem.",
|
|
scope=Scope.user_state
|
|
)
|
|
weight = Float(
|
|
display_name="Problem Weight",
|
|
help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.",
|
|
scope=Scope.settings, values={"min": 0, "step": ".1"},
|
|
default=1
|
|
)
|
|
display_name = String(
|
|
display_name="Display Name",
|
|
help="Display name for this module",
|
|
scope=Scope.settings,
|
|
default="Peer Grading Interface"
|
|
)
|
|
data = String(
|
|
help="Html contents to display for this module",
|
|
default='<peergrading></peergrading>',
|
|
scope=Scope.content
|
|
)
|
|
|
|
|
|
class PeerGradingModule(PeerGradingFields, XModule):
|
|
"""
|
|
PeerGradingModule.__init__ takes the same arguments as xmodule.x_module:XModule.__init__
|
|
"""
|
|
_VERSION = 1
|
|
|
|
js = {
|
|
'coffee': [
|
|
resource_string(__name__, 'js/src/peergrading/peer_grading.coffee'),
|
|
resource_string(__name__, 'js/src/peergrading/peer_grading_problem.coffee'),
|
|
resource_string(__name__, 'js/src/collapsible.coffee'),
|
|
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
|
]
|
|
}
|
|
js_module_name = "PeerGrading"
|
|
|
|
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(PeerGradingModule, self).__init__(*args, **kwargs)
|
|
|
|
#We need to set the location here so the child modules can use it
|
|
self.runtime.set('location', self.location)
|
|
if (self.system.open_ended_grading_interface):
|
|
self.peer_gs = PeerGradingService(self.system.open_ended_grading_interface, self.system)
|
|
else:
|
|
self.peer_gs = MockPeerGradingService()
|
|
|
|
if self.use_for_single_location:
|
|
try:
|
|
self.linked_problem = self.system.get_module(self.link_to_location)
|
|
except ItemNotFoundError:
|
|
log.error("Linked location {0} for peer grading module {1} does not exist".format(
|
|
self.link_to_location, self.location))
|
|
raise
|
|
due_date = self.linked_problem.lms.due
|
|
if due_date:
|
|
self.lms.due = due_date
|
|
|
|
try:
|
|
self.timeinfo = TimeInfo(self.due, self.graceperiod)
|
|
except Exception:
|
|
log.error("Error parsing due date information in location {0}".format(self.location))
|
|
raise
|
|
|
|
self.display_due_date = self.timeinfo.display_due_date
|
|
|
|
try:
|
|
self.student_data_for_location = json.loads(self.student_data_for_location)
|
|
except Exception:
|
|
pass
|
|
|
|
self.ajax_url = self.system.ajax_url
|
|
if not self.ajax_url.endswith("/"):
|
|
self.ajax_url = self.ajax_url + "/"
|
|
|
|
def closed(self):
|
|
return self._closed(self.timeinfo)
|
|
|
|
def _closed(self, timeinfo):
|
|
if timeinfo.close_date is not None and datetime.now(UTC()) > timeinfo.close_date:
|
|
return True
|
|
return False
|
|
|
|
def _err_response(self, msg):
|
|
"""
|
|
Return a HttpResponse with a json dump with success=False, and the given error message.
|
|
"""
|
|
return {'success': False, 'error': msg}
|
|
|
|
def _check_required(self, data, required):
|
|
actual = set(data.keys())
|
|
missing = required - actual
|
|
if len(missing) > 0:
|
|
return False, "Missing required keys: {0}".format(', '.join(missing))
|
|
else:
|
|
return True, ""
|
|
|
|
def get_html(self):
|
|
"""
|
|
Needs to be implemented by inheritors. Renders the HTML that students see.
|
|
@return:
|
|
"""
|
|
if self.closed():
|
|
return self.peer_grading_closed()
|
|
if not self.use_for_single_location:
|
|
return self.peer_grading()
|
|
else:
|
|
return self.peer_grading_problem({'location': self.link_to_location})['html']
|
|
|
|
def handle_ajax(self, dispatch, data):
|
|
"""
|
|
Needs to be implemented by child modules. Handles AJAX events.
|
|
@return:
|
|
"""
|
|
handlers = {
|
|
'get_next_submission': self.get_next_submission,
|
|
'show_calibration_essay': self.show_calibration_essay,
|
|
'is_student_calibrated': self.is_student_calibrated,
|
|
'save_grade': self.save_grade,
|
|
'save_calibration_essay': self.save_calibration_essay,
|
|
'problem': self.peer_grading_problem,
|
|
}
|
|
|
|
if dispatch not in handlers:
|
|
# This is a dev_facing_error
|
|
log.error("Cannot find {0} in handlers in handle_ajax function for open_ended_module.py".format(dispatch))
|
|
# This is a dev_facing_error
|
|
return json.dumps({'error': 'Error handling action. Please try again.', 'success': False})
|
|
|
|
d = handlers[dispatch](data)
|
|
|
|
return json.dumps(d, cls=ComplexEncoder)
|
|
|
|
def query_data_for_location(self, location):
|
|
student_id = self.system.anonymous_student_id
|
|
success = False
|
|
response = {}
|
|
|
|
try:
|
|
response = self.peer_gs.get_data_for_location(location, student_id)
|
|
count_graded = response['count_graded']
|
|
count_required = response['count_required']
|
|
success = True
|
|
except GradingServiceError:
|
|
# This is a dev_facing_error
|
|
log.exception("Error getting location data from controller for location {0}, student {1}"
|
|
.format(location, student_id))
|
|
|
|
return success, response
|
|
|
|
def get_progress(self):
|
|
pass
|
|
|
|
def get_score(self):
|
|
max_score = None
|
|
score = None
|
|
weight = self.weight
|
|
|
|
#The old default was None, so set to 1 if it is the old default weight
|
|
if weight is None:
|
|
weight = 1
|
|
score_dict = {
|
|
'score': score,
|
|
'total': max_score,
|
|
}
|
|
if not self.use_for_single_location or not self.graded:
|
|
return score_dict
|
|
|
|
try:
|
|
count_graded = self.student_data_for_location['count_graded']
|
|
count_required = self.student_data_for_location['count_required']
|
|
except:
|
|
success, response = self.query_data_for_location(self.location)
|
|
if not success:
|
|
log.exception(
|
|
"No instance data found and could not get data from controller for loc {0} student {1}".format(
|
|
self.system.location.url(), self.system.anonymous_student_id
|
|
))
|
|
return None
|
|
count_graded = response['count_graded']
|
|
count_required = response['count_required']
|
|
if count_required > 0 and count_graded >= count_required:
|
|
# Ensures that once a student receives a final score for peer grading, that it does not change.
|
|
self.student_data_for_location = response
|
|
|
|
score = int(count_graded >= count_required and count_graded > 0) * float(weight)
|
|
total = float(weight)
|
|
score_dict['score'] = score
|
|
score_dict['total'] = total
|
|
|
|
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_grade = None
|
|
if self.use_for_single_location and self.graded:
|
|
max_grade = self.weight
|
|
return max_grade
|
|
|
|
def get_next_submission(self, data):
|
|
"""
|
|
Makes a call to the grading controller for the next essay that should be graded
|
|
Returns a json dict with the following keys:
|
|
|
|
'success': bool
|
|
|
|
'submission_id': a unique identifier for the submission, to be passed back
|
|
with the grade.
|
|
|
|
'submission': the submission, rendered as read-only html for grading
|
|
|
|
'rubric': the rubric, also rendered as html.
|
|
|
|
'submission_key': a key associated with the submission for validation reasons
|
|
|
|
'error': if success is False, will have an error message with more info.
|
|
"""
|
|
required = set(['location'])
|
|
success, message = self._check_required(data, required)
|
|
if not success:
|
|
return self._err_response(message)
|
|
grader_id = self.system.anonymous_student_id
|
|
location = data['location']
|
|
|
|
try:
|
|
response = self.peer_gs.get_next_submission(location, grader_id)
|
|
return response
|
|
except GradingServiceError:
|
|
# This is a dev_facing_error
|
|
log.exception("Error getting next submission. server url: {0} location: {1}, grader_id: {2}"
|
|
.format(self.peer_gs.url, location, grader_id))
|
|
# This is a student_facing_error
|
|
return {'success': False,
|
|
'error': EXTERNAL_GRADER_NO_CONTACT_ERROR}
|
|
|
|
def save_grade(self, data):
|
|
"""
|
|
Saves the grade of a given submission.
|
|
Input:
|
|
The request should have the following keys:
|
|
location - problem location
|
|
submission_id - id associated with this submission
|
|
submission_key - submission key given for validation purposes
|
|
score - the grade that was given to the submission
|
|
feedback - the feedback from the student
|
|
Returns
|
|
A json object with the following keys:
|
|
success: bool indicating whether the save was a success
|
|
error: if there was an error in the submission, this is the error message
|
|
"""
|
|
|
|
required = ['location', 'submission_id', 'submission_key', 'score', 'feedback', 'submission_flagged', 'answer_unknown']
|
|
if data.get("submission_flagged", False) in ["false", False, "False", "FALSE"]:
|
|
required.append("rubric_scores[]")
|
|
success, message = self._check_required(data, set(required))
|
|
if not success:
|
|
return self._err_response(message)
|
|
|
|
data_dict = {k:data.get(k) for k in required}
|
|
if 'rubric_scores[]' in required:
|
|
data_dict['rubric_scores'] = data.getlist('rubric_scores[]')
|
|
data_dict['grader_id'] = self.system.anonymous_student_id
|
|
|
|
try:
|
|
response = self.peer_gs.save_grade(**data_dict)
|
|
success, location_data = self.query_data_for_location(data_dict['location'])
|
|
#Don't check for success above because the response = statement will raise the same Exception as the one
|
|
#that will cause success to be false.
|
|
response.update({'required_done' : False})
|
|
if 'count_graded' in location_data and 'count_required' in location_data and int(location_data['count_graded'])>=int(location_data['count_required']):
|
|
response['required_done'] = True
|
|
return response
|
|
except GradingServiceError:
|
|
# This is a dev_facing_error
|
|
log.exception("""Error saving grade to open ended grading service. server url: {0}"""
|
|
.format(self.peer_gs.url)
|
|
)
|
|
# This is a student_facing_error
|
|
return {
|
|
'success': False,
|
|
'error': EXTERNAL_GRADER_NO_CONTACT_ERROR
|
|
}
|
|
|
|
def is_student_calibrated(self, data):
|
|
"""
|
|
Calls the grading controller to see if the given student is calibrated
|
|
on the given problem
|
|
|
|
Input:
|
|
In the request, we need the following arguments:
|
|
location - problem location
|
|
|
|
Returns:
|
|
Json object with the following keys
|
|
success - bool indicating whether or not the call was successful
|
|
calibrated - true if the grader has fully calibrated and can now move on to grading
|
|
- false if the grader is still working on calibration problems
|
|
total_calibrated_on_so_far - the number of calibration essays for this problem
|
|
that this grader has graded
|
|
"""
|
|
|
|
required = set(['location'])
|
|
success, message = self._check_required(data, required)
|
|
if not success:
|
|
return self._err_response(message)
|
|
grader_id = self.system.anonymous_student_id
|
|
|
|
location = data['location']
|
|
|
|
try:
|
|
response = self.peer_gs.is_student_calibrated(location, grader_id)
|
|
return response
|
|
except GradingServiceError:
|
|
# This is a dev_facing_error
|
|
log.exception("Error from open ended grading service. server url: {0}, grader_id: {0}, location: {1}"
|
|
.format(self.peer_gs.url, grader_id, location))
|
|
# This is a student_facing_error
|
|
return {
|
|
'success': False,
|
|
'error': EXTERNAL_GRADER_NO_CONTACT_ERROR
|
|
}
|
|
|
|
def show_calibration_essay(self, data):
|
|
"""
|
|
Fetch the next calibration essay from the grading controller and return it
|
|
Inputs:
|
|
In the request
|
|
location - problem location
|
|
|
|
Returns:
|
|
A json dict with the following keys
|
|
'success': bool
|
|
|
|
'submission_id': a unique identifier for the submission, to be passed back
|
|
with the grade.
|
|
|
|
'submission': the submission, rendered as read-only html for grading
|
|
|
|
'rubric': the rubric, also rendered as html.
|
|
|
|
'submission_key': a key associated with the submission for validation reasons
|
|
|
|
'error': if success is False, will have an error message with more info.
|
|
|
|
"""
|
|
|
|
required = set(['location'])
|
|
success, message = self._check_required(data, required)
|
|
if not success:
|
|
return self._err_response(message)
|
|
|
|
grader_id = self.system.anonymous_student_id
|
|
|
|
location = data['location']
|
|
try:
|
|
response = self.peer_gs.show_calibration_essay(location, grader_id)
|
|
return response
|
|
except GradingServiceError:
|
|
# This is a dev_facing_error
|
|
log.exception("Error from open ended grading service. server url: {0}, location: {0}"
|
|
.format(self.peer_gs.url, location))
|
|
# This is a student_facing_error
|
|
return {'success': False,
|
|
'error': EXTERNAL_GRADER_NO_CONTACT_ERROR}
|
|
# if we can't parse the rubric into HTML,
|
|
except etree.XMLSyntaxError:
|
|
# This is a dev_facing_error
|
|
log.exception("Cannot parse rubric string.")
|
|
# This is a student_facing_error
|
|
return {'success': False,
|
|
'error': 'Error displaying submission. Please notify course staff.'}
|
|
|
|
def save_calibration_essay(self, data):
|
|
"""
|
|
Saves the grader's grade of a given calibration.
|
|
Input:
|
|
The request should have the following keys:
|
|
location - problem location
|
|
submission_id - id associated with this submission
|
|
submission_key - submission key given for validation purposes
|
|
score - the grade that was given to the submission
|
|
feedback - the feedback from the student
|
|
Returns
|
|
A json object with the following keys:
|
|
success: bool indicating whether the save was a success
|
|
error: if there was an error in the submission, this is the error message
|
|
actual_score: the score that the instructor gave to this calibration essay
|
|
|
|
"""
|
|
|
|
required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]'])
|
|
success, message = self._check_required(data, required)
|
|
if not success:
|
|
return self._err_response(message)
|
|
|
|
data_dict = {k:data.get(k) for k in required}
|
|
data_dict['rubric_scores'] = data.getlist('rubric_scores[]')
|
|
data_dict['student_id'] = self.system.anonymous_student_id
|
|
data_dict['calibration_essay_id'] = data_dict['submission_id']
|
|
|
|
try:
|
|
response = self.peer_gs.save_calibration_essay(**data_dict)
|
|
if 'actual_rubric' in response:
|
|
rubric_renderer = combined_open_ended_rubric.CombinedOpenEndedRubric(self.system, True)
|
|
response['actual_rubric'] = rubric_renderer.render_rubric(response['actual_rubric'])['html']
|
|
return response
|
|
except GradingServiceError:
|
|
# This is a dev_facing_error
|
|
log.exception("Error saving calibration grade")
|
|
# This is a student_facing_error
|
|
return self._err_response('There was an error saving your score. Please notify course staff.')
|
|
|
|
def peer_grading_closed(self):
|
|
'''
|
|
Show the Peer grading closed template
|
|
'''
|
|
html = self.system.render_template('peer_grading/peer_grading_closed.html', {
|
|
'use_for_single_location': self.use_for_single_location
|
|
})
|
|
return html
|
|
|
|
def peer_grading(self, _data=None):
|
|
'''
|
|
Show a peer grading interface
|
|
'''
|
|
|
|
# call problem list service
|
|
success = False
|
|
error_text = ""
|
|
problem_list = []
|
|
try:
|
|
problem_list_json = self.peer_gs.get_problem_list(self.system.course_id, self.system.anonymous_student_id)
|
|
problem_list_dict = 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']
|
|
|
|
except GradingServiceError:
|
|
# This is a student_facing_error
|
|
error_text = EXTERNAL_GRADER_NO_CONTACT_ERROR
|
|
log.error(error_text)
|
|
success = False
|
|
# catch error if if the json loads fails
|
|
except ValueError:
|
|
# This is a student_facing_error
|
|
error_text = "Could not get list of problems to peer grade. Please notify course staff."
|
|
log.error(error_text)
|
|
success = False
|
|
except Exception:
|
|
log.exception("Could not contact peer grading service.")
|
|
success = False
|
|
|
|
|
|
def _find_corresponding_module_for_location(location):
|
|
'''
|
|
find the peer grading module that links to the given location
|
|
'''
|
|
try:
|
|
return modulestore().get_instance(self.system.course_id, location)
|
|
except Exception:
|
|
# the linked problem doesn't exist
|
|
log.error("Problem {0} does not exist in this course".format(location))
|
|
raise
|
|
|
|
good_problem_list = []
|
|
for problem in problem_list:
|
|
problem_location = problem['location']
|
|
try:
|
|
descriptor = _find_corresponding_module_for_location(problem_location)
|
|
except Exception:
|
|
continue
|
|
if descriptor:
|
|
problem['due'] = descriptor.lms.due
|
|
grace_period = descriptor.lms.graceperiod
|
|
try:
|
|
problem_timeinfo = TimeInfo(problem['due'], grace_period)
|
|
except Exception:
|
|
log.error("Malformed due date or grace period string for location {0}".format(problem_location))
|
|
raise
|
|
if self._closed(problem_timeinfo):
|
|
problem['closed'] = True
|
|
else:
|
|
problem['closed'] = False
|
|
else:
|
|
# if we can't find the due date, assume that it doesn't have one
|
|
problem['due'] = None
|
|
problem['closed'] = False
|
|
good_problem_list.append(problem)
|
|
|
|
ajax_url = self.ajax_url
|
|
html = self.system.render_template('peer_grading/peer_grading.html', {
|
|
'course_id': self.system.course_id,
|
|
'ajax_url': ajax_url,
|
|
'success': success,
|
|
'problem_list': good_problem_list,
|
|
'error_text': error_text,
|
|
# Checked above
|
|
'staff_access': False,
|
|
'use_single_location': self.use_for_single_location,
|
|
})
|
|
|
|
return html
|
|
|
|
def peer_grading_problem(self, data=None):
|
|
'''
|
|
Show individual problem interface
|
|
'''
|
|
if data is None or data.get('location') is None:
|
|
if not self.use_for_single_location:
|
|
# This is an error case, because it must be set to use a single location to be called without get parameters
|
|
# This is a dev_facing_error
|
|
log.error(
|
|
"Peer grading problem in peer_grading_module called with no get parameters, but use_for_single_location is False.")
|
|
return {'html': "", 'success': False}
|
|
problem_location = self.link_to_location
|
|
|
|
elif data.get('location') is not None:
|
|
problem_location = data.get('location')
|
|
|
|
ajax_url = self.ajax_url
|
|
html = self.system.render_template('peer_grading/peer_grading_problem.html', {
|
|
'view_html': '',
|
|
'problem_location': problem_location,
|
|
'course_id': self.system.course_id,
|
|
'ajax_url': ajax_url,
|
|
# Checked above
|
|
'staff_access': False,
|
|
'use_single_location': self.use_for_single_location,
|
|
})
|
|
|
|
return {'html': html, 'success': True}
|
|
|
|
def get_instance_state(self):
|
|
"""
|
|
Returns the current instance state. The module can be recreated from the instance state.
|
|
Input: None
|
|
Output: A dictionary containing the instance state.
|
|
"""
|
|
|
|
state = {
|
|
'student_data_for_location': self.student_data_for_location,
|
|
}
|
|
|
|
return json.dumps(state)
|
|
|
|
|
|
class PeerGradingDescriptor(PeerGradingFields, RawDescriptor):
|
|
"""
|
|
Module for adding peer grading questions
|
|
"""
|
|
mako_template = "widgets/raw-edit.html"
|
|
module_class = PeerGradingModule
|
|
filename_extension = "xml"
|
|
|
|
has_score = True
|
|
always_recalculate_grades = True
|
|
|
|
#Specify whether or not to pass in open ended interface
|
|
needs_open_ended_interface = True
|
|
|
|
metadata_translations = {
|
|
'is_graded': 'graded',
|
|
'attempts': 'max_attempts',
|
|
'due_data' : 'due'
|
|
}
|
|
|
|
@property
|
|
def non_editable_metadata_fields(self):
|
|
non_editable_fields = super(PeerGradingDescriptor, self).non_editable_metadata_fields
|
|
non_editable_fields.extend([PeerGradingFields.due, PeerGradingFields.graceperiod])
|
|
return non_editable_fields
|
|
|
|
def get_required_module_descriptors(self):
|
|
"""Returns a list of XModuleDescritpor instances upon which this module depends, but are
|
|
not children of this module"""
|
|
if self.use_for_single_location:
|
|
return [self.system.load_item(self.link_to_location)]
|
|
else:
|
|
return []
|