Merge remote-tracking branch 'origin/master' into feature/cale/cms-master
Conflicts: lms/static/sass/course/courseware/_courseware.scss
This commit is contained in:
@@ -29,6 +29,9 @@ from django_future.csrf import ensure_csrf_cookie, csrf_exempt
|
||||
from student.models import (Registration, UserProfile,
|
||||
PendingNameChange, PendingEmailChange,
|
||||
CourseEnrollment)
|
||||
|
||||
from certificates.models import CertificateStatuses, certificate_status_for_student
|
||||
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -143,11 +146,20 @@ def dashboard(request):
|
||||
show_courseware_links_for = frozenset(course.id for course in courses
|
||||
if has_access(request.user, course, 'load'))
|
||||
|
||||
# TODO: workaround to not have to zip courses and certificates in the template
|
||||
# since before there is a migration to certificates
|
||||
if settings.MITX_FEATURES.get('CERTIFICATES_ENABLED'):
|
||||
cert_statuses = { course.id: certificate_status_for_student(request.user, course.id) for course in courses}
|
||||
else:
|
||||
cert_statuses = {}
|
||||
|
||||
context = {'courses': courses,
|
||||
'message': message,
|
||||
'staff_access': staff_access,
|
||||
'errored_courses': errored_courses,
|
||||
'show_courseware_links_for' : show_courseware_links_for}
|
||||
'show_courseware_links_for' : show_courseware_links_for,
|
||||
'cert_statuses': cert_statuses,
|
||||
}
|
||||
|
||||
return render_to_response('dashboard.html', context)
|
||||
|
||||
@@ -206,13 +218,13 @@ def change_enrollment(request):
|
||||
return {'success': False,
|
||||
'error': 'enrollment in {} not allowed at this time'
|
||||
.format(course.display_name)}
|
||||
|
||||
org, course_num, run=course_id.split("/")
|
||||
|
||||
org, course_num, run=course_id.split("/")
|
||||
statsd.increment("common.student.enrollment",
|
||||
tags=["org:{0}".format(org),
|
||||
"course:{0}".format(course_num),
|
||||
"run:{0}".format(run)])
|
||||
|
||||
|
||||
enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id)
|
||||
return {'success': True}
|
||||
|
||||
@@ -220,13 +232,13 @@ def change_enrollment(request):
|
||||
try:
|
||||
enrollment = CourseEnrollment.objects.get(user=user, course_id=course_id)
|
||||
enrollment.delete()
|
||||
|
||||
org, course_num, run=course_id.split("/")
|
||||
|
||||
org, course_num, run=course_id.split("/")
|
||||
statsd.increment("common.student.unenrollment",
|
||||
tags=["org:{0}".format(org),
|
||||
"course:{0}".format(course_num),
|
||||
"run:{0}".format(run)])
|
||||
|
||||
|
||||
return {'success': True}
|
||||
except CourseEnrollment.DoesNotExist:
|
||||
return {'success': False, 'error': 'You are not enrolled for this course.'}
|
||||
@@ -275,13 +287,13 @@ def login_user(request, error=""):
|
||||
log.info("Login success - {0} ({1})".format(username, email))
|
||||
|
||||
try_change_enrollment(request)
|
||||
|
||||
|
||||
statsd.increment("common.student.successful_login")
|
||||
|
||||
|
||||
return HttpResponse(json.dumps({'success': True}))
|
||||
|
||||
|
||||
log.warning("Login failed - Account not active for user {0}, resending activation".format(username))
|
||||
|
||||
|
||||
reactivation_email_for_user(user)
|
||||
not_activated_msg = "This account has not been activated. We have " + \
|
||||
"sent another activation message. Please check your " + \
|
||||
@@ -483,9 +495,9 @@ def create_account(request, post_override=None):
|
||||
log.debug('bypassing activation email')
|
||||
login_user.is_active = True
|
||||
login_user.save()
|
||||
|
||||
|
||||
statsd.increment("common.student.account_created")
|
||||
|
||||
|
||||
js = {'success': True}
|
||||
return HttpResponse(json.dumps(js), mimetype="application/json")
|
||||
|
||||
@@ -541,9 +553,9 @@ def password_reset(request):
|
||||
''' Attempts to send a password reset e-mail. '''
|
||||
if request.method != "POST":
|
||||
raise Http404
|
||||
|
||||
|
||||
# By default, Django doesn't allow Users with is_active = False to reset their passwords,
|
||||
# but this bites people who signed up a long time ago, never activated, and forgot their
|
||||
# but this bites people who signed up a long time ago, never activated, and forgot their
|
||||
# password. So for their sake, we'll auto-activate a user for whome password_reset is called.
|
||||
try:
|
||||
user = User.objects.get(email=request.POST['email'])
|
||||
@@ -551,7 +563,7 @@ def password_reset(request):
|
||||
user.save()
|
||||
except:
|
||||
log.exception("Tried to auto-activate user to enable password reset, but failed.")
|
||||
|
||||
|
||||
form = PasswordResetForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save(use_https = request.is_secure(),
|
||||
@@ -589,7 +601,7 @@ def reactivation_email_for_user(user):
|
||||
res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
|
||||
|
||||
return HttpResponse(json.dumps({'success': True}))
|
||||
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def change_email_request(request):
|
||||
@@ -764,8 +776,8 @@ def accept_name_change_by_id(id):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def accept_name_change(request):
|
||||
''' JSON: Name change process. Course staff clicks 'accept' on a given name change
|
||||
|
||||
''' JSON: Name change process. Course staff clicks 'accept' on a given name change
|
||||
|
||||
We used this during the prototype but now we simply record name changes instead
|
||||
of manually approving them. Still keeping this around in case we want to go
|
||||
back to this approval method.
|
||||
@@ -794,6 +806,3 @@ def test_center_login(request):
|
||||
return redirect('/courses/MITx/6.002x/2012_Fall/courseware/Final_Exam/Final_Exam_Fall_2012/')
|
||||
else:
|
||||
return HttpResponseForbidden()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ setup(
|
||||
"problem = xmodule.capa_module:CapaDescriptor",
|
||||
"problemset = xmodule.seq_module:SequenceDescriptor",
|
||||
"section = xmodule.backcompat_module:SemanticSectionDescriptor",
|
||||
"selfassessment = xmodule.self_assessment_module:SelfAssessmentDescriptor",
|
||||
"sequential = xmodule.seq_module:SequenceDescriptor",
|
||||
"slides = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"vertical = xmodule.vertical_module:VerticalDescriptor",
|
||||
|
||||
@@ -29,15 +29,17 @@ TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?)
|
||||
def only_one(lst, default="", process=lambda x: x):
|
||||
"""
|
||||
If lst is empty, returns default
|
||||
If lst has a single element, applies process to that element and returns it
|
||||
Otherwise, raises an exeception
|
||||
|
||||
If lst has a single element, applies process to that element and returns it.
|
||||
|
||||
Otherwise, raises an exception.
|
||||
"""
|
||||
if len(lst) == 0:
|
||||
return default
|
||||
elif len(lst) == 1:
|
||||
return process(lst[0])
|
||||
else:
|
||||
raise Exception('Malformed XML')
|
||||
raise Exception('Malformed XML: expected at most one element in list.')
|
||||
|
||||
|
||||
def parse_timedelta(time_str):
|
||||
@@ -291,11 +293,11 @@ class CapaModule(XModule):
|
||||
# check button is context-specific.
|
||||
|
||||
# Put a "Check" button if unlimited attempts or still some left
|
||||
if self.max_attempts is None or self.attempts < self.max_attempts-1:
|
||||
if self.max_attempts is None or self.attempts < self.max_attempts-1:
|
||||
check_button = "Check"
|
||||
else:
|
||||
# Will be final check so let user know that
|
||||
check_button = "Final Check"
|
||||
check_button = "Final Check"
|
||||
|
||||
reset_button = True
|
||||
save_button = True
|
||||
@@ -518,11 +520,11 @@ class CapaModule(XModule):
|
||||
# Problem queued. Students must wait a specified waittime before they are allowed to submit
|
||||
if self.lcp.is_queued():
|
||||
current_time = datetime.datetime.now()
|
||||
prev_submit_time = self.lcp.get_recentmost_queuetime()
|
||||
prev_submit_time = self.lcp.get_recentmost_queuetime()
|
||||
waittime_between_requests = self.system.xqueue['waittime']
|
||||
if (current_time-prev_submit_time).total_seconds() < waittime_between_requests:
|
||||
msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests
|
||||
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
|
||||
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
|
||||
|
||||
try:
|
||||
old_state = self.lcp.get_state()
|
||||
@@ -663,10 +665,10 @@ class CapaDescriptor(RawDescriptor):
|
||||
'problems/' + path[8:],
|
||||
path[8:],
|
||||
]
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(CapaDescriptor, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
weight_string = self.metadata.get('weight', None)
|
||||
if weight_string:
|
||||
self.weight = float(weight_string)
|
||||
|
||||
@@ -243,7 +243,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
Returns True if the current time is after the specified course end date.
|
||||
Returns False if there is no end date specified.
|
||||
"""
|
||||
if self.end_date is None:
|
||||
if self.end is None:
|
||||
return False
|
||||
|
||||
return time.gmtime() > self.end
|
||||
@@ -364,6 +364,10 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
displayed_start = self._try_parse_time('advertised_start') or self.start
|
||||
return time.strftime("%b %d, %Y", displayed_start)
|
||||
|
||||
@property
|
||||
def end_date_text(self):
|
||||
return time.strftime("%b %d, %Y", self.end)
|
||||
|
||||
# An extra property is used rather than the wiki_slug/number because
|
||||
# there are courses that change the number for different runs. This allows
|
||||
# courses to share the same css_class across runs even if they have
|
||||
@@ -412,6 +416,16 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
# Explicit comparison to True because we always want to return a bool.
|
||||
return self.metadata.get('hide_progress_tab') == True
|
||||
|
||||
@property
|
||||
def end_of_course_survey_url(self):
|
||||
"""
|
||||
Pull from policy. Once we have our own survey module set up, can change this to point to an automatically
|
||||
created survey for each class.
|
||||
|
||||
Returns None if no url specified.
|
||||
"""
|
||||
return self.metadata.get('end_of_course_survey_url')
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self.display_name
|
||||
|
||||
133
common/lib/xmodule/xmodule/js/src/selfassessment/display.coffee
Normal file
133
common/lib/xmodule/xmodule/js/src/selfassessment/display.coffee
Normal file
@@ -0,0 +1,133 @@
|
||||
class @SelfAssessment
|
||||
constructor: (element) ->
|
||||
@el = $(element).find('section.self-assessment')
|
||||
@id = @el.data('id')
|
||||
@ajax_url = @el.data('ajax-url')
|
||||
@state = @el.data('state')
|
||||
@allow_reset = @el.data('allow_reset')
|
||||
# valid states: 'initial', 'assessing', 'request_hint', 'done'
|
||||
|
||||
# Where to put the rubric once we load it
|
||||
@errors_area = @$('.error')
|
||||
@answer_area = @$('textarea.answer')
|
||||
|
||||
@rubric_wrapper = @$('.rubric-wrapper')
|
||||
@hint_wrapper = @$('.hint-wrapper')
|
||||
@message_wrapper = @$('.message-wrapper')
|
||||
@submit_button = @$('.submit-button')
|
||||
@reset_button = @$('.reset-button')
|
||||
@reset_button.click @reset
|
||||
|
||||
@find_assessment_elements()
|
||||
@find_hint_elements()
|
||||
|
||||
@rebind()
|
||||
|
||||
# locally scoped jquery.
|
||||
$: (selector) ->
|
||||
$(selector, @el)
|
||||
|
||||
rebind: () =>
|
||||
# rebind to the appropriate function for the current state
|
||||
@submit_button.unbind('click')
|
||||
@submit_button.show()
|
||||
@reset_button.hide()
|
||||
@hint_area.attr('disabled', false)
|
||||
if @state == 'initial'
|
||||
@answer_area.attr("disabled", false)
|
||||
@submit_button.prop('value', 'Submit')
|
||||
@submit_button.click @save_answer
|
||||
else if @state == 'assessing'
|
||||
@answer_area.attr("disabled", true)
|
||||
@submit_button.prop('value', 'Submit assessment')
|
||||
@submit_button.click @save_assessment
|
||||
else if @state == 'request_hint'
|
||||
@answer_area.attr("disabled", true)
|
||||
@submit_button.prop('value', 'Submit hint')
|
||||
@submit_button.click @save_hint
|
||||
else if @state == 'done'
|
||||
@answer_area.attr("disabled", true)
|
||||
@hint_area.attr('disabled', true)
|
||||
@submit_button.hide()
|
||||
if @allow_reset
|
||||
@reset_button.show()
|
||||
else
|
||||
@reset_button.hide()
|
||||
|
||||
|
||||
find_assessment_elements: ->
|
||||
@assessment = @$('select.assessment')
|
||||
|
||||
find_hint_elements: ->
|
||||
@hint_area = @$('textarea.hint')
|
||||
|
||||
save_answer: (event) =>
|
||||
event.preventDefault()
|
||||
if @state == 'initial'
|
||||
data = {'student_answer' : @answer_area.val()}
|
||||
$.postWithPrefix "#{@ajax_url}/save_answer", data, (response) =>
|
||||
if response.success
|
||||
@rubric_wrapper.html(response.rubric_html)
|
||||
@state = 'assessing'
|
||||
@find_assessment_elements()
|
||||
@rebind()
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
else
|
||||
@errors_area.html('Problem state got out of sync. Try reloading the page.')
|
||||
|
||||
save_assessment: (event) =>
|
||||
event.preventDefault()
|
||||
if @state == 'assessing'
|
||||
data = {'assessment' : @assessment.find(':selected').text()}
|
||||
$.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) =>
|
||||
if response.success
|
||||
@state = response.state
|
||||
|
||||
if @state == 'request_hint'
|
||||
@hint_wrapper.html(response.hint_html)
|
||||
@find_hint_elements()
|
||||
else if @state == 'done'
|
||||
@message_wrapper.html(response.message_html)
|
||||
@allow_reset = response.allow_reset
|
||||
|
||||
@rebind()
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
else
|
||||
@errors_area.html('Problem state got out of sync. Try reloading the page.')
|
||||
|
||||
|
||||
save_hint: (event) =>
|
||||
event.preventDefault()
|
||||
if @state == 'request_hint'
|
||||
data = {'hint' : @hint_area.val()}
|
||||
|
||||
$.postWithPrefix "#{@ajax_url}/save_hint", data, (response) =>
|
||||
if response.success
|
||||
@message_wrapper.html(response.message_html)
|
||||
@state = 'done'
|
||||
@allow_reset = response.allow_reset
|
||||
@rebind()
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
else
|
||||
@errors_area.html('Problem state got out of sync. Try reloading the page.')
|
||||
|
||||
|
||||
reset: (event) =>
|
||||
event.preventDefault()
|
||||
if @state == 'done'
|
||||
$.postWithPrefix "#{@ajax_url}/reset", {}, (response) =>
|
||||
if response.success
|
||||
@answer_area.html('')
|
||||
@rubric_wrapper.html('')
|
||||
@hint_wrapper.html('')
|
||||
@message_wrapper.html('')
|
||||
@state = 'initial'
|
||||
@rebind()
|
||||
@reset_button.hide()
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
else
|
||||
@errors_area.html('Problem state got out of sync. Try reloading the page.')
|
||||
476
common/lib/xmodule/xmodule/self_assessment_module.py
Normal file
476
common/lib/xmodule/xmodule/self_assessment_module.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
A Self Assessment module that allows students to write open-ended responses,
|
||||
submit, then see a rubric and rate themselves. Persists student supplied
|
||||
hints, answers, and assessment judgment (currently only correct/incorrect).
|
||||
Parses xml definition file--see below for exact format.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from fs.errors import ResourceNotFoundError
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from lxml import etree
|
||||
from lxml.html import rewrite_links
|
||||
from path import path
|
||||
import json
|
||||
from progress import Progress
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from .capa_module import only_one, ComplexEncoder
|
||||
from .editing_module import EditingDescriptor
|
||||
from .html_checker import check_html
|
||||
from .stringify import stringify_children
|
||||
from .x_module import XModule
|
||||
from .xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
# Set the default number of max attempts. Should be 1 for production
|
||||
# Set higher for debugging/testing
|
||||
# attempts specified in xml definition overrides this.
|
||||
MAX_ATTEMPTS = 1
|
||||
|
||||
# Set maximum available number of points.
|
||||
# Overriden by max_score specified in xml.
|
||||
MAX_SCORE = 1
|
||||
|
||||
class SelfAssessmentModule(XModule):
|
||||
"""
|
||||
States:
|
||||
|
||||
initial (prompt, textbox shown)
|
||||
|
|
||||
assessing (read-only textbox, rubric + assessment input shown)
|
||||
|
|
||||
request_hint (read-only textbox, read-only rubric and assessment, hint input box shown)
|
||||
|
|
||||
done (submitted msg, green checkmark, everything else read-only. If attempts < max, shows
|
||||
a reset button that goes back to initial state. Saves previous
|
||||
submissions too.)
|
||||
"""
|
||||
|
||||
# states
|
||||
INITIAL = 'initial'
|
||||
ASSESSING = 'assessing'
|
||||
REQUEST_HINT = 'request_hint'
|
||||
DONE = 'done'
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/selfassessment/display.coffee')]}
|
||||
js_module_name = "SelfAssessment"
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
|
||||
"""
|
||||
Definition file should have 4 blocks -- prompt, rubric, submitmessage, hintprompt,
|
||||
and two optional attributes:
|
||||
attempts, which should be an integer that defaults to 1.
|
||||
If it's > 1, the student will be able to re-submit after they see
|
||||
the rubric.
|
||||
max_score, which should be an integer that defaults to 1.
|
||||
It defines the maximum number of points a student can get. Assumed to be integer scale
|
||||
from 0 to max_score, with an interval of 1.
|
||||
|
||||
Note: all the submissions are stored.
|
||||
|
||||
Sample file:
|
||||
|
||||
<selfassessment attempts="1" max_score="1">
|
||||
<prompt>
|
||||
Insert prompt text here. (arbitrary html)
|
||||
</prompt>
|
||||
<rubric>
|
||||
Insert grading rubric here. (arbitrary html)
|
||||
</rubric>
|
||||
<hintprompt>
|
||||
Please enter a hint below: (arbitrary html)
|
||||
</hintprompt>
|
||||
<submitmessage>
|
||||
Thanks for submitting! (arbitrary html)
|
||||
</submitmessage>
|
||||
</selfassessment>
|
||||
"""
|
||||
|
||||
# Load instance state
|
||||
if instance_state is not None:
|
||||
instance_state = json.loads(instance_state)
|
||||
else:
|
||||
instance_state = {}
|
||||
|
||||
# Note: score responses are on scale from 0 to max_score
|
||||
self.student_answers = instance_state.get('student_answers', [])
|
||||
self.scores = instance_state.get('scores', [])
|
||||
self.hints = instance_state.get('hints', [])
|
||||
|
||||
self.state = instance_state.get('state', 'initial')
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
|
||||
self._max_score = int(self.metadata.get('max_score', MAX_SCORE))
|
||||
|
||||
self.attempts = instance_state.get('attempts', 0)
|
||||
|
||||
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
|
||||
|
||||
self.rubric = definition['rubric']
|
||||
self.prompt = definition['prompt']
|
||||
self.submit_message = definition['submitmessage']
|
||||
self.hint_prompt = definition['hintprompt']
|
||||
|
||||
def _allow_reset(self):
|
||||
"""Can the module be reset?"""
|
||||
return self.state == self.DONE and self.attempts < self.max_attempts
|
||||
|
||||
def get_html(self):
|
||||
#set context variables and render template
|
||||
if self.state != self.INITIAL and self.student_answers:
|
||||
previous_answer = self.student_answers[-1]
|
||||
else:
|
||||
previous_answer = ''
|
||||
|
||||
context = {
|
||||
'prompt': self.prompt,
|
||||
'previous_answer': previous_answer,
|
||||
'ajax_url': self.system.ajax_url,
|
||||
'initial_rubric': self.get_rubric_html(),
|
||||
'initial_hint': self.get_hint_html(),
|
||||
'initial_message': self.get_message_html(),
|
||||
'state': self.state,
|
||||
'allow_reset': self._allow_reset(),
|
||||
}
|
||||
html = self.system.render_template('self_assessment_prompt.html', context)
|
||||
|
||||
# cdodge: perform link substitutions for any references to course static content (e.g. images)
|
||||
return rewrite_links(html, self.rewrite_content_links)
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
Returns dict with 'score' key
|
||||
"""
|
||||
return {'score': self.get_last_score()}
|
||||
|
||||
def max_score(self):
|
||||
"""
|
||||
Return max_score
|
||||
"""
|
||||
return self._max_score
|
||||
|
||||
def get_last_score(self):
|
||||
"""
|
||||
Returns the last score in the list
|
||||
"""
|
||||
last_score=0
|
||||
if(len(self.scores)>0):
|
||||
last_score=self.scores[len(self.scores)-1]
|
||||
return last_score
|
||||
|
||||
def get_progress(self):
|
||||
'''
|
||||
For now, just return last score / max_score
|
||||
'''
|
||||
if self._max_score > 0:
|
||||
try:
|
||||
return Progress(self.get_last_score(), self._max_score)
|
||||
except Exception as err:
|
||||
log.exception("Got bad progress")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
"""
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
"get" is request.POST.
|
||||
|
||||
Returns a json dictionary:
|
||||
{ 'progress_changed' : True/False,
|
||||
'progress': 'none'/'in_progress'/'done',
|
||||
<other request-specific values here > }
|
||||
"""
|
||||
|
||||
handlers = {
|
||||
'save_answer': self.save_answer,
|
||||
'save_assessment': self.save_assessment,
|
||||
'save_hint': self.save_hint,
|
||||
'reset': self.reset,
|
||||
}
|
||||
|
||||
if dispatch not in handlers:
|
||||
return 'Error'
|
||||
|
||||
before = self.get_progress()
|
||||
d = handlers[dispatch](get)
|
||||
after = self.get_progress()
|
||||
d.update({
|
||||
'progress_changed': after != before,
|
||||
'progress_status': Progress.to_js_status_str(after),
|
||||
})
|
||||
return json.dumps(d, cls=ComplexEncoder)
|
||||
|
||||
def out_of_sync_error(self, get, msg=''):
|
||||
"""
|
||||
return dict out-of-sync error message, and also log.
|
||||
"""
|
||||
log.warning("Assessment module state out sync. state: %r, get: %r. %s",
|
||||
self.state, get, msg)
|
||||
return {'success': False,
|
||||
'error': 'The problem state got out-of-sync'}
|
||||
|
||||
def get_rubric_html(self):
|
||||
"""
|
||||
Return the appropriate version of the rubric, based on the state.
|
||||
"""
|
||||
if self.state == self.INITIAL:
|
||||
return ''
|
||||
|
||||
# we'll render it
|
||||
context = {'rubric': self.rubric,
|
||||
'max_score' : self._max_score,
|
||||
}
|
||||
|
||||
if self.state == self.ASSESSING:
|
||||
context['read_only'] = False
|
||||
elif self.state in (self.REQUEST_HINT, self.DONE):
|
||||
context['read_only'] = True
|
||||
else:
|
||||
raise ValueError("Illegal state '%r'" % self.state)
|
||||
|
||||
return self.system.render_template('self_assessment_rubric.html', context)
|
||||
|
||||
def get_hint_html(self):
|
||||
"""
|
||||
Return the appropriate version of the hint view, based on state.
|
||||
"""
|
||||
if self.state in (self.INITIAL, self.ASSESSING):
|
||||
return ''
|
||||
|
||||
if self.state == self.DONE and len(self.hints) > 0:
|
||||
# display the previous hint
|
||||
hint = self.hints[-1]
|
||||
else:
|
||||
hint = ''
|
||||
|
||||
context = {'hint_prompt': self.hint_prompt,
|
||||
'hint': hint}
|
||||
|
||||
if self.state == self.REQUEST_HINT:
|
||||
context['read_only'] = False
|
||||
elif self.state == self.DONE:
|
||||
context['read_only'] = True
|
||||
else:
|
||||
raise ValueError("Illegal state '%r'" % self.state)
|
||||
|
||||
return self.system.render_template('self_assessment_hint.html', context)
|
||||
|
||||
def get_message_html(self):
|
||||
"""
|
||||
Return the appropriate version of the message view, based on state.
|
||||
"""
|
||||
if self.state != self.DONE:
|
||||
return ""
|
||||
|
||||
return """<div class="save_message">{0}</div>""".format(self.submit_message)
|
||||
|
||||
|
||||
def save_answer(self, get):
|
||||
"""
|
||||
After the answer is submitted, show the rubric.
|
||||
"""
|
||||
# Check to see if attempts are less than max
|
||||
if self.attempts > self.max_attempts:
|
||||
# If too many attempts, prevent student from saving answer and
|
||||
# seeing rubric. In normal use, students shouldn't see this because
|
||||
# they won't see the reset button once they're out of attempts.
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
|
||||
if self.state != self.INITIAL:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
self.student_answers.append(get['student_answer'])
|
||||
self.state = self.ASSESSING
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'rubric_html': self.get_rubric_html()
|
||||
}
|
||||
|
||||
def save_assessment(self, get):
|
||||
"""
|
||||
Save the assessment. If the student said they're right, don't ask for a
|
||||
hint, and go straight to the done state. Otherwise, do ask for a hint.
|
||||
|
||||
Returns a dict { 'success': bool, 'state': state,
|
||||
|
||||
'hint_html': hint_html OR 'message_html': html and 'allow_reset',
|
||||
|
||||
'error': error-msg},
|
||||
|
||||
with 'error' only present if 'success' is False, and 'hint_html' or
|
||||
'message_html' only if success is true
|
||||
"""
|
||||
|
||||
n_answers = len(self.student_answers)
|
||||
n_scores = len(self.scores)
|
||||
if (self.state != self.ASSESSING or n_answers != n_scores + 1):
|
||||
msg = "%d answers, %d scores" % (n_answers, n_scores)
|
||||
return self.out_of_sync_error(get, msg)
|
||||
|
||||
try:
|
||||
score = int(get['assessment'])
|
||||
except:
|
||||
return {'success': False, 'error': "Non-integer score value"}
|
||||
|
||||
self.scores.append(score)
|
||||
|
||||
d = {'success': True,}
|
||||
|
||||
if score == self.max_score():
|
||||
self.state = self.DONE
|
||||
d['message_html'] = self.get_message_html()
|
||||
d['allow_reset'] = self._allow_reset()
|
||||
else:
|
||||
self.state = self.REQUEST_HINT
|
||||
d['hint_html'] = self.get_hint_html()
|
||||
|
||||
d['state'] = self.state
|
||||
return d
|
||||
|
||||
|
||||
def save_hint(self, get):
|
||||
'''
|
||||
Save the hint.
|
||||
Returns a dict { 'success': bool,
|
||||
'message_html': message_html,
|
||||
'error': error-msg,
|
||||
'allow_reset': bool},
|
||||
with the error key only present if success is False and message_html
|
||||
only if True.
|
||||
'''
|
||||
if self.state != self.REQUEST_HINT:
|
||||
# Note: because we only ask for hints on wrong answers, may not have
|
||||
# the same number of hints and answers.
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
self.hints.append(get['hint'].lower())
|
||||
self.state = self.DONE
|
||||
|
||||
# increment attempts
|
||||
self.attempts = self.attempts + 1
|
||||
|
||||
# To the tracking logs!
|
||||
event_info = {
|
||||
'selfassessment_id': self.location.url(),
|
||||
'state': {
|
||||
'student_answers': self.student_answers,
|
||||
'score': self.scores,
|
||||
'hints': self.hints,
|
||||
}
|
||||
}
|
||||
self.system.track_function('save_hint', event_info)
|
||||
|
||||
return {'success': True,
|
||||
'message_html': self.get_message_html(),
|
||||
'allow_reset': self._allow_reset()}
|
||||
|
||||
|
||||
def reset(self, get):
|
||||
"""
|
||||
If resetting is allowed, reset the state.
|
||||
|
||||
Returns {'success': bool, 'error': msg}
|
||||
(error only present if not success)
|
||||
"""
|
||||
if self.state != self.DONE:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
if self.attempts > self.max_attempts:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
self.state = self.INITIAL
|
||||
return {'success': True}
|
||||
|
||||
|
||||
def get_instance_state(self):
|
||||
"""
|
||||
Get the current score and state
|
||||
"""
|
||||
|
||||
state = {
|
||||
'student_answers': self.student_answers,
|
||||
'hints': self.hints,
|
||||
'state': self.state,
|
||||
'scores': self.scores,
|
||||
'max_score': self._max_score,
|
||||
'attempts': self.attempts
|
||||
}
|
||||
return json.dumps(state)
|
||||
|
||||
|
||||
class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
Module for adding self assessment questions to courses
|
||||
"""
|
||||
mako_template = "widgets/html-edit.html"
|
||||
module_class = SelfAssessmentModule
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = "selfassessment"
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
|
||||
js_module_name = "HTMLEditingDescriptor"
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
Pull out the rubric, prompt, and submitmessage into a dictionary.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'rubric': 'some-html',
|
||||
'prompt': 'some-html',
|
||||
'submitmessage': 'some-html'
|
||||
'hintprompt': 'some-html'
|
||||
}
|
||||
"""
|
||||
expected_children = ['rubric', 'prompt', 'submitmessage', 'hintprompt']
|
||||
for child in expected_children:
|
||||
if len(xml_object.xpath(child)) != 1:
|
||||
raise ValueError("Self assessment definition must include exactly one '{0}' tag".format(child))
|
||||
|
||||
def parse(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return stringify_children(xml_object.xpath(k)[0])
|
||||
|
||||
return {'rubric': parse('rubric'),
|
||||
'prompt': parse('prompt'),
|
||||
'submitmessage': parse('submitmessage'),
|
||||
'hintprompt': parse('hintprompt'),
|
||||
}
|
||||
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
'''Return an xml element representing this definition.'''
|
||||
elt = etree.Element('selfassessment')
|
||||
|
||||
def add_child(k):
|
||||
child_str = '<{tag}>{body}</{tag}>'.format(tag=k, body=self.definition[k])
|
||||
child_node = etree.fromstring(child_str)
|
||||
elt.append(child_node)
|
||||
|
||||
for child in ['rubric', 'prompt', 'submitmessage', 'hintprompt']:
|
||||
add_child(child)
|
||||
|
||||
return elt
|
||||
@@ -113,3 +113,7 @@ class RoundTripTestCase(unittest.TestCase):
|
||||
|
||||
def test_full_roundtrip(self):
|
||||
self.check_export_roundtrip(DATA_DIR, "full")
|
||||
|
||||
def test_selfassessment_roundtrip(self):
|
||||
#Test selfassessment xmodule to see if it exports correctly
|
||||
self.check_export_roundtrip(DATA_DIR,"self_assessment")
|
||||
|
||||
@@ -339,4 +339,16 @@ class ImportTestCase(unittest.TestCase):
|
||||
|
||||
self.assertRaises(etree.XMLSyntaxError, system.process_xml, bad_xml)
|
||||
|
||||
def test_selfassessment_import(self):
|
||||
'''
|
||||
Check to see if definition_from_xml in self_assessment_module.py
|
||||
works properly. Pulls data from the self_assessment directory in the test data directory.
|
||||
'''
|
||||
|
||||
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['self_assessment'])
|
||||
|
||||
sa_id = "edX/sa_test/2012_Fall"
|
||||
location = Location(["i4x", "edX", "sa_test", "selfassessment", "SampleQuestion"])
|
||||
sa_sample = modulestore.get_instance(sa_id, location)
|
||||
#10 attempts is hard coded into SampleQuestion, which is the url_name of a selfassessment xml tag
|
||||
self.assertEqual(sa_sample.metadata['attempts'], '10')
|
||||
|
||||
1
common/test/data/self_assessment/README.md
Normal file
1
common/test/data/self_assessment/README.md
Normal file
@@ -0,0 +1 @@
|
||||
This is a very very simple course, useful for debugging self assessment code.
|
||||
1
common/test/data/self_assessment/course.xml
Symbolic link
1
common/test/data/self_assessment/course.xml
Symbolic link
@@ -0,0 +1 @@
|
||||
roots/2012_Fall.xml
|
||||
5
common/test/data/self_assessment/course/2012_Fall.xml
Normal file
5
common/test/data/self_assessment/course/2012_Fall.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<course>
|
||||
<chapter url_name="Overview">
|
||||
<selfassessment url_name="SampleQuestion"/>
|
||||
</chapter>
|
||||
</course>
|
||||
14
common/test/data/self_assessment/policies/2012_Fall.json
Normal file
14
common/test/data/self_assessment/policies/2012_Fall.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"course/2012_Fall": {
|
||||
"graceperiod": "2 days 5 hours 59 minutes 59 seconds",
|
||||
"start": "2015-07-17T12:00",
|
||||
"display_name": "Self Assessment Test",
|
||||
"graded": "true"
|
||||
},
|
||||
"chapter/Overview": {
|
||||
"display_name": "Overview"
|
||||
},
|
||||
"selfassessment/SampleQuestion": {
|
||||
"display_name": "Sample Question",
|
||||
},
|
||||
}
|
||||
1
common/test/data/self_assessment/roots/2012_Fall.xml
Normal file
1
common/test/data/self_assessment/roots/2012_Fall.xml
Normal file
@@ -0,0 +1 @@
|
||||
<course org="edX" course="sa_test" url_name="2012_Fall"/>
|
||||
@@ -0,0 +1,14 @@
|
||||
<selfassessment attempts='10'>
|
||||
<prompt>
|
||||
What is the meaning of life?
|
||||
</prompt>
|
||||
<rubric>
|
||||
This is a rubric.
|
||||
</rubric>
|
||||
<submitmessage>
|
||||
Thanks for your submission!
|
||||
</submitmessage>
|
||||
<hintprompt>
|
||||
Enter a hint below:
|
||||
</hintprompt>
|
||||
</selfassessment>
|
||||
Reference in New Issue
Block a user