Commit 82cf37b2 by Victor Shnayder

Merge pull request #1085 from MITx/feature/victor/fix-self-assessment

Feature/victor/fix self assessment
parents 68158b39 4b58cb95
...@@ -120,7 +120,7 @@ class @SelfAssessment ...@@ -120,7 +120,7 @@ class @SelfAssessment
if @state == 'done' if @state == 'done'
$.postWithPrefix "#{@ajax_url}/reset", {}, (response) => $.postWithPrefix "#{@ajax_url}/reset", {}, (response) =>
if response.success if response.success
@answer_area.html('') @answer_area.val('')
@rubric_wrapper.html('') @rubric_wrapper.html('')
@hint_wrapper.html('') @hint_wrapper.html('')
@message_wrapper.html('') @message_wrapper.html('')
......
...@@ -7,20 +7,21 @@ Parses xml definition file--see below for exact format. ...@@ -7,20 +7,21 @@ Parses xml definition file--see below for exact format.
import copy import copy
from fs.errors import ResourceNotFoundError from fs.errors import ResourceNotFoundError
import itertools
import json
import logging import logging
import os
import sys
from lxml import etree from lxml import etree
from lxml.html import rewrite_links from lxml.html import rewrite_links
from path import path from path import path
import json import os
from progress import Progress import sys
from pkg_resources import resource_string from pkg_resources import resource_string
from .capa_module import only_one, ComplexEncoder from .capa_module import only_one, ComplexEncoder
from .editing_module import EditingDescriptor from .editing_module import EditingDescriptor
from .html_checker import check_html from .html_checker import check_html
from progress import Progress
from .stringify import stringify_children from .stringify import stringify_children
from .x_module import XModule from .x_module import XModule
from .xml_module import XmlDescriptor from .xml_module import XmlDescriptor
...@@ -52,6 +53,8 @@ class SelfAssessmentModule(XModule): ...@@ -52,6 +53,8 @@ class SelfAssessmentModule(XModule):
submissions too.) submissions too.)
""" """
STATE_VERSION = 1
# states # states
INITIAL = 'initial' INITIAL = 'initial'
ASSESSING = 'assessing' ASSESSING = 'assessing'
...@@ -102,35 +105,130 @@ class SelfAssessmentModule(XModule): ...@@ -102,35 +105,130 @@ class SelfAssessmentModule(XModule):
else: else:
instance_state = {} instance_state = {}
# Note: score responses are on scale from 0 to max_score instance_state = self.convert_state_to_current_format(instance_state)
self.student_answers = instance_state.get('student_answers', [])
self.scores = instance_state.get('scores', []) # History is a list of tuples of (answer, score, hint), where hint may be
self.hints = instance_state.get('hints', []) # None for any element, and score and hint can be None for the last (current)
# element.
# Scores are on scale from 0 to max_score
self.history = instance_state.get('history', [])
self.state = instance_state.get('state', 'initial') self.state = instance_state.get('state', 'initial')
self.attempts = instance_state.get('attempts', 0)
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
# 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))
self.attempts = instance_state.get('attempts', 0)
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
self.rubric = definition['rubric'] self.rubric = definition['rubric']
self.prompt = definition['prompt'] self.prompt = definition['prompt']
self.submit_message = definition['submitmessage'] self.submit_message = definition['submitmessage']
self.hint_prompt = definition['hintprompt'] self.hint_prompt = definition['hintprompt']
def latest_answer(self):
"""None if not available"""
if not self.history:
return None
return self.history[-1].get('answer')
def latest_score(self):
"""None if not available"""
if not self.history:
return None
return self.history[-1].get('score')
def latest_hint(self):
"""None if not available"""
if not self.history:
return None
return self.history[-1].get('hint')
def new_history_entry(self, answer):
self.history.append({'answer': answer})
def record_latest_score(self, score):
"""Assumes that state is right, so we're adding a score to the latest
history element"""
self.history[-1]['score'] = score
def record_latest_hint(self, hint):
"""Assumes that state is right, so we're adding a score to the latest
history element"""
self.history[-1]['hint'] = hint
def change_state(self, new_state):
"""
A centralized place for state changes--allows for hooks. If the
current state matches the old state, don't run any hooks.
"""
if self.state == new_state:
return
self.state = new_state
if self.state == self.DONE:
self.attempts += 1
@staticmethod
def convert_state_to_current_format(old_state):
"""
This module used to use a problematic state representation. This method
converts that into the new format.
Args:
old_state: dict of state, as passed in. May be old.
Returns:
new_state: dict of new state
"""
if old_state.get('version', 0) == SelfAssessmentModule.STATE_VERSION:
# already current
return old_state
# for now, there's only one older format.
new_state = {'version': SelfAssessmentModule.STATE_VERSION}
def copy_if_present(key):
if key in old_state:
new_state[key] = old_state[key]
for to_copy in ['attempts', 'state']:
copy_if_present(to_copy)
# The answers, scores, and hints need to be kept together to avoid them
# getting out of sync.
# NOTE: Since there's only one problem with a few hundred submissions
# in production so far, not trying to be smart about matching up hints
# and submissions in cases where they got out of sync.
student_answers = old_state.get('student_answers', [])
scores = old_state.get('scores', [])
hints = old_state.get('hints', [])
new_state['history'] = [
{'answer': answer,
'score': score,
'hint': hint}
for answer, score, hint in itertools.izip_longest(
student_answers, scores, hints)]
return new_state
def _allow_reset(self): def _allow_reset(self):
"""Can the module be reset?""" """Can the module be reset?"""
return self.state == self.DONE and self.attempts < self.max_attempts return self.state == self.DONE and self.attempts < self.max_attempts
def get_html(self): def get_html(self):
#set context variables and render template #set context variables and render template
if self.state != self.INITIAL and self.student_answers: if self.state != self.INITIAL:
previous_answer = self.student_answers[-1] latest = self.latest_answer()
previous_answer = latest if latest is not None else ''
else: else:
previous_answer = '' previous_answer = ''
...@@ -149,26 +247,19 @@ class SelfAssessmentModule(XModule): ...@@ -149,26 +247,19 @@ class SelfAssessmentModule(XModule):
# cdodge: perform link substitutions for any references to course static content (e.g. images) # cdodge: perform link substitutions for any references to course static content (e.g. images)
return rewrite_links(html, self.rewrite_content_links) 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): def max_score(self):
""" """
Return max_score Return max_score
""" """
return self._max_score return self._max_score
def get_last_score(self): def get_score(self):
""" """
Returns the last score in the list Returns the last score in the list
""" """
last_score=0 score = self.latest_score()
if(len(self.scores)>0): return {'score': score if score is not None else 0,
last_score=self.scores[len(self.scores)-1] 'total': self._max_score}
return last_score
def get_progress(self): def get_progress(self):
''' '''
...@@ -176,7 +267,7 @@ class SelfAssessmentModule(XModule): ...@@ -176,7 +267,7 @@ class SelfAssessmentModule(XModule):
''' '''
if self._max_score > 0: if self._max_score > 0:
try: try:
return Progress(self.get_last_score(), self._max_score) return Progress(self.get_score()['score'], self._max_score)
except Exception as err: except Exception as err:
log.exception("Got bad progress") log.exception("Got bad progress")
return None return None
...@@ -250,9 +341,10 @@ class SelfAssessmentModule(XModule): ...@@ -250,9 +341,10 @@ class SelfAssessmentModule(XModule):
if self.state in (self.INITIAL, self.ASSESSING): if self.state in (self.INITIAL, self.ASSESSING):
return '' return ''
if self.state == self.DONE and len(self.hints) > 0: if self.state == self.DONE:
# display the previous hint # display the previous hint
hint = self.hints[-1] latest = self.latest_hint()
hint = latest if latest is not None else ''
else: else:
hint = '' hint = ''
...@@ -295,8 +387,9 @@ class SelfAssessmentModule(XModule): ...@@ -295,8 +387,9 @@ class SelfAssessmentModule(XModule):
if self.state != self.INITIAL: if self.state != self.INITIAL:
return self.out_of_sync_error(get) return self.out_of_sync_error(get)
self.student_answers.append(get['student_answer']) # add new history element with answer and empty score and hint.
self.state = self.ASSESSING self.new_history_entry(get['student_answer'])
self.change_state(self.ASSESSING)
return { return {
'success': True, 'success': True,
...@@ -318,27 +411,24 @@ class SelfAssessmentModule(XModule): ...@@ -318,27 +411,24 @@ class SelfAssessmentModule(XModule):
'message_html' only if success is true 'message_html' only if success is true
""" """
n_answers = len(self.student_answers) if self.state != self.ASSESSING:
n_scores = len(self.scores) return self.out_of_sync_error(get)
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: try:
score = int(get['assessment']) score = int(get['assessment'])
except: except ValueError:
return {'success': False, 'error': "Non-integer score value"} return {'success': False, 'error': "Non-integer score value"}
self.scores.append(score) self.record_latest_score(score)
d = {'success': True,} d = {'success': True,}
if score == self.max_score(): if score == self.max_score():
self.state = self.DONE self.change_state(self.DONE)
d['message_html'] = self.get_message_html() d['message_html'] = self.get_message_html()
d['allow_reset'] = self._allow_reset() d['allow_reset'] = self._allow_reset()
else: else:
self.state = self.REQUEST_HINT self.change_state(self.REQUEST_HINT)
d['hint_html'] = self.get_hint_html() d['hint_html'] = self.get_hint_html()
d['state'] = self.state d['state'] = self.state
...@@ -360,19 +450,15 @@ class SelfAssessmentModule(XModule): ...@@ -360,19 +450,15 @@ class SelfAssessmentModule(XModule):
# the same number of hints and answers. # the same number of hints and answers.
return self.out_of_sync_error(get) return self.out_of_sync_error(get)
self.hints.append(get['hint'].lower()) self.record_latest_hint(get['hint'])
self.state = self.DONE self.change_state(self.DONE)
# increment attempts
self.attempts = self.attempts + 1
# To the tracking logs! # To the tracking logs!
event_info = { event_info = {
'selfassessment_id': self.location.url(), 'selfassessment_id': self.location.url(),
'state': { 'state': {
'student_answers': self.student_answers, 'version': self.STATE_VERSION,
'score': self.scores, 'history': self.history,
'hints': self.hints,
} }
} }
self.system.track_function('save_hint', event_info) self.system.track_function('save_hint', event_info)
...@@ -397,7 +483,7 @@ class SelfAssessmentModule(XModule): ...@@ -397,7 +483,7 @@ class SelfAssessmentModule(XModule):
'success': False, 'success': False,
'error': 'Too many attempts.' 'error': 'Too many attempts.'
} }
self.state = self.INITIAL self.change_state(self.INITIAL)
return {'success': True} return {'success': True}
...@@ -407,12 +493,11 @@ class SelfAssessmentModule(XModule): ...@@ -407,12 +493,11 @@ class SelfAssessmentModule(XModule):
""" """
state = { state = {
'student_answers': self.student_answers, 'version': self.STATE_VERSION,
'hints': self.hints, 'history': self.history,
'state': self.state, 'state': self.state,
'scores': self.scores,
'max_score': self._max_score, 'max_score': self._max_score,
'attempts': self.attempts 'attempts': self.attempts,
} }
return json.dumps(state) return json.dumps(state)
......
...@@ -19,11 +19,12 @@ import xmodule ...@@ -19,11 +19,12 @@ import xmodule
from xmodule.x_module import ModuleSystem from xmodule.x_module import ModuleSystem
from mock import Mock from mock import Mock
i4xs = ModuleSystem( test_system = ModuleSystem(
ajax_url='courses/course_id/modx/a_location', ajax_url='courses/course_id/modx/a_location',
track_function=Mock(), track_function=Mock(),
get_module=Mock(), get_module=Mock(),
render_template=Mock(), # "render" to just the context...
render_template=lambda template, context: str(context),
replace_urls=Mock(), replace_urls=Mock(),
user=Mock(), user=Mock(),
filestore=Mock(), filestore=Mock(),
......
...@@ -5,7 +5,7 @@ import unittest ...@@ -5,7 +5,7 @@ import unittest
from xmodule.progress import Progress from xmodule.progress import Progress
from xmodule import x_module from xmodule import x_module
from . import i4xs from . import test_system
class ProgressTest(unittest.TestCase): class ProgressTest(unittest.TestCase):
''' Test that basic Progress objects work. A Progress represents a ''' Test that basic Progress objects work. A Progress represents a
...@@ -133,6 +133,6 @@ class ModuleProgressTest(unittest.TestCase): ...@@ -133,6 +133,6 @@ class ModuleProgressTest(unittest.TestCase):
''' '''
def test_xmodule_default(self): def test_xmodule_default(self):
'''Make sure default get_progress exists, returns None''' '''Make sure default get_progress exists, returns None'''
xm = x_module.XModule(i4xs, 'a://b/c/d/e', None, {}) xm = x_module.XModule(test_system, 'a://b/c/d/e', None, {})
p = xm.get_progress() p = xm.get_progress()
self.assertEqual(p, None) self.assertEqual(p, None)
import json
from mock import Mock
import unittest
from xmodule.self_assessment_module import SelfAssessmentModule
from xmodule.modulestore import Location
from . import test_system
class SelfAssessmentTest(unittest.TestCase):
definition = {'rubric': 'A rubric',
'prompt': 'Who?',
'submitmessage': 'Shall we submit now?',
'hintprompt': 'Consider this...',
}
location = Location(["i4x", "edX", "sa_test", "selfassessment",
"SampleQuestion"])
metadata = {'attempts': '10'}
descriptor = Mock()
def test_import(self):
state = json.dumps({'student_answers': ["Answer 1", "answer 2", "answer 3"],
'scores': [0, 1],
'hints': ['o hai'],
'state': SelfAssessmentModule.ASSESSING,
'attempts': 2})
module = SelfAssessmentModule(test_system, self.location,
self.definition, self.descriptor,
state, {}, metadata=self.metadata)
self.assertEqual(module.get_score()['score'], 0)
self.assertTrue('answer 3' in module.get_html())
self.assertFalse('answer 2' in module.get_html())
module.save_assessment({'assessment': '0'})
self.assertEqual(module.state, module.REQUEST_HINT)
module.save_hint({'hint': 'hint for ans 3'})
self.assertEqual(module.state, module.DONE)
d = module.reset({})
self.assertTrue(d['success'])
self.assertEqual(module.state, module.INITIAL)
# if we now assess as right, skip the REQUEST_HINT state
module.save_answer({'student_answer': 'answer 4'})
module.save_assessment({'assessment': '1'})
self.assertEqual(module.state, module.DONE)
...@@ -288,8 +288,20 @@ class XModule(HTMLSnippet): ...@@ -288,8 +288,20 @@ class XModule(HTMLSnippet):
return '{}' return '{}'
def get_score(self): def get_score(self):
''' Score the student received on the problem. """
''' 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()}
NOTE (vshnayder): not sure if this was the intended return value, but
that's what it's doing now. I suspect that we really want it to just
return a number. Would need to change (at least) capa and
modx_dispatch to match if we did that.
"""
return None return None
def max_score(self): def max_score(self):
......
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